using System; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using System.Linq; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using SNote.Models; using SNote.Server.Data; using SNote.Server.Security; namespace SNote.Server.Endpoints; public static class NodeEndpoints { public static void MapNodeEndpoints(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/nodes"); // Helper to check user access and fetch decrypted content async Task GetNodeWithAccessAsync(string uuid, string username, string? masterKey, ServerDbContext db) { var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted); if (node == null) return null; // Check if user is owner bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase); // Fetch ACL entries var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync(); var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase)); var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase)); bool canView = isOwner || (userAcl?.CanView ?? false) || (everyoneAcl?.CanView ?? false); if (!canView) return null; bool canEdit = isOwner || (userAcl?.CanEdit ?? false) || (everyoneAcl?.CanEdit ?? false); bool canDelete = isOwner || (userAcl?.CanDelete ?? false) || (everyoneAcl?.CanDelete ?? false); bool canRename = isOwner || (userAcl?.CanRename ?? false) || (everyoneAcl?.CanRename ?? false); // Determine if encrypted bool isPublic = everyoneAcl?.CanView ?? false; string plaintext = node.Content; if (!isPublic && !string.IsNullOrEmpty(node.Content)) { if (string.IsNullOrEmpty(masterKey)) { // No key provided; return encrypted content or a placeholder plaintext = "[Encrypted Content - Provide Encryption Key]"; } else { // Fetch the encrypted node key for this user var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower()); if (nodeKey != null) { // Decrypt the node key (k_0) using the user's master key string rawNodeKey = SecurityHelper.Decrypt(nodeKey.EncryptedNodeKey, masterKey); // Decrypt the node content using the raw node key (k_0) plaintext = SecurityHelper.Decrypt(node.Content, rawNodeKey); } else if (isOwner) { // Owner but no node key? This shouldn't happen, but fallback plaintext = "[Encrypted Content - Key Missing]"; } else { plaintext = "[Encrypted Content - Access Denied]"; } } } return new NodeDto( node.Uuid, node.Name, node.Type, plaintext, node.OwnerUsername, node.UpdatedAt, isOwner, canView, canEdit, canDelete, canRename, isPublic ); } // GET all accessible nodes group.MapGet("/", async (HttpContext context, ServerDbContext db, CertificateManager certManager) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); // Fetch nodes where owner is user, or user/everyone has ACL access var allNodes = await db.Nodes.Where(n => !n.IsDeleted).ToListAsync(); var result = new List(); foreach (var node in allNodes) { var dto = await GetNodeWithAccessAsync(node.Uuid, username, masterKey, db); if (dto != null) { result.Add(dto); } } return Results.Ok(result); }); // GET single node by UUID group.MapGet("/{uuid}", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); var dto = await GetNodeWithAccessAsync(uuid, username, masterKey, db); if (dto == null) return Results.NotFound("Node not found or access denied."); return Results.Ok(dto); }); // POST create new node (folder/note) group.MapPost("/", async (CreateNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); if (string.IsNullOrWhiteSpace(req.Name)) return Results.BadRequest("Name is required."); string uuid = req.Uuid; if (string.IsNullOrEmpty(uuid)) { do { uuid = Guid.NewGuid().ToString(); } while (await db.Nodes.AnyAsync(n => n.Uuid == uuid)); } // Generate node specific key (k_0) using var aes = System.Security.Cryptography.Aes.Create(); aes.GenerateKey(); string rawNodeKey = Convert.ToBase64String(aes.Key); var node = new Node { Uuid = uuid, Name = req.Name, Type = req.Type, OwnerUsername = username, UpdatedAt = DateTime.UtcNow, IsDeleted = false }; // By default, it's private. Encrypt content. if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to create a private node."); } node.Content = SecurityHelper.Encrypt(req.Content ?? "", rawNodeKey); // Store the encrypted node key for owner var nodeKey = new NodeKey { NodeUuid = uuid, Username = username, EncryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey) }; db.Nodes.Add(node); db.NodeKeys.Add(nodeKey); await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting POST change: {ex.Message}"); } }); // Return the created DTO var dto = new NodeDto(uuid, node.Name, node.Type, req.Content ?? "", username, node.UpdatedAt, true, true, true, true, true, false); return Results.Ok(dto); }); // PUT update node (rename/edit content) group.MapPut("/{uuid}", async (string uuid, UpdateNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted); if (node == null) return Results.NotFound("Node not found."); // Check permissions bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase); var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync(); var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase)); var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase)); bool canEdit = isOwner || (userAcl?.CanEdit ?? false) || (everyoneAcl?.CanEdit ?? false); bool canRename = isOwner || (userAcl?.CanRename ?? false) || (everyoneAcl?.CanRename ?? false); if (req.Content != null && !canEdit) return Results.Forbid(); if (req.Name != null && !canRename) return Results.Forbid(); bool isPublic = everyoneAcl?.CanView ?? false; if (req.Name != null) { node.Name = req.Name; } if (req.Content != null) { if (isPublic) { // Public node: plaintext node.Content = req.Content; } else { // Private node: requires X-Encryption-Key if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to edit a private node."); } // Fetch node key var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower()); if (nodeKey == null) return Results.BadRequest("Encryption key not authorized for this node."); string rawNodeKey = SecurityHelper.Decrypt(nodeKey.EncryptedNodeKey, masterKey); node.Content = SecurityHelper.Encrypt(req.Content, rawNodeKey); } } node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting PUT change: {ex.Message}"); } }); return Results.Ok(new { message = "Node updated successfully." }); }); // DELETE a node (soft delete) group.MapDelete("/{uuid}", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted); if (node == null) return Results.NotFound("Node not found."); bool isOwner = node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase); var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync(); var userAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals(username, StringComparison.OrdinalIgnoreCase)); var everyoneAcl = acls.FirstOrDefault(a => a.UserOrRole.Equals("everyone", StringComparison.OrdinalIgnoreCase)); bool canDelete = isOwner || (userAcl?.CanDelete ?? false) || (everyoneAcl?.CanDelete ?? false); if (!canDelete) return Results.Forbid(); node.IsDeleted = true; node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting DELETE change: {ex.Message}"); } }); return Results.Ok(new { message = "Node deleted successfully." }); }); // GET ACL entries group.MapGet("/{uuid}/acl", async (string uuid, HttpContext context, ServerDbContext db, CertificateManager certManager) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted); if (node == null) return Results.NotFound("Node not found."); // Only owner can manage/view ACLs directly if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid(); var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync(); return Results.Ok(acls); }); // PUT update ACL entries group.MapPut("/{uuid}/acl", async (string uuid, List aclReqs, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid && !n.IsDeleted); if (node == null) return Results.NotFound("Node not found."); if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid(); // Validate the request: Adding edit/delete/rename permission requires at least view permission foreach (var req in aclReqs) { if ((req.CanEdit || req.CanDelete || req.CanRename) && !req.CanView) { return Results.BadRequest("Adding Edit/Delete/Rename permission requires at least View permission."); } } // Check if everyone view status is changing to handle encryption removal/addition bool wasPublic = await db.AclEntries.AnyAsync(a => a.NodeUuid == uuid && a.UserOrRole == "everyone" && a.CanView); bool becomesPublic = aclReqs.Any(r => r.UserOrRole == "everyone" && r.CanView); // Fetch the owner's node key (we will need this to encrypt/decrypt when transitioning public/private) var ownerNodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == uuid && k.Username.ToLower() == username.ToLower()); if (wasPublic && !becomesPublic) { // Transitioning from PUBLIC to PRIVATE: encrypt the content if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to transition a public node to private."); } // Generate new raw node key k_0 using var aes = System.Security.Cryptography.Aes.Create(); aes.GenerateKey(); string rawNodeKey = Convert.ToBase64String(aes.Key); // Encrypt content string plaintext = node.Content; // Currently in plaintext on server node.Content = SecurityHelper.Encrypt(plaintext, rawNodeKey); // Save node key for owner if (ownerNodeKey == null) { ownerNodeKey = new NodeKey { NodeUuid = uuid, Username = username }; db.NodeKeys.Add(ownerNodeKey); } ownerNodeKey.EncryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey); } else if (!wasPublic && becomesPublic) { // Transitioning from PRIVATE to PUBLIC: decrypt the content if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to transition a private node to public."); } if (ownerNodeKey == null) { return Results.BadRequest("Encryption key not found for this node."); } // Decrypt content string rawNodeKey = SecurityHelper.Decrypt(ownerNodeKey.EncryptedNodeKey, masterKey); string plaintext = SecurityHelper.Decrypt(node.Content, rawNodeKey); node.Content = plaintext; // Plaintext on server } // Remove existing ACLs var existingAcls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync(); db.AclEntries.RemoveRange(existingAcls); // Add new ACLs foreach (var req in aclReqs) { db.AclEntries.Add(new AclEntry { NodeUuid = uuid, UserOrRole = req.UserOrRole, CanView = req.CanView, CanEdit = req.CanEdit, CanDelete = req.CanDelete, CanRename = req.CanRename }); } node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting ACL change: {ex.Message}"); } }); return Results.Ok(new { message = "ACLs updated successfully." }); }); // POST Share a Node (generates k0 encrypted by k1 and saves in PendingShare) group.MapPost("/share", async (ShareNodeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == req.NodeUuid && !n.IsDeleted); if (node == null) return Results.NotFound("Node not found."); // Only owner can share if (!node.OwnerUsername.Equals(username, StringComparison.OrdinalIgnoreCase)) return Results.Forbid(); // Verify recipient exists var recipientExists = await db.Users.AnyAsync(u => u.Username.ToLower() == req.RecipientUsername.ToLower()); if (!recipientExists) return Results.BadRequest("Recipient user does not exist."); if (string.IsNullOrWhiteSpace(req.PasskeyK1)) return Results.BadRequest("Sharing passkey k1 is required."); // Retrieve raw node key k_0 var ownerNodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == req.NodeUuid && k.Username.ToLower() == username.ToLower()); if (ownerNodeKey == null) return Results.BadRequest("Encryption key not found for this node."); if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to share a private node."); } string rawNodeKey = SecurityHelper.Decrypt(ownerNodeKey.EncryptedNodeKey, masterKey); // Derive k1 key (derive standard AES 256 key from passkey k1) // We use simple SHA256 of the passkey string to yield a 256-bit key using var sha256 = System.Security.Cryptography.SHA256.Create(); var k1Bytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(req.PasskeyK1)); string base64K1 = Convert.ToBase64String(k1Bytes); // Encrypt k_0 using Derived k_1 key string encryptedNodeKeyWithK1 = SecurityHelper.Encrypt(rawNodeKey, base64K1); // Add or update PendingShare var pendingShare = await db.PendingShares.FirstOrDefaultAsync(s => s.NodeUuid == req.NodeUuid && s.RecipientUsername.ToLower() == req.RecipientUsername.ToLower()); if (pendingShare == null) { pendingShare = new PendingShare { NodeUuid = req.NodeUuid, RecipientUsername = req.RecipientUsername }; db.PendingShares.Add(pendingShare); } pendingShare.EncryptedNodeKeyWithK1 = encryptedNodeKeyWithK1; // Grant recipient view access in ACL var hasAcl = await db.AclEntries.AnyAsync(a => a.NodeUuid == req.NodeUuid && a.UserOrRole.ToLower() == req.RecipientUsername.ToLower()); if (!hasAcl) { db.AclEntries.Add(new AclEntry { NodeUuid = req.NodeUuid, UserOrRole = req.RecipientUsername, CanView = true // Standard view access upon sharing }); } node.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(req.NodeUuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting share change: {ex.Message}"); } }); return Results.Ok(new { message = "Share initiated successfully.", guid = req.NodeUuid }); }); // POST Accept a Share (decrypts E_k1(k0) using k1 and re-encrypts using recipient's masterKey) group.MapPost("/accept-share", async (AcceptShareRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager, PeerCache peerCache, HttpClient httpClient, IServiceProvider serviceProvider) => { var username = AuthHelper.GetAuthenticatedUser(context, certManager); if (string.IsNullOrEmpty(username)) return Results.Unauthorized(); var masterKey = context.Request.Headers["X-Encryption-Key"].ToString(); if (string.IsNullOrWhiteSpace(req.NodeUuid)) return Results.BadRequest("GUID (Node UUID) is required."); if (string.IsNullOrWhiteSpace(req.PasskeyK1)) return Results.BadRequest("Passkey k1 is required."); // Fetch PendingShare var pendingShare = await db.PendingShares.FirstOrDefaultAsync(s => s.NodeUuid == req.NodeUuid && s.RecipientUsername.ToLower() == username.ToLower()); if (pendingShare == null) return Results.NotFound("Pending share not found for this node and user."); if (string.IsNullOrEmpty(masterKey)) { return Results.BadRequest("X-Encryption-Key is required to accept a share."); } // Derive k1 key using var sha256 = System.Security.Cryptography.SHA256.Create(); var k1Bytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(req.PasskeyK1)); string base64K1 = Convert.ToBase64String(k1Bytes); // Decrypt k_0 using derived k_1 key string rawNodeKey; try { rawNodeKey = SecurityHelper.Decrypt(pendingShare.EncryptedNodeKeyWithK1, base64K1); // Simple validation: check if decrypted value is a valid Base64 string of 32 bytes (key length) var testBytes = Convert.FromBase64String(rawNodeKey); if (testBytes.Length != 32) throw new Exception("Invalid key length."); } catch { return Results.BadRequest("Invalid passkey k1. Decryption failed."); } // Encrypt k_0 using Recipient's masterKey string encryptedNodeKey = SecurityHelper.Encrypt(rawNodeKey, masterKey); // Add NodeKey record for the recipient var nodeKey = await db.NodeKeys.FirstOrDefaultAsync(k => k.NodeUuid == req.NodeUuid && k.Username.ToLower() == username.ToLower()); if (nodeKey == null) { nodeKey = new NodeKey { NodeUuid = req.NodeUuid, Username = username }; db.NodeKeys.Add(nodeKey); } nodeKey.EncryptedNodeKey = encryptedNodeKey; // Delete pending share db.PendingShares.Remove(pendingShare); // Fetch the corresponding node and update UpdatedAt timestamp var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == req.NodeUuid && !n.IsDeleted); if (node != null) { node.UpdatedAt = DateTime.UtcNow; } await db.SaveChangesAsync(); _ = Task.Run(async () => { try { using var scope = serviceProvider.CreateScope(); var scopeDb = scope.ServiceProvider.GetRequiredService(); var rsaKeyManager = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); await SyncEndpoints.BroadcastNodeChangeAsync(req.NodeUuid, scopeDb, peerCache, rsaKeyManager, configuration, httpClient); } catch (Exception ex) { Console.WriteLine($"Error broadcasting accept-share change: {ex.Message}"); } }); return Results.Ok(new { message = "Share accepted and linked successfully." }); }); } } public record NodeDto( string Uuid, string Name, string Type, string Content, string OwnerUsername, DateTime UpdatedAt, bool IsOwner, bool CanView, bool CanEdit, bool CanDelete, bool CanRename, bool IsPublic ); public record CreateNodeRequest(string? Uuid, string Name, string Type, string? Content); public record UpdateNodeRequest(string? Name, string? Content); public record AclEntryRequest(string UserOrRole, bool CanView, bool CanEdit, bool CanDelete, bool CanRename); public record ShareNodeRequest(string NodeUuid, string RecipientUsername, string PasskeyK1); public record AcceptShareRequest(string NodeUuid, string PasskeyK1); public record LinkFolderRequest(string NodeUuid, string TargetParentUuid);