diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c05911d..911bf40 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(tree:*)", "Bash(del Index.cshtml Index.cshtml.cs)", "Bash(dotnet build:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(grep:*)", + "Bash(ls:*)" ], "deny": [], "ask": [] diff --git a/Media.JoshHeaps.Net/Api/BreadboardApi.cs b/Media.JoshHeaps.Net/Api/BreadboardApi.cs new file mode 100644 index 0000000..b42fd6d --- /dev/null +++ b/Media.JoshHeaps.Net/Api/BreadboardApi.cs @@ -0,0 +1,141 @@ +using System.Security.Claims; +using System.Text.Json; +using Media.JoshHeaps.Net.Models; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/breadboard")] +public class BreadboardApi(BreadboardService breadboardService) : ControllerBase +{ + /// + /// Pipeline-level guard so an oversized body is rejected before it is buffered into a + /// string. The validator's 2 MB circuit cap is the real limit; the extra megabyte is + /// headroom for the JSON envelope around it. + /// + private const long MaxRequestBodyBytes = 3L * 1024 * 1024; + + [HttpGet("projects")] + public async Task ListProjects() + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var projects = await breadboardService.GetProjectsAsync(userId.Value); + return Ok(projects); + } + + [HttpPost("projects")] + [RequestSizeLimit(MaxRequestBodyBytes)] + public async Task CreateProject([FromBody] CreateBreadboardProjectRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.CreateProjectAsync(userId.Value, request.Name, request.Description); + return MapResult(result); + } + + [HttpGet("projects/{projectId:long}")] + public async Task GetProject(long projectId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var project = await breadboardService.GetProjectAsync(projectId, userId.Value); + if (project == null) + { + return NotFound(Problems(BreadboardResult.NotFoundMessage)); + } + + return Ok(project); + } + + [HttpPut("projects/{projectId:long}")] + [RequestSizeLimit(MaxRequestBodyBytes)] + public async Task UpdateProject(long projectId, [FromBody] UpdateBreadboardProjectRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.UpdateProjectAsync( + projectId, + userId.Value, + request.Name, + request.Description, + RawCircuit(request.Circuit)); + + return MapResult(result); + } + + [HttpDelete("projects/{projectId:long}")] + public async Task DeleteProject(long projectId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) + { + return Unauthorized(Problems("Not authenticated")); + } + + var result = await breadboardService.DeleteProjectAsync(projectId, userId.Value); + return MapResult(result); + } + + /// A project that belongs to someone else is reported as missing, never as forbidden. + private IActionResult MapResult(BreadboardResult result) => result.Outcome switch + { + BreadboardOutcome.Success => result.Project is null ? NoContent() : Ok(result.Project), + BreadboardOutcome.NotFound => NotFound(new { errors = result.Errors }), + BreadboardOutcome.Invalid => BadRequest(new { errors = result.Errors }), + _ => StatusCode(StatusCodes.Status500InternalServerError, new { errors = result.Errors }) + }; + + private static object Problems(string message) => new { errors = new[] { message } }; + + /// + /// An omitted circuit and an explicit JSON null both mean "leave the circuit alone". + /// Anything else is handed to the service as raw text — the server never reshapes the document. + /// + private static string? RawCircuit(JsonElement? circuit) => + circuit is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } element + ? element.GetRawText() + : null; + + /// + /// JWT first, then the session cookie — the repo-wide pattern. + /// CSRF note: the session path carries no antiforgery token, and is safe today only + /// because of three framework defaults — session cookies are SameSite=Lax, no CORS policy + /// is registered, and an application/json body forces a preflight. Adding a permissive + /// CORS policy or SameSite=None anywhere in this app makes these writes CSRF-able. + /// + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record CreateBreadboardProjectRequest(string? Name, string? Description); + +public record UpdateBreadboardProjectRequest(string? Name, string? Description, JsonElement? Circuit); diff --git a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs index a5e1caf..6807e65 100644 --- a/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs +++ b/Media.JoshHeaps.Net/Api/MedicalDocsApi.cs @@ -17,7 +17,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - var people = await medicalDocsService.GetPeopleAsync(); + var people = await medicalDocsService.GetPeopleAsync(userId.Value); return Ok(people); } @@ -31,13 +31,68 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var person = await medicalDocsService.CreatePersonAsync(request.Name.Trim(), request.DateOfBirth, request.Notes); + var person = await medicalDocsService.CreatePersonAsync(userId.Value, request.Name.Trim(), request.DateOfBirth, request.Notes); if (person == null) return StatusCode(500, new { error = "Failed to create person" }); return Ok(person); } + // --- People Access --- + + [HttpGet("people/{personId}/access")] + public async Task GetPersonAccess(long personId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var users = await medicalDocsService.GetPeopleAccessAsync(personId); + return Ok(users); + } + + [HttpPost("people/{personId}/access")] + public async Task GrantPersonAccess(long personId, [FromBody] GrantAccessRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + if (string.IsNullOrWhiteSpace(request.Username)) + return BadRequest(new { error = "Username is required" }); + + var targetUser = await dbExecutor.ExecuteReaderAsync( + "SELECT id FROM app.users WHERE LOWER(username) = LOWER(@username)", + reader => reader.GetInt64(0), + new { username = request.Username.Trim() }); + + if (targetUser == 0) + return NotFound(new { error = "User not found" }); + + var success = await medicalDocsService.GrantAccessAsync(personId, targetUser); + if (!success) + return StatusCode(500, new { error = "Failed to grant access" }); + + return Ok(new { success = true }); + } + + [HttpDelete("people/{personId}/access/{targetUserId}")] + public async Task RevokePersonAccess(long personId, long targetUserId) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); + + var success = await medicalDocsService.RevokeAccessAsync(personId, targetUserId); + if (!success) + return BadRequest(new { error = "Cannot revoke access — at least one user must have access" }); + + return Ok(new { success = true }); + } + // --- Documents --- [HttpGet("documents")] @@ -46,6 +101,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; @@ -56,7 +112,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc [HttpGet("documents/search")] public async Task SearchDocuments( - [FromQuery] long personId, + [FromQuery] long? personId = null, [FromQuery] string? search = null, [FromQuery] string? classification = null, [FromQuery] string? documentType = null, @@ -72,28 +128,24 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 100) limit = 50; if (offset < 0) offset = 0; - var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, offset, limit); + var documents = await medicalDocsService.SearchDocumentsAsync(personId, search, classification, documentType, doctorId, tagId, conditionId, fromDate, toDate, aiProcessed, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(documents); } [HttpGet("tags")] - public async Task GetPersonTags([FromQuery] long personId) + public async Task GetPersonTags([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var tags = await medicalDocsService.GetPersonTagsAsync(personId); + var tags = await medicalDocsService.GetPersonTagsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(tags); } @@ -104,6 +156,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); if (file == null || file.Length == 0) return BadRequest(new { error = "No file provided" }); @@ -126,6 +179,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required" }); @@ -144,6 +198,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -157,6 +212,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -176,6 +232,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.UpdateDocumentAsync(id, request.Title, request.Description, request.DocumentDate, request.Classification, request.DoctorId); if (!success) return NotFound(new { error = "Document not found" }); @@ -189,6 +246,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var success = await medicalDocsService.DeleteDocumentAsync(id); if (!success) return NotFound(new { error = "Document not found" }); @@ -204,6 +262,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var doc = await medicalDocsService.GetDocumentByIdAsync(id); if (doc == null) return NotFound(new { error = "Document not found" }); @@ -260,21 +319,23 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "document", id)) return Forbid(); var tags = await medicalDocsService.GetDocumentTagsAsync(id); return Ok(tags); } - // --- Doctors --- + // --- Doctors (shared, no per-person access check) --- [HttpGet("doctors")] - public async Task GetDoctors() + public async Task GetDoctors([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - var doctors = await medicalDocsService.GetDoctorsAsync(); + var doctors = await medicalDocsService.GetDoctorsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(doctors); } @@ -284,11 +345,12 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); - var doctor = await medicalDocsService.CreateDoctorAsync(request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); + var doctor = await medicalDocsService.CreateDoctorAsync(request.PersonId, request.Name.Trim(), request.Specialty, request.Phone, request.Address, request.Notes); if (doctor == null) return StatusCode(500, new { error = "Failed to create doctor" }); @@ -301,6 +363,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -317,6 +380,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "doctor", id)) return Forbid(); var success = await medicalDocsService.DeleteDoctorAsync(id); if (!success) return NotFound(new { error = "Doctor not found" }); @@ -327,16 +391,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Conditions --- [HttpGet("conditions")] - public async Task GetConditions([FromQuery] long personId) + public async Task GetConditions([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var conditions = await medicalDocsService.GetConditionsAsync(personId); + var conditions = await medicalDocsService.GetConditionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(conditions); } @@ -349,6 +411,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -365,6 +428,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -381,6 +445,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "condition", id)) return Forbid(); var success = await medicalDocsService.DeleteConditionAsync(id); if (!success) return NotFound(new { error = "Condition not found" }); @@ -391,16 +456,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Prescriptions --- [HttpGet("prescriptions")] - public async Task GetPrescriptions([FromQuery] long personId) + public async Task GetPrescriptions([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId); + var prescriptions = await medicalDocsService.GetPrescriptionsAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(prescriptions); } @@ -413,6 +476,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -429,6 +493,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.MedicationName)) return BadRequest(new { error = "Medication name is required" }); @@ -445,6 +510,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var success = await medicalDocsService.DeletePrescriptionAsync(id); if (!success) return NotFound(new { error = "Prescription not found" }); @@ -460,6 +526,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickups = await medicalDocsService.GetPickupsAsync(id); return Ok(pickups); @@ -471,6 +538,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "prescription", id)) return Forbid(); var pickup = await medicalDocsService.CreatePickupAsync(id, request.PickupDate, request.Quantity, request.Pharmacy, request.Cost, request.Notes); if (pickup == null) @@ -485,6 +553,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "pickup", id)) return Forbid(); var success = await medicalDocsService.DeletePickupAsync(id); if (!success) return NotFound(new { error = "Pickup not found" }); @@ -495,16 +564,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Billing Providers --- [HttpGet("providers")] - public async Task GetProviders([FromQuery] long personId) + public async Task GetProviders([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var providers = await medicalDocsService.GetProvidersAsync(personId); + var providers = await medicalDocsService.GetProvidersAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(providers); } @@ -517,6 +584,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -533,6 +601,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest(new { error = "Name is required" }); @@ -549,6 +618,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderAsync(id); if (!success) return NotFound(new { error = "Provider not found" }); @@ -564,6 +634,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); var payments = await medicalDocsService.GetProviderPaymentsAsync(id); return Ok(payments); @@ -575,6 +646,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider", id)) return Forbid(); if (request.Amount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -592,6 +664,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "provider-payment", id)) return Forbid(); var success = await medicalDocsService.DeleteProviderPaymentAsync(id); if (!success) return NotFound(new { error = "Payment not found" }); @@ -602,16 +675,14 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Bills --- [HttpGet("bills")] - public async Task GetBills([FromQuery] long personId, [FromQuery] long? providerId = null) + public async Task GetBills([FromQuery] long? personId = null, [FromQuery] long? providerId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var bills = await medicalDocsService.GetBillsAsync(personId, providerId); + var bills = await medicalDocsService.GetBillsAsync(personId, providerId, accessUserId: personId.HasValue ? null : userId); return Ok(bills); } @@ -624,6 +695,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0) return BadRequest(new { error = "Person is required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -640,6 +712,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.TotalAmount <= 0) return BadRequest(new { error = "Amount must be greater than 0" }); @@ -656,6 +729,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var success = await medicalDocsService.DeleteBillAsync(id); if (!success) return NotFound(new { error = "Bill not found" }); @@ -669,6 +743,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (request.DocumentId <= 0) return BadRequest(new { error = "Document is required" }); @@ -686,6 +761,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", billId)) return Forbid(); var success = await medicalDocsService.UnlinkDocumentFromBillAsync(billId, docId); if (!success) return NotFound(new { error = "Link not found" }); @@ -701,6 +777,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); var charges = await medicalDocsService.GetChargesAsync(id); return Ok(charges); @@ -712,6 +789,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill", id)) return Forbid(); if (string.IsNullOrWhiteSpace(request.Description)) return BadRequest(new { error = "Description is required" }); @@ -731,6 +809,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (!await HasResourceAccess(userId.Value, "bill-charge", id)) return Forbid(); var success = await medicalDocsService.DeleteChargeAsync(id); if (!success) return NotFound(new { error = "Charge not found" }); @@ -741,19 +820,17 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc // --- Timeline --- [HttpGet("timeline")] - public async Task GetTimeline([FromQuery] long personId, [FromQuery] int offset = 0, [FromQuery] int limit = 100) + public async Task GetTimeline([FromQuery] long? personId = null, [FromQuery] int offset = 0, [FromQuery] int limit = 100) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); - - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); if (limit < 1 || limit > 200) limit = 100; if (offset < 0) offset = 0; - var events = await medicalDocsService.GetTimelineAsync(personId, offset, limit); + var events = await medicalDocsService.GetTimelineAsync(personId, accessUserId: personId.HasValue ? null : userId, offset: offset, limit: limit); return Ok(events); } @@ -768,6 +845,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (personId <= 0 || doctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, personId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(personId, doctorId); return Ok(data); @@ -782,6 +860,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc if (request.PersonId <= 0 || request.DoctorId <= 0) return BadRequest(new { error = "personId and doctorId are required" }); + if (!await HasPersonAccess(userId.Value, request.PersonId)) return Forbid(); var data = await medicalDocsService.GetVisitPrepAsync(request.PersonId, request.DoctorId); var doctor = await medicalDocsService.GetDoctorByIdAsync(request.DoctorId); @@ -793,20 +872,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc } [HttpGet("bills/summary")] - public async Task GetBillSummary([FromQuery] long personId) + public async Task GetBillSummary([FromQuery] long? personId = null) { var userId = GetUserIdFromAuth(); if (userId == null) return Unauthorized(); if (!await HasMedicalAccess(userId.Value)) return Forbid(); + if (personId.HasValue && !await HasPersonAccess(userId.Value, personId.Value)) return Forbid(); - if (personId <= 0) - return BadRequest(new { error = "personId is required" }); - - var summary = await medicalDocsService.GetBillSummaryAsync(personId); + var summary = await medicalDocsService.GetBillSummaryAsync(personId, accessUserId: personId.HasValue ? null : userId); return Ok(summary); } - // --- Auth helpers (same pattern as AdminApi) --- + // --- Auth helpers --- private async Task HasMedicalAccess(long userId) { @@ -815,6 +892,18 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc new { UserId = userId }); } + private async Task HasPersonAccess(long userId, long personId) + { + return await medicalDocsService.HasAccessToPersonAsync(userId, personId); + } + + private async Task HasResourceAccess(long userId, string resourceType, long resourceId) + { + var personId = await medicalDocsService.GetPersonIdForResourceAsync(resourceType, resourceId); + if (personId == null) return false; + return await medicalDocsService.HasAccessToPersonAsync(userId, personId.Value); + } + private long? GetUserIdFromAuth() { var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; @@ -836,7 +925,7 @@ public class MedicalDocsApi(DbExecutor dbExecutor, MedicalDocsService medicalDoc public record CreatePersonRequest(string Name, DateTime? DateOfBirth = null, string? Notes = null); public record CreateNoteRequest(long PersonId, string Title, string? Description = null, DateTime? DocumentDate = null, string? Classification = null); public record UpdateDocumentRequest(string? Title = null, string? Description = null, DateTime? DocumentDate = null, string? Classification = null, long? DoctorId = null); -public record CreateDoctorRequest(string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); +public record CreateDoctorRequest(long PersonId, string Name, string? Specialty = null, string? Phone = null, string? Address = null, string? Notes = null); public record CreateConditionRequest(long PersonId, string Name, DateTime? DiagnosedDate = null, string? Notes = null); public record UpdateConditionRequest(string Name, DateTime? DiagnosedDate = null, string? Notes = null, bool IsActive = true); public record CreatePrescriptionRequest(long PersonId, string MedicationName, string? Dosage = null, string? Frequency = null, long? DoctorId = null, DateTime? StartDate = null, string? Notes = null, string? RxNumber = null); @@ -851,3 +940,4 @@ public record LinkDocumentRequest(long DocumentId); public record CreateChargeRequest(string Description, decimal Amount); public record ProcessBatchRequest(List DocumentIds); public record VisitPrepSummaryRequest(long PersonId, long DoctorId); +public record GrantAccessRequest(string Username); diff --git a/Media.JoshHeaps.Net/Api/SsoApi.cs b/Media.JoshHeaps.Net/Api/SsoApi.cs new file mode 100644 index 0000000..d6273df --- /dev/null +++ b/Media.JoshHeaps.Net/Api/SsoApi.cs @@ -0,0 +1,190 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using Npgsql; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("sso")] +public class SsoApi(DbExecutor db, IConfiguration config, ILogger logger) : ControllerBase +{ + [HttpPost("token")] + public async Task Exchange([FromBody] SsoTokenRequest request) + { + if (request == null || string.IsNullOrWhiteSpace(request.ClientId) || string.IsNullOrWhiteSpace(request.Code)) + { + return BadRequest(new { error = "client_id and code are required" }); + } + + if (!Request.Headers.TryGetValue("X-Client-Secret", out var providedSecret) || string.IsNullOrWhiteSpace(providedSecret)) + { + return Unauthorized(new { error = "missing client credentials" }); + } + + var client = SsoClientRegistry.Find(config, request.ClientId); + if (client == null || !BCrypt.Net.BCrypt.Verify(providedSecret!, client.ClientSecretHash)) + { + logger.LogWarning("SSO token exchange failed: bad client credentials for {ClientId}", request.ClientId); + return Unauthorized(new { error = "invalid client credentials" }); + } + + var codeHash = HashCode(request.Code); + var row = await ConsumeCodeAsync(codeHash); + if (row == null) + { + return BadRequest(new { error = "invalid, expired, or already-used code" }); + } + + if (!string.Equals(row.Value.ClientId, request.ClientId, StringComparison.Ordinal)) + { + return BadRequest(new { error = "code was issued for a different client" }); + } + + if (!client.AllowsRedirectUri(row.Value.RedirectUri)) + { + return BadRequest(new { error = "redirect_uri mismatch" }); + } + + var user = await LoadUserAsync(row.Value.UserId); + if (user == null) + { + return BadRequest(new { error = "user no longer exists" }); + } + + var jwt = await IssueTokenAsync(user, request.ClientId); + return Ok(new SsoTokenResponse + { + AccessToken = jwt, + TokenType = "Bearer", + ExpiresIn = 300 + }); + } + + private async Task<(long UserId, string ClientId, string RedirectUri)?> ConsumeCodeAsync(string codeHash) + { + var connectionString = config["connectionString"]!; + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + long userId; + string clientId; + string redirectUri; + + await using (var select = new NpgsqlCommand( + @"SELECT user_id, client_id, redirect_uri + FROM app.sso_authorization_codes + WHERE code_hash = @h + AND consumed_at IS NULL + AND expires_at > NOW() + FOR UPDATE", conn, tx)) + { + select.Parameters.AddWithValue("@h", codeHash); + await using var reader = await select.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return null; + userId = reader.GetInt64(0); + clientId = reader.GetString(1); + redirectUri = reader.GetString(2); + } + + await using (var update = new NpgsqlCommand( + "UPDATE app.sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = @h", + conn, tx)) + { + update.Parameters.AddWithValue("@h", codeHash); + await update.ExecuteNonQueryAsync(); + } + + await tx.CommitAsync(); + return (userId, clientId, redirectUri); + } + + private async Task LoadUserAsync(long userId) + { + return await db.ExecuteReaderAsync( + "SELECT id, email, username, email_verified FROM app.users WHERE id = @userId AND is_active = true", + reader => new SsoUser + { + Id = reader.GetInt64(0), + Email = reader.GetString(1), + Username = reader.GetString(2), + EmailVerified = reader.GetBoolean(3) + }, + new { userId }); + } + + private async Task IssueTokenAsync(SsoUser user, string audience) + { + var jwtKey = config["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); + var jwtIssuer = config["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var roles = await LoadUserRolesAsync(user.Id); + + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Username), + new Claim("EmailVerified", user.EmailVerified.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) + }; + + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + + var token = new JwtSecurityToken( + issuer: jwtIssuer, + audience: audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private async Task> LoadUserRolesAsync(long userId) + { + return await db.ExecuteListReaderAsync( + @"SELECT r.name + FROM app.user_roles ur + JOIN app.roles r ON ur.role_id = r.id + WHERE ur.user_id = @userId", + reader => reader.GetString(0), + new { userId }); + } + + private static string HashCode(string code) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} + +public sealed class SsoTokenRequest +{ + public string ClientId { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; +} + +public sealed class SsoTokenResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string TokenType { get; set; } = "Bearer"; + public int ExpiresIn { get; set; } +} + +internal sealed class SsoUser +{ + public long Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool EmailVerified { get; set; } +} diff --git a/Media.JoshHeaps.Net/Api/ThemeApi.cs b/Media.JoshHeaps.Net/Api/ThemeApi.cs new file mode 100644 index 0000000..352be33 --- /dev/null +++ b/Media.JoshHeaps.Net/Api/ThemeApi.cs @@ -0,0 +1,74 @@ +using System.Text.RegularExpressions; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/theme")] +public partial class ThemeApi(ThemeService themeService) : ControllerBase +{ + private static readonly HashSet ValidCssVariables = + [ + "--bg-primary", "--bg-secondary", "--bg-tertiary", "--bg-hover", + "--text-primary", "--text-secondary", + "--border-primary", "--border-secondary", + "--accent-primary", "--accent-hover", + "--danger", "--danger-hover", "--success" + ]; + + [GeneratedRegex(@"^#[0-9a-fA-F]{6}$")] + private static partial Regex HexColorRegex(); + + [HttpGet("my")] + public async Task GetMyTheme() + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + var theme = await themeService.GetUserThemeAsync(userId.Value); + if (theme == null) + { + return Ok(new { baseTheme = "light", colorOverrides = new Dictionary() }); + } + + return Ok(new { baseTheme = theme.BaseTheme, colorOverrides = theme.ColorOverrides }); + } + + [HttpPut("my")] + public async Task SaveMyTheme([FromBody] SaveThemeRequest request) + { + var userId = GetUserIdFromAuth(); + if (userId == null) return Unauthorized(); + + if (request.BaseTheme != "dark" && request.BaseTheme != "light") + return BadRequest("baseTheme must be 'dark' or 'light'"); + + foreach (var (key, value) in request.ColorOverrides) + { + if (!ValidCssVariables.Contains(key)) + return BadRequest($"Invalid CSS variable: {key}"); + if (!HexColorRegex().IsMatch(value)) + return BadRequest($"Invalid hex color for {key}: {value}"); + } + + await themeService.SaveUserThemeAsync(userId.Value, request.BaseTheme, request.ColorOverrides); + return Ok(); + } + + private long? GetUserIdFromAuth() + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + return jwtUserId; + + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + return sessionUserId; + + return null; + } +} + +public record SaveThemeRequest(string BaseTheme, Dictionary ColorOverrides); diff --git a/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql new file mode 100644 index 0000000..187086d --- /dev/null +++ b/Media.JoshHeaps.Net/Database/025_password_reset_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE app.password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_prt_token_hash ON app.password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_prt_user_id ON app.password_reset_tokens(user_id); diff --git a/Media.JoshHeaps.Net/Database/026_medical_people_access.sql b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql new file mode 100644 index 0000000..4e9d8b2 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/026_medical_people_access.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS app.medical_people_access ( + id BIGSERIAL PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES app.medical_people(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_mpa_user_id ON app.medical_people_access(user_id); +CREATE INDEX IF NOT EXISTS idx_mpa_person_id ON app.medical_people_access(person_id); diff --git a/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql new file mode 100644 index 0000000..5249524 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/027_medical_doctors_person_id.sql @@ -0,0 +1,7 @@ +-- Add person_id to medical_doctors to scope doctors per person + +ALTER TABLE app.medical_doctors ADD COLUMN IF NOT EXISTS person_id BIGINT REFERENCES app.medical_people(id); + +CREATE INDEX IF NOT EXISTS idx_medical_doctors_person_id ON app.medical_doctors(person_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_medical_doctors_person_name + ON app.medical_doctors(person_id, LOWER(name)) WHERE person_id IS NOT NULL; diff --git a/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql new file mode 100644 index 0000000..d5039d7 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/028_user_theme_overrides.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS app.user_theme_overrides ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + base_theme TEXT NOT NULL DEFAULT 'light', + color_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +CREATE INDEX IF NOT EXISTS idx_uto_user_id ON app.user_theme_overrides(user_id); diff --git a/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql b/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql new file mode 100644 index 0000000..f809010 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/030_sso_authorization_codes.sql @@ -0,0 +1,15 @@ +-- SSO authorization codes for the OAuth2 authorization-code flow. +-- The raw code is never stored; we persist the SHA-256 hash only. +-- Codes are single-use and short-lived (see Sso:CodeLifetimeSeconds in config). + +CREATE TABLE IF NOT EXISTS app.sso_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sso_codes_expires ON app.sso_authorization_codes(expires_at); diff --git a/Media.JoshHeaps.Net/Database/031_breadboard.sql b/Media.JoshHeaps.Net/Database/031_breadboard.sql new file mode 100644 index 0000000..f05f169 --- /dev/null +++ b/Media.JoshHeaps.Net/Database/031_breadboard.sql @@ -0,0 +1,26 @@ +-- Breadboard circuit simulator. +-- A project owns one circuit document (schema v1) stored as JSONB: boards, +-- components and wires laid out on full-size 830-point breadboards. +-- Memory images hold the contents of memory components keyed by the component's +-- uid within the circuit document; no endpoints use them yet. + +CREATE TABLE IF NOT EXISTS app.breadboard_projects ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES app.users(id), + name TEXT NOT NULL, + description TEXT, + circuit JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_breadboard_projects_user_id ON app.breadboard_projects(user_id); + +CREATE TABLE IF NOT EXISTS app.breadboard_memory_images ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES app.breadboard_projects(id) ON DELETE CASCADE, + component_uid TEXT NOT NULL, + data BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_breadboard_memory_images UNIQUE (project_id, component_uid) +); diff --git a/Media.JoshHeaps.Net/Models/BreadboardProject.cs b/Media.JoshHeaps.Net/Models/BreadboardProject.cs new file mode 100644 index 0000000..655475b --- /dev/null +++ b/Media.JoshHeaps.Net/Models/BreadboardProject.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Nodes; + +namespace Media.JoshHeaps.Net.Models; + +/// Row shape for the project list — deliberately excludes the circuit document. +public sealed record BreadboardProjectSummary( + long Id, + string Name, + string? Description, + DateTime CreatedAt, + DateTime UpdatedAt); + +/// +/// A single project including its circuit document. The circuit is opaque to C# — +/// it is stored and validated as text and only parsed here so the API emits it as a +/// real JSON object rather than a JSON-encoded string. +/// +public sealed record BreadboardProject( + long Id, + string Name, + string? Description, + JsonNode? Circuit, + DateTime CreatedAt, + DateTime UpdatedAt); diff --git a/Media.JoshHeaps.Net/Models/BreadboardResult.cs b/Media.JoshHeaps.Net/Models/BreadboardResult.cs new file mode 100644 index 0000000..fd19c5f --- /dev/null +++ b/Media.JoshHeaps.Net/Models/BreadboardResult.cs @@ -0,0 +1,35 @@ +namespace Media.JoshHeaps.Net.Models; + +public enum BreadboardOutcome +{ + Success, + NotFound, + Invalid, + Failed +} + +/// +/// Outcome of a write against a breadboard project. The API layer maps the outcome to a +/// status code; every rule that produces lives in +/// the service (or the validator it delegates to), never in the controller. +/// +public sealed record BreadboardResult( + BreadboardOutcome Outcome, + IReadOnlyList Errors, + BreadboardProject? Project) +{ + /// The single wording for "gone or never yours" — read and write paths share it. + public const string NotFoundMessage = "Project not found"; + + public static BreadboardResult Succeeded(BreadboardProject? project = null) => + new(BreadboardOutcome.Success, [], project); + + public static BreadboardResult Missing() => + new(BreadboardOutcome.NotFound, [NotFoundMessage], null); + + public static BreadboardResult Invalid(IReadOnlyList errors) => + new(BreadboardOutcome.Invalid, errors, null); + + public static BreadboardResult Failed(string error) => + new(BreadboardOutcome.Failed, [error], null); +} diff --git a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs index d655513..50866cd 100644 --- a/Media.JoshHeaps.Net/Models/MedicalDoctor.cs +++ b/Media.JoshHeaps.Net/Models/MedicalDoctor.cs @@ -3,6 +3,7 @@ namespace Media.JoshHeaps.Net.Models; public class MedicalDoctor { public long Id { get; set; } + public long PersonId { get; set; } public string Name { get; set; } = string.Empty; public string? Specialty { get; set; } public string? Phone { get; set; } diff --git a/Media.JoshHeaps.Net/Models/MedicalPerson.cs b/Media.JoshHeaps.Net/Models/MedicalPerson.cs index 14e9c25..02ac67a 100644 --- a/Media.JoshHeaps.Net/Models/MedicalPerson.cs +++ b/Media.JoshHeaps.Net/Models/MedicalPerson.cs @@ -9,3 +9,9 @@ public class MedicalPerson public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } } + +public class PersonAccessUser +{ + public long Id { get; set; } + public string Username { get; set; } = string.Empty; +} diff --git a/Media.JoshHeaps.Net/Models/TimelineEvent.cs b/Media.JoshHeaps.Net/Models/TimelineEvent.cs index b90af3d..30f606b 100644 --- a/Media.JoshHeaps.Net/Models/TimelineEvent.cs +++ b/Media.JoshHeaps.Net/Models/TimelineEvent.cs @@ -4,6 +4,7 @@ public class TimelineEvent { public string EventType { get; set; } = ""; public long Id { get; set; } + public long PersonId { get; set; } public string? Label { get; set; } public string? Detail { get; set; } public string? SubType { get; set; } diff --git a/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs new file mode 100644 index 0000000..093845a --- /dev/null +++ b/Media.JoshHeaps.Net/Models/UserThemeOverrides.cs @@ -0,0 +1,11 @@ +namespace Media.JoshHeaps.Net.Models; + +public class UserThemeOverrides +{ + public long Id { get; set; } + public long UserId { get; set; } + public string BaseTheme { get; set; } = "light"; + public Dictionary ColorOverrides { get; set; } = new(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/Media.JoshHeaps.Net/Pages/Breadboard.cshtml b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml new file mode 100644 index 0000000..68b6b43 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml @@ -0,0 +1,176 @@ +@page +@model Media.JoshHeaps.Net.Pages.BreadboardModel +@{ + ViewData["Title"] = "Breadboard Simulator"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@* Class names are this page's own (bbp- prefix, styled in breadboard-projects.css). + This app never links Bootstrap's stylesheet, so Bootstrap class names here would imply + styling that does not exist. Colours come from the site.css custom properties, so the + page follows the theme toggle without any theme-specific rules. *@ +
+
+
+ + + + + + +

Breadboard Simulator

+
+ Logout +
+ + + +
+

New project

+
+
+ + +
+
+ + +
+ +
+
+ + @if (Model.Projects.Count == 0) + { +

No projects yet. Create one above to start wiring.

+ } + else + { +
+ @foreach (var project in Model.Projects) + { +
+
+ @project.Name + @if (!string.IsNullOrWhiteSpace(project.Description)) + { +
@project.Description
+ } + @* Rendered in the viewer's timezone by the module script below, not the server's. *@ +
+
+ +
+ } +
+ } +
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs new file mode 100644 index 0000000..21f2fd6 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Breadboard.cshtml.cs @@ -0,0 +1,26 @@ +using Media.JoshHeaps.Net.Models; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages; + +public class BreadboardModel(BreadboardService breadboardService) : AuthenticatedPageModel +{ + public List Projects { get; private set; } = []; + + public async Task OnGetAsync() + { + RequireAuthentication(); + LoadUserSession(); + + // RequireAuthentication only queues a redirect, so bail out explicitly rather than + // rendering the page against a zero user id. + if (UserId == 0) + { + return Redirect("/Login"); + } + + Projects = await breadboardService.GetProjectsAsync(UserId); + return Page(); + } +} diff --git a/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml new file mode 100644 index 0000000..2f7d88c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml @@ -0,0 +1,24 @@ +@page +@model Media.JoshHeaps.Net.Pages.BreadboardEditorModel +@{ + ViewData["Title"] = $"Breadboard - {Model.ProjectName}"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@* Shell only. Everything inside #breadboard-editor is built by the editor module, so the + component palette can grow without a Razor edit. Contract agreed with frontend-impl: + the root id and the three data-attributes below are the entire server-to-editor surface. *@ +
+
+
+ +@section Scripts { + +} diff --git a/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs new file mode 100644 index 0000000..a1384ff --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/BreadboardEditor.cshtml.cs @@ -0,0 +1,34 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages; + +public class BreadboardEditorModel(BreadboardService breadboardService) : AuthenticatedPageModel +{ + public long ProjectId { get; private set; } + public string ProjectName { get; private set; } = string.Empty; + + public async Task OnGetAsync([FromQuery] long projectId) + { + RequireAuthentication(); + LoadUserSession(); + + if (UserId == 0) + { + return Redirect("/Login"); + } + + // Ownership check lives in SQL, so someone else's project is simply not found. The + // summary lookup deliberately skips the circuit — the editor module fetches the + // document itself, and pulling it here would parse a multi-megabyte payload to throw away. + var project = await breadboardService.GetProjectSummaryAsync(projectId, UserId); + if (project == null) + { + return NotFound(); + } + + ProjectId = project.Id; + ProjectName = project.Name; + return Page(); + } +} diff --git a/Media.JoshHeaps.Net/Pages/Gallery.cshtml b/Media.JoshHeaps.Net/Pages/Gallery.cshtml index 9c8fd76..94cf68b 100644 --- a/Media.JoshHeaps.Net/Pages/Gallery.cshtml +++ b/Media.JoshHeaps.Net/Pages/Gallery.cshtml @@ -21,6 +21,7 @@

Welcome back, @Model.Dashboard?.Username!

+
@if (Model.Dashboard?.EmailVerified == false) { diff --git a/Media.JoshHeaps.Net/Pages/Landing.cshtml b/Media.JoshHeaps.Net/Pages/Landing.cshtml index 6052410..ce6073e 100644 --- a/Media.JoshHeaps.Net/Pages/Landing.cshtml +++ b/Media.JoshHeaps.Net/Pages/Landing.cshtml @@ -93,6 +93,35 @@
+ +
+
+
+ + + + + + + +
+
+
+

Breadboard Simulator

+

Build and simulate logic circuits on a virtual solderless breadboard

+
+
+ +
+ @if (Model.IsAdmin) { diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml b/Media.JoshHeaps.Net/Pages/Login.cshtml index 79ee689..45f2cc9 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml @@ -43,6 +43,10 @@
@Html.AntiForgeryToken() + @if (!string.IsNullOrEmpty(Model.ReturnUrl)) + { + + }
-
- - + diff --git a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs index ec68471..12ba8a6 100644 --- a/Media.JoshHeaps.Net/Pages/Login.cshtml.cs +++ b/Media.JoshHeaps.Net/Pages/Login.cshtml.cs @@ -16,17 +16,20 @@ public class LoginModel(AuthService authService) : PageModel [BindProperty] public bool RememberMe { get; set; } + [BindProperty(SupportsGet = true)] + public string? ReturnUrl { get; set; } + public string? ErrorMessage { get; set; } public string? SuccessMessage { get; set; } public string? WarningMessage { get; set; } - public void OnGet([FromQuery] string? registered, [FromQuery] string? verified) + public void OnGet([FromQuery] string? registered, [FromQuery] string? verified, [FromQuery] string? reset) { // Check if user is already logged in var userId = HttpContext.Session.GetString("UserId"); if (!string.IsNullOrEmpty(userId)) { - Response.Redirect("/Landing"); + Response.Redirect(SafeReturnUrl() ?? "/Landing"); return; } @@ -41,6 +44,12 @@ public class LoginModel(AuthService authService) : PageModel { SuccessMessage = "Email verified! You can now sign in."; } + + // Show success message if password was just reset + if (reset == "true") + { + SuccessMessage = "Your password has been reset. You can now sign in with your new password."; + } } public async Task OnPostAsync() @@ -84,6 +93,9 @@ public class LoginModel(AuthService authService) : PageModel Response.Cookies.Append("RememberMe", userInfo.Id.ToString(), cookieOptions); } - return Redirect("/Landing"); + return Redirect(SafeReturnUrl() ?? "/Landing"); } + + private string? SafeReturnUrl() => + !string.IsNullOrWhiteSpace(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) ? ReturnUrl : null; } diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml new file mode 100644 index 0000000..7df47af --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml @@ -0,0 +1,102 @@ +@page +@model Media.JoshHeaps.Net.Pages.LoginHelpModel +@{ + ViewData["Title"] = "Login Help"; + Layout = "_Layout"; +} + +@section Styles { + +} + +@section Scripts { + +} + +
+
+ @if (Model.ShowResetForm) + { +
+

Reset Password

+

Enter your new password below

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + + + @Html.AntiForgeryToken() + + +
+ +
+ + +
+
+
+
+
+
+
+ +
+ +
+ + +
+
+
+ + + + } + else + { +
+

Forgot Password

+

Enter your email to receive a reset link

+
+ + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { +
+ @Model.ErrorMessage +
+ } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { +
+ @Model.SuccessMessage +
+ } + +
+ @Html.AntiForgeryToken() + +
+ + +
+
+ + +
+ } + + +
+
diff --git a/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs new file mode 100644 index 0000000..f55031c --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/LoginHelp.cshtml.cs @@ -0,0 +1,111 @@ +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Media.JoshHeaps.Net.Pages; + +public class LoginHelpModel(AuthService authService, EmailService emailService, ILogger logger) : PageModel +{ + [BindProperty] + public string Email { get; set; } = string.Empty; + + [BindProperty] + public string Token { get; set; } = string.Empty; + + [BindProperty] + public string NewPassword { get; set; } = string.Empty; + + [BindProperty] + public string ConfirmPassword { get; set; } = string.Empty; + + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + public bool ShowResetForm { get; set; } + + public async Task OnGetAsync([FromQuery] string? token) + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (!string.IsNullOrEmpty(token)) + { + var (valid, error) = await authService.ValidatePasswordResetTokenAsync(token); + if (valid) + { + ShowResetForm = true; + Token = token; + } + else + { + ErrorMessage = error; + } + } + + return Page(); + } + + public async Task OnPostRequestResetAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(Email)) + { + ErrorMessage = "Please enter your email address."; + return Page(); + } + + var (success, error, token, username) = await authService.RequestPasswordResetAsync(Email.Trim()); + + if (!success) + { + logger.LogError("Password reset request failed for {Email}: {Error}", Email, error); + } + + // Send email if we got a token back (user exists and is eligible) + if (token != null) + { + await emailService.SendPasswordResetEmailAsync(Email.Trim(), username ?? Email.Split('@')[0], token); + } + + // Always show the same message regardless of whether the email exists + SuccessMessage = "If an account exists with that email, you will receive a password reset link shortly."; + return Page(); + } + + public async Task OnPostResetPasswordAsync() + { + // Redirect if already logged in + var userId = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userId)) + return Redirect("/Landing"); + + if (string.IsNullOrWhiteSpace(NewPassword) || NewPassword.Length < 8) + { + ErrorMessage = "Password must be at least 8 characters."; + ShowResetForm = true; + return Page(); + } + + if (NewPassword != ConfirmPassword) + { + ErrorMessage = "Passwords do not match."; + ShowResetForm = true; + return Page(); + } + + var (success, error) = await authService.ResetPasswordAsync(Token, NewPassword); + + if (!success) + { + ErrorMessage = error; + return Page(); + } + + return Redirect("/Login?reset=true"); + } +} diff --git a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml index abd2830..be1579f 100644 --- a/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml +++ b/Media.JoshHeaps.Net/Pages/MedicalDocs.cshtml @@ -285,6 +285,23 @@
+ + + @section Scripts { diff --git a/Media.JoshHeaps.Net/Pages/Profile.cshtml b/Media.JoshHeaps.Net/Pages/Profile.cshtml index 18af864..58b263e 100644 --- a/Media.JoshHeaps.Net/Pages/Profile.cshtml +++ b/Media.JoshHeaps.Net/Pages/Profile.cshtml @@ -7,133 +7,8 @@ @section Styles { - + + }
@@ -176,5 +51,14 @@
+
+
+ Custom Colors + Personalize individual theme colors +
+ +
+ + diff --git a/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml new file mode 100644 index 0000000..e2f7b2f --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml @@ -0,0 +1,5 @@ +@page "/sso/authorize" +@model Media.JoshHeaps.Net.Pages.Sso.AuthorizeModel +@{ + Layout = null; +} diff --git a/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs new file mode 100644 index 0000000..d585144 --- /dev/null +++ b/Media.JoshHeaps.Net/Pages/Sso/Authorize.cshtml.cs @@ -0,0 +1,59 @@ +using System.Security.Cryptography; +using System.Text; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Media.JoshHeaps.Net.Pages.Sso; + +public class AuthorizeModel(DbExecutor db, IConfiguration config, ILogger logger) : AuthenticatedPageModel +{ + public async Task OnGetAsync( + [FromQuery(Name = "client_id")] string? clientId, + [FromQuery(Name = "redirect_uri")] string? redirectUri, + [FromQuery] string? state) + { + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(redirectUri) || string.IsNullOrWhiteSpace(state)) + { + return BadRequest("client_id, redirect_uri, and state are required"); + } + + var client = SsoClientRegistry.Find(config, clientId); + if (client == null) return BadRequest("unknown client_id"); + if (!client.AllowsRedirectUri(redirectUri)) return BadRequest("redirect_uri is not registered for this client"); + + if (!IsAuthenticated()) + { + var original = $"/sso/authorize?client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}&state={Uri.EscapeDataString(state)}"; + return Redirect($"/Login?ReturnUrl={Uri.EscapeDataString(original)}"); + } + + LoadUserSession(); + + var code = GenerateCode(); + var codeHash = HashCode(code); + var lifetime = int.TryParse(config["Sso:CodeLifetimeSeconds"], out var s) ? s : 60; + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(lifetime); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, expires_at) + VALUES (@codeHash, @clientId, @userId, @redirectUri, @expiresAt)", + new { codeHash, clientId, userId = UserId, redirectUri, expiresAt }); + + logger.LogInformation("SSO code issued for user {UserId} to client {ClientId}", UserId, clientId); + + var separator = redirectUri.Contains('?') ? '&' : '?'; + return Redirect($"{redirectUri}{separator}code={Uri.EscapeDataString(code)}&state={Uri.EscapeDataString(state)}"); + } + + private static string GenerateCode() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).Replace("+", "-").Replace("/", "_").TrimEnd('='); + } + + private static string HashCode(string code) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index dab4ad8..3af69d8 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -19,7 +19,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(); // Add session support diff --git a/Media.JoshHeaps.Net/Services/AuthService.cs b/Media.JoshHeaps.Net/Services/AuthService.cs index 5106df3..340e11b 100644 --- a/Media.JoshHeaps.Net/Services/AuthService.cs +++ b/Media.JoshHeaps.Net/Services/AuthService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Models; @@ -302,4 +304,173 @@ public class AuthService(DbExecutor db) new { userId, lastLogin = DateTime.UtcNow } ); } + + public static string GenerateSecureToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + public static string HashToken(string token) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + public async Task<(bool Success, string? Error, string? Token, string? Username)> RequestPasswordResetAsync(string email) + { + try + { + var userRow = await db.ExecuteReaderAsync( + "SELECT id, username, is_active, locked_until FROM app.users WHERE email = @email", + reader => new + { + UserId = reader.GetInt64(0), + Username = reader.GetString(1), + IsActive = reader.GetBoolean(2), + LockedUntil = reader.IsDBNull(3) ? (DateTime?)null : reader.GetDateTime(3) + }, + new { email } + ); + + if (userRow == null) + { + // Artificial delay to prevent timing-based email enumeration + await Task.Delay(Random.Shared.Next(100, 300)); + return (true, null, null, null); + } + + // Silently succeed for inactive/locked accounts (don't reveal state) + if (!userRow.IsActive || + (userRow.LockedUntil.HasValue && userRow.LockedUntil.Value > DateTime.UtcNow)) + { + return (true, null, null, null); + } + + // Rate limit: max 3 requests per hour + var recentCount = await db.ExecuteAsync( + @"SELECT COUNT(*) FROM app.password_reset_tokens + WHERE user_id = @userId AND created_at > @cutoff", + new { userId = userRow.UserId, cutoff = DateTimeOffset.UtcNow.AddHours(-1) } + ); + + if (recentCount >= 3) + { + return (true, null, null, null); + } + + // Invalidate all existing unused tokens for this user + await db.ExecuteNonQueryAsync( + @"UPDATE app.password_reset_tokens + SET used_at = @now + WHERE user_id = @userId AND used_at IS NULL", + new { userId = userRow.UserId, now = DateTimeOffset.UtcNow } + ); + + // Generate and store new token + var token = GenerateSecureToken(); + var tokenHash = HashToken(token); + var expiresAt = DateTimeOffset.UtcNow.AddHours(1); + + await db.ExecuteNonQueryAsync( + @"INSERT INTO app.password_reset_tokens (user_id, token_hash, expires_at) + VALUES (@userId, @tokenHash, @expiresAt)", + new { userId = userRow.UserId, tokenHash, expiresAt } + ); + + return (true, null, token, userRow.Username); + } + catch (Exception ex) + { + return (false, $"Password reset request failed: {ex.Message}", null, null); + } + } + + public async Task<(bool Valid, string? Error)> ValidatePasswordResetTokenAsync(string token) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + ExpiresAt = reader.GetFieldValue(0), + UsedAt = reader.IsDBNull(1) ? (DateTimeOffset?)null : reader.GetFieldValue(1) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Token validation failed: {ex.Message}"); + } + } + + public async Task<(bool Success, string? Error)> ResetPasswordAsync(string token, string newPassword) + { + try + { + var tokenHash = HashToken(token); + + var tokenRow = await db.ExecuteReaderAsync( + @"SELECT id, user_id, expires_at, used_at FROM app.password_reset_tokens + WHERE token_hash = @tokenHash", + reader => new + { + Id = reader.GetInt64(0), + UserId = reader.GetInt64(1), + ExpiresAt = reader.GetFieldValue(2), + UsedAt = reader.IsDBNull(3) ? (DateTimeOffset?)null : reader.GetFieldValue(3) + }, + new { tokenHash } + ); + + if (tokenRow == null) + return (false, "Invalid or expired reset link. Please request a new one."); + + if (tokenRow.UsedAt.HasValue) + return (false, "This reset link has already been used. Please request a new one."); + + if (tokenRow.ExpiresAt < DateTimeOffset.UtcNow) + return (false, "This reset link has expired. Please request a new one."); + + // Hash new password and update user + var passwordHash = HashPassword(newPassword); + await db.ExecuteNonQueryAsync( + @"UPDATE app.users + SET password_hash = @passwordHash, failed_login_attempts = 0, locked_until = NULL + WHERE id = @userId", + new { userId = tokenRow.UserId, passwordHash } + ); + + // Mark token as used + await db.ExecuteNonQueryAsync( + "UPDATE app.password_reset_tokens SET used_at = @now WHERE id = @tokenId", + new { tokenId = tokenRow.Id, now = DateTimeOffset.UtcNow } + ); + + return (true, null); + } + catch (Exception ex) + { + return (false, $"Password reset failed: {ex.Message}"); + } + } } diff --git a/Media.JoshHeaps.Net/Services/BreadboardService.cs b/Media.JoshHeaps.Net/Services/BreadboardService.cs new file mode 100644 index 0000000..bf8efac --- /dev/null +++ b/Media.JoshHeaps.Net/Services/BreadboardService.cs @@ -0,0 +1,293 @@ +using System.Text.Json.Nodes; +using Media.JoshHeaps.Net.Models; +using Npgsql; + +namespace Media.JoshHeaps.Net.Services; + +/// +/// Data access and write rules for breadboard projects. The circuit document is opaque +/// here: it travels as JSON text, is checked by before +/// it ever reaches the database, and is only parsed on the way out so the API can emit it +/// as a JSON object. Ownership is enforced in SQL — every statement is scoped by user_id, +/// so a project belonging to someone else is indistinguishable from one that never existed. +/// +public class BreadboardService(DbExecutor db, BreadboardValidator validator, ILogger logger) +{ + public const int MaxNameLength = 200; + public const int MaxDescriptionLength = 2000; + public const int MaxProjectsPerUser = 200; + + private const string EmptyCircuitJson = """{"version":1,"boards":[],"components":[],"wires":[]}"""; + + public async Task> GetProjectsAsync(long userId) + { + try + { + var query = @" + SELECT id, name, description, created_at, updated_at + FROM app.breadboard_projects + WHERE user_id = @userId + ORDER BY updated_at DESC"; + + return await db.ExecuteListReaderAsync(query, MapSummary, new { userId }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to list breadboard projects for user {UserId}", userId); + return []; + } + } + + /// + /// Ownership check plus display fields, without dragging the circuit document along. + /// Pages that only need to know "is this mine, and what is it called" use this so the + /// document is fetched exactly once, by the editor module over the API. + /// + public async Task GetProjectSummaryAsync(long projectId, long userId) + { + var query = @" + SELECT id, name, description, created_at, updated_at + FROM app.breadboard_projects + WHERE id = @projectId AND user_id = @userId"; + + return await db.ExecuteReaderAsync(query, MapSummary, new { projectId, userId }); + } + + /// + /// Returns null only when the project does not exist or is not this user's. Database + /// failures deliberately propagate — a caller must never turn an outage into a 404. + /// + public async Task GetProjectAsync(long projectId, long userId) + { + var query = @" + SELECT id, name, description, circuit::text, created_at, updated_at + FROM app.breadboard_projects + WHERE id = @projectId AND user_id = @userId"; + + return await db.ExecuteReaderAsync(query, MapProject, new { projectId, userId }); + } + + public async Task CreateProjectAsync(long userId, string? name, string? description) + { + var errors = new List(); + + var trimmedName = ValidateName(name, errors); + var trimmedDescription = ValidateDescription(description, errors); + + if (errors.Count > 0) + { + return BreadboardResult.Invalid(errors); + } + + // Even the starter document goes through the validator — there is no trusted path by + // which a circuit reaches the database unchecked. It is server-authored though, so a + // rejection means the seed and the validator have drifted: that is our bug, not the + // caller's, and it must not surface as a 400 blaming their input. + var circuitCheck = validator.Validate(EmptyCircuitJson); + if (!circuitCheck.IsValid) + { + logger.LogError( + "Seed breadboard circuit document was rejected by the validator: {Errors}", + string.Join(", ", circuitCheck.Errors)); + return BreadboardResult.Failed("Failed to create project"); + } + + try + { + // An authenticated user can otherwise grow the table without bound, 2 MB at a time. + var projectCount = await db.ExecuteAsync( + "SELECT COUNT(*) FROM app.breadboard_projects WHERE user_id = @userId", + new { userId }); + + if (projectCount >= MaxProjectsPerUser) + { + return BreadboardResult.Invalid([$"Project limit reached ({MaxProjectsPerUser} per account)"]); + } + + var query = @" + INSERT INTO app.breadboard_projects (user_id, name, description, circuit, created_at, updated_at) + VALUES (@userId, @name, @description, @circuit::jsonb, NOW(), NOW()) + RETURNING id, name, description, circuit::text, created_at, updated_at"; + + var project = await db.ExecuteReaderAsync(query, MapProject, new + { + userId, + name = trimmedName, + description = trimmedDescription, + circuit = EmptyCircuitJson + }); + + return project is null + ? BreadboardResult.Failed("Failed to create project") + : BreadboardResult.Succeeded(project); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create breadboard project for user {UserId}", userId); + return BreadboardResult.Failed("Failed to create project"); + } + } + + /// + /// Partial update: a null argument means "leave unchanged". An explicitly blank + /// description clears the column. + /// + public async Task UpdateProjectAsync( + long projectId, + long userId, + string? name, + string? description, + string? circuitJson) + { + var errors = new List(); + + var setName = name is not null; + var trimmedName = setName ? ValidateName(name, errors) : null; + + var setDescription = description is not null; + var trimmedDescription = setDescription ? ValidateDescription(description, errors) : null; + + var setCircuit = circuitJson is not null; + if (setCircuit) + { + var circuitCheck = validator.Validate(circuitJson!); + if (!circuitCheck.IsValid) + { + errors.AddRange(circuitCheck.Errors); + } + } + + if (errors.Count > 0) + { + return BreadboardResult.Invalid(errors); + } + + try + { + var query = @" + UPDATE app.breadboard_projects + SET name = CASE WHEN @setName THEN @name ELSE name END, + -- NULLIF keeps @description a non-null text parameter, so the server never has + -- to infer a type for an untyped NULL, and clearing still works. + description = CASE WHEN @setDescription THEN NULLIF(@description, '') ELSE description END, + circuit = CASE WHEN @setCircuit THEN @circuit::jsonb ELSE circuit END, + -- A no-op save must not reorder the project list, which sorts on updated_at. + updated_at = CASE WHEN @setName OR @setDescription OR @setCircuit THEN NOW() ELSE updated_at END + WHERE id = @projectId AND user_id = @userId"; + + var rows = await db.ExecuteNonQueryAsync(query, new + { + projectId, + userId, + setName, + // Guarded by @setName — the CASE is what keeps this placeholder off the column. + name = trimmedName ?? string.Empty, + setDescription, + description = trimmedDescription ?? string.Empty, + setCircuit, + // Guarded by @setCircuit, but the ::jsonb cast still parses it, so it must be + // valid JSON even on the branch the CASE discards. + circuit = circuitJson ?? EmptyCircuitJson + }); + + return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to update breadboard project {ProjectId} for user {UserId}", projectId, userId); + return BreadboardResult.Failed("Failed to update project"); + } + } + + public async Task DeleteProjectAsync(long projectId, long userId) + { + try + { + var query = "DELETE FROM app.breadboard_projects WHERE id = @projectId AND user_id = @userId"; + var rows = await db.ExecuteNonQueryAsync(query, new { projectId, userId }); + + return rows == 0 ? BreadboardResult.Missing() : BreadboardResult.Succeeded(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to delete breadboard project {ProjectId} for user {UserId}", projectId, userId); + return BreadboardResult.Failed("Failed to delete project"); + } + } + + private static BreadboardProjectSummary MapSummary(NpgsqlDataReader reader) => new( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.GetDateTime(3), + reader.GetDateTime(4)); + + private BreadboardProject MapProject(NpgsqlDataReader reader) => new( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + ParseCircuit(reader.GetString(3), reader.GetInt64(0)), + reader.GetDateTime(4), + reader.GetDateTime(5)); + + private JsonNode? ParseCircuit(string circuitJson, long projectId) + { + try + { + return JsonNode.Parse(circuitJson); + } + catch (Exception ex) + { + logger.LogError(ex, "Stored circuit for breadboard project {ProjectId} is not parseable JSON", projectId); + return null; + } + } + + private static string ValidateName(string? name, List errors) + { + var trimmed = name?.Trim() ?? string.Empty; + + if (trimmed.Length == 0) + { + errors.Add("Name is required"); + } + else if (trimmed.Length > MaxNameLength) + { + errors.Add($"Name must be {MaxNameLength} characters or fewer"); + } + else if (trimmed.Any(char.IsControl)) + { + // PostgreSQL rejects NUL in text outright; catching it here makes it a 400 rather + // than a generic 500, and the rest of the control range has no business in a name. + errors.Add("Name must not contain control characters"); + } + + return trimmed; + } + + private static string? ValidateDescription(string? description, List errors) + { + var trimmed = description?.Trim(); + + if (string.IsNullOrEmpty(trimmed)) + { + return null; + } + + if (trimmed.Length > MaxDescriptionLength) + { + errors.Add($"Description must be {MaxDescriptionLength} characters or fewer"); + } + else if (trimmed.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t'))) + { + // Line breaks and tabs are legitimate in free text — CR is in the list because a + //