Implemented with Antigravity.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SNote.Models;
|
||||
using SNote.Server.Data;
|
||||
using SNote.Server.Security;
|
||||
|
||||
namespace SNote.Server.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
var group = routes.MapGroup("/api/auth");
|
||||
|
||||
group.MapPost("/register", async (RegisterRequest req, ServerDbContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password) || string.IsNullOrWhiteSpace(req.EncryptedKey))
|
||||
{
|
||||
return Results.BadRequest("Username, password, and encrypted key are required.");
|
||||
}
|
||||
|
||||
var existing = await db.Users.AnyAsync(u => u.Username.ToLower() == req.Username.ToLower());
|
||||
if (existing)
|
||||
{
|
||||
return Results.Conflict("Username already exists.");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Username = req.Username,
|
||||
PasswordHash = PasswordHasher.HashPassword(req.Password),
|
||||
EncryptedKey = req.EncryptedKey
|
||||
};
|
||||
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { message = "Registration successful." });
|
||||
});
|
||||
|
||||
group.MapPost("/login", async (LoginRequest req, ServerDbContext db, CertificateManager certManager) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
||||
{
|
||||
return Results.BadRequest("Username and password are required.");
|
||||
}
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == req.Username.ToLower());
|
||||
if (user == null || !PasswordHasher.VerifyPassword(user.PasswordHash, req.Password))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
// Generate JWT Token signed by shared certificate
|
||||
var cert = certManager.GetCertificate();
|
||||
var privateKey = new X509SecurityKey(cert);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim(ClaimTypes.Role, "User")
|
||||
};
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Expires = DateTime.UtcNow.AddDays(7),
|
||||
SigningCredentials = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256)
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
var tokenString = tokenHandler.WriteToken(token);
|
||||
|
||||
return Results.Ok(new LoginResponse(tokenString, user.EncryptedKey));
|
||||
});
|
||||
|
||||
group.MapGet("/encrypted-key/{username}", async (string username, ServerDbContext db) =>
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Username.ToLower() == username.ToLower());
|
||||
if (user == null)
|
||||
{
|
||||
return Results.NotFound("User not found.");
|
||||
}
|
||||
|
||||
return Results.Ok(new { encryptedKey = user.EncryptedKey });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record RegisterRequest(string Username, string Password, string EncryptedKey);
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record LoginResponse(string Token, string EncryptedKey);
|
||||
@@ -0,0 +1,590 @@
|
||||
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<NodeDto?> 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<NodeDto>();
|
||||
|
||||
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) =>
|
||||
{
|
||||
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();
|
||||
|
||||
// Broadcast change to other servers in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = context.RequestServices.CreateScope();
|
||||
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
||||
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, 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) =>
|
||||
{
|
||||
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();
|
||||
|
||||
// Broadcast change to other servers in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = context.RequestServices.CreateScope();
|
||||
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
||||
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, 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) =>
|
||||
{
|
||||
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();
|
||||
|
||||
// Broadcast change to other servers in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = context.RequestServices.CreateScope();
|
||||
var scopeDb = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
|
||||
await SyncEndpoints.BroadcastNodeChangeAsync(uuid, scopeDb, peerCache, certManager, 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<AclEntryRequest> aclReqs, 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 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();
|
||||
|
||||
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) =>
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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) =>
|
||||
{
|
||||
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);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,500 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SNote.Models;
|
||||
using SNote.Server.Data;
|
||||
using SNote.Server.Security;
|
||||
|
||||
namespace SNote.Server.Endpoints;
|
||||
|
||||
public static class SyncEndpoints
|
||||
{
|
||||
public static void MapSyncEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
// 0. Heartbeat ping endpoint
|
||||
routes.MapGet("/api/sync/ping", () => Results.Ok(new { status = "Healthy" }));
|
||||
|
||||
// 1. Handshake Endpoint: Receive registration of downstream peers
|
||||
routes.MapPost("/api/sync/register-peer", async (RegisterPeerRequest req, HttpContext context, PeerCache peerCache, CertificateManager certManager) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.PeerUrl))
|
||||
{
|
||||
return Results.BadRequest("Peer URL is required.");
|
||||
}
|
||||
|
||||
// Authenticate server-to-server JWT
|
||||
if (!AuthHelper.IsServerAuthenticated(context, certManager))
|
||||
{
|
||||
return Results.Json(new { error = "Unauthorized server connection handshake." }, statusCode: 401);
|
||||
}
|
||||
|
||||
peerCache.RegisterPeer(req.PeerUrl);
|
||||
return Results.Ok(new { message = "Server peer registered successfully." });
|
||||
});
|
||||
|
||||
// 2. Full Database pulling (bootstrapping new server node)
|
||||
routes.MapPost("/api/sync/pull-database", async (HttpContext context, ServerDbContext db, IConfiguration config, CertificateManager certManager) =>
|
||||
{
|
||||
var enablePull = config.GetValue<bool>("EnableFullDbPull", false);
|
||||
if (!enablePull)
|
||||
{
|
||||
return Results.Json(new { error = "Full database pulling is disabled on this server." }, statusCode: 403);
|
||||
}
|
||||
|
||||
if (!AuthHelper.IsServerAuthenticated(context, certManager))
|
||||
{
|
||||
return Results.Json(new { error = "Unauthorized server node." }, statusCode: 401);
|
||||
}
|
||||
|
||||
var users = await db.Users.ToListAsync();
|
||||
var nodes = await db.Nodes.ToListAsync();
|
||||
var nodeKeys = await db.NodeKeys.ToListAsync();
|
||||
var pendingShares = await db.PendingShares.ToListAsync();
|
||||
var aclEntries = await db.AclEntries.ToListAsync();
|
||||
var peerServers = await db.PeerServers.ToListAsync();
|
||||
|
||||
var dump = new DatabaseDumpPayload(users, nodes, nodeKeys, pendingShares, aclEntries, peerServers);
|
||||
return Results.Ok(dump);
|
||||
});
|
||||
|
||||
// 3. Synchronize nodes states
|
||||
routes.MapPost("/api/sync/nodes", async (SyncExchangeRequest req, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
||||
{
|
||||
if (!AuthHelper.IsServerAuthenticated(context, certManager))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var toPush = new List<SyncPushItem>();
|
||||
var toRequest = new List<string>();
|
||||
|
||||
var localNodes = await db.Nodes.ToListAsync();
|
||||
var localNodeUuids = localNodes.Select(n => n.Uuid).ToHashSet();
|
||||
|
||||
foreach (var remoteItem in req.Items)
|
||||
{
|
||||
var localNode = localNodes.FirstOrDefault(n => n.Uuid == remoteItem.Uuid);
|
||||
if (localNode == null)
|
||||
{
|
||||
toRequest.Add(remoteItem.Uuid);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (localNode.UpdatedAt > remoteItem.UpdatedAt)
|
||||
{
|
||||
var acls = await db.AclEntries.Where(a => a.NodeUuid == localNode.Uuid).ToListAsync();
|
||||
var keys = await db.NodeKeys.Where(k => k.NodeUuid == localNode.Uuid).ToListAsync();
|
||||
toPush.Add(new SyncPushItem(localNode, acls, keys));
|
||||
}
|
||||
else if (localNode.UpdatedAt < remoteItem.UpdatedAt)
|
||||
{
|
||||
toRequest.Add(remoteItem.Uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var remoteUuids = req.Items.Select(i => i.Uuid).ToHashSet();
|
||||
foreach (var localNode in localNodes)
|
||||
{
|
||||
if (!remoteUuids.Contains(localNode.Uuid))
|
||||
{
|
||||
var acls = await db.AclEntries.Where(a => a.NodeUuid == localNode.Uuid).ToListAsync();
|
||||
var keys = await db.NodeKeys.Where(k => k.NodeUuid == localNode.Uuid).ToListAsync();
|
||||
toPush.Add(new SyncPushItem(localNode, acls, keys));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new SyncExchangeResponse(toPush, toRequest));
|
||||
});
|
||||
|
||||
// 4. Receive full node pushes during sync
|
||||
routes.MapPost("/api/sync/push", async (List<SyncPushItem> items, HttpContext context, ServerDbContext db, CertificateManager certManager) =>
|
||||
{
|
||||
if (!AuthHelper.IsServerAuthenticated(context, certManager))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var localNode = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == item.Node.Uuid);
|
||||
if (localNode == null)
|
||||
{
|
||||
db.Nodes.Add(item.Node);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
}
|
||||
else if (localNode.UpdatedAt < item.Node.UpdatedAt)
|
||||
{
|
||||
localNode.Name = item.Node.Name;
|
||||
localNode.Type = item.Node.Type;
|
||||
localNode.Content = item.Node.Content;
|
||||
localNode.OwnerUsername = item.Node.OwnerUsername;
|
||||
localNode.UpdatedAt = item.Node.UpdatedAt;
|
||||
localNode.IsDeleted = item.Node.IsDeleted;
|
||||
|
||||
var existingAcls = await db.AclEntries.Where(a => a.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.AclEntries.RemoveRange(existingAcls);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
|
||||
var existingKeys = await db.NodeKeys.Where(k => k.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.NodeKeys.RemoveRange(existingKeys);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { message = "Synced elements updated successfully." });
|
||||
});
|
||||
|
||||
// 5. Receive broadcast of changes (broadcasts single node updates)
|
||||
routes.MapPost("/api/sync/broadcast", async (SyncPushItem item, HttpContext context, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient) =>
|
||||
{
|
||||
if (!AuthHelper.IsServerAuthenticated(context, certManager))
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var broadcastId = item.BroadcastId;
|
||||
if (!string.IsNullOrEmpty(broadcastId))
|
||||
{
|
||||
// Loop check: if already processed, ignore!
|
||||
if (!peerCache.TryProcessBroadcast(broadcastId))
|
||||
{
|
||||
return Results.Ok(new { message = "Broadcast already processed (loop detected).", updated = false });
|
||||
}
|
||||
}
|
||||
|
||||
var localNode = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == item.Node.Uuid);
|
||||
bool updated = false;
|
||||
|
||||
if (localNode == null)
|
||||
{
|
||||
db.Nodes.Add(item.Node);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
updated = true;
|
||||
}
|
||||
else if (localNode.UpdatedAt < item.Node.UpdatedAt)
|
||||
{
|
||||
localNode.Name = item.Node.Name;
|
||||
localNode.Type = item.Node.Type;
|
||||
localNode.Content = item.Node.Content;
|
||||
localNode.OwnerUsername = item.Node.OwnerUsername;
|
||||
localNode.UpdatedAt = item.Node.UpdatedAt;
|
||||
localNode.IsDeleted = item.Node.IsDeleted;
|
||||
|
||||
var existingAcls = await db.AclEntries.Where(a => a.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.AclEntries.RemoveRange(existingAcls);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
|
||||
var existingKeys = await db.NodeKeys.Where(k => k.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.NodeKeys.RemoveRange(existingKeys);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Relay broadcast to all peers (upstream + downstream) with loop prevention!
|
||||
if (!string.IsNullOrEmpty(broadcastId))
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await BroadcastNodeChangeAsync(item.Node.Uuid, db, peerCache, certManager, httpClient, broadcastId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(new { message = "Broadcast processed.", updated });
|
||||
});
|
||||
}
|
||||
|
||||
// Handshake: Tells the destination server who we are and registers us
|
||||
public static async Task RegisterPeerWithDestinationAsync(string destinationUrl, string localUrl, CertificateManager certManager, HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[Handshake] Registering our address '{localUrl}' with destination '{destinationUrl}'...");
|
||||
var cleanDest = destinationUrl.TrimEnd('/') + "/api/sync/register-peer";
|
||||
|
||||
var payload = new RegisterPeerRequest(localUrl);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, cleanDest)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
// Generate server-to-server JWT signature
|
||||
var token = AuthHelper.GenerateServerToken(certManager);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine("[Handshake] Successfully registered with destination server.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Handshake] Handshake failed: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Handshake] Handshake error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast a node change to both database and dynamic in-memory cached peer servers
|
||||
public static async Task BroadcastNodeChangeAsync(string nodeUuid, ServerDbContext db, PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, string? broadcastId = null)
|
||||
{
|
||||
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == nodeUuid);
|
||||
if (node == null) return;
|
||||
|
||||
var acls = await db.AclEntries.Where(a => a.NodeUuid == nodeUuid).ToListAsync();
|
||||
var keys = await db.NodeKeys.Where(k => k.NodeUuid == nodeUuid).ToListAsync();
|
||||
|
||||
var bId = string.IsNullOrEmpty(broadcastId) ? Guid.NewGuid().ToString() : broadcastId;
|
||||
|
||||
// Register it locally in PeerCache so we don't process it ourselves if it loops back
|
||||
peerCache.TryProcessBroadcast(bId);
|
||||
|
||||
var payload = new SyncPushItem(node, acls, keys, bId);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
|
||||
// Fetch database configured peers
|
||||
var dbPeers = await db.PeerServers.Select(p => p.Url).ToListAsync();
|
||||
|
||||
// Fetch dynamically handshake-registered in-memory peers
|
||||
var dynamicPeers = peerCache.GetPeers();
|
||||
|
||||
// Combine peers
|
||||
var allPeers = dbPeers.Union(dynamicPeers, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
foreach (var peerUrl in allPeers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/broadcast");
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var token = AuthHelper.GenerateServerToken(certManager);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
Console.WriteLine($"Broadcast to {peerUrl}: {response.StatusCode}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to broadcast change to {peerUrl}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically pings downstream peer servers in PeerCache to ensure they are online
|
||||
public static async Task StartHeartbeatPingerAsync(PeerCache peerCache, CertificateManager certManager, HttpClient httpClient, System.Threading.CancellationToken token)
|
||||
{
|
||||
Console.WriteLine("[Heartbeat] Starting periodic downstream heartbeat pinger...");
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Wait for 30 seconds
|
||||
await Task.Delay(30000, token);
|
||||
|
||||
var peers = peerCache.GetPeers();
|
||||
if (peers.Count == 0) continue;
|
||||
|
||||
var jwtToken = AuthHelper.GenerateServerToken(certManager);
|
||||
|
||||
foreach (var peerUrl in peers)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{peerUrl.TrimEnd('/')}/api/sync/ping");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken);
|
||||
|
||||
// Set a short timeout (e.g. 5 seconds) to avoid blocking
|
||||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
using var linkedCts = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(token, cts.Token);
|
||||
|
||||
var response = await httpClient.SendAsync(request, linkedCts.Token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"[Heartbeat] Peer {peerUrl} ping failed with status code: {response.StatusCode}. Evicting...");
|
||||
peerCache.RemovePeer(peerUrl);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Heartbeat] Peer {peerUrl} is unreachable ({ex.Message}). Evicting...");
|
||||
peerCache.RemovePeer(peerUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Heartbeat] Error in heartbeat loop: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SyncWithDestinationServerAsync(string peerUrl, ServerDbContext db, CertificateManager certManager, HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = AuthHelper.GenerateServerToken(certManager);
|
||||
|
||||
var localNodes = await db.Nodes.ToListAsync();
|
||||
var reqItems = localNodes.Select(n => new SyncNodeState(n.Uuid, n.UpdatedAt, n.IsDeleted)).ToList();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/nodes");
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(new SyncExchangeRequest(reqItems)), Encoding.UTF8, "application/json");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode) return;
|
||||
|
||||
var result = JsonSerializer.Deserialize<SyncExchangeResponse>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (result == null) return;
|
||||
|
||||
if (result.ToPush != null && result.ToPush.Count > 0)
|
||||
{
|
||||
foreach (var item in result.ToPush)
|
||||
{
|
||||
var local = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == item.Node.Uuid);
|
||||
if (local == null)
|
||||
{
|
||||
db.Nodes.Add(item.Node);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
}
|
||||
else if (local.UpdatedAt < item.Node.UpdatedAt)
|
||||
{
|
||||
local.Name = item.Node.Name;
|
||||
local.Type = item.Node.Type;
|
||||
local.Content = item.Node.Content;
|
||||
local.OwnerUsername = item.Node.OwnerUsername;
|
||||
local.UpdatedAt = item.Node.UpdatedAt;
|
||||
local.IsDeleted = item.Node.IsDeleted;
|
||||
|
||||
var existingAcls = await db.AclEntries.Where(a => a.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.AclEntries.RemoveRange(existingAcls);
|
||||
db.AclEntries.AddRange(item.AclEntries);
|
||||
|
||||
var existingKeys = await db.NodeKeys.Where(k => k.NodeUuid == item.Node.Uuid).ToListAsync();
|
||||
db.NodeKeys.RemoveRange(existingKeys);
|
||||
db.NodeKeys.AddRange(item.NodeKeys);
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (result.ToRequest != null && result.ToRequest.Count > 0)
|
||||
{
|
||||
var pushItems = new List<SyncPushItem>();
|
||||
foreach (var uuid in result.ToRequest)
|
||||
{
|
||||
var node = await db.Nodes.FirstOrDefaultAsync(n => n.Uuid == uuid);
|
||||
if (node != null)
|
||||
{
|
||||
var acls = await db.AclEntries.Where(a => a.NodeUuid == uuid).ToListAsync();
|
||||
var keys = await db.NodeKeys.Where(k => k.NodeUuid == uuid).ToListAsync();
|
||||
pushItems.Add(new SyncPushItem(node, acls, keys));
|
||||
}
|
||||
}
|
||||
|
||||
if (pushItems.Count > 0)
|
||||
{
|
||||
var pushRequest = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/push");
|
||||
pushRequest.Content = new StringContent(JsonSerializer.Serialize(pushItems), Encoding.UTF8, "application/json");
|
||||
pushRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
await httpClient.SendAsync(pushRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error synchronizing with peer {peerUrl}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task BootstrapFromPeerServerAsync(string peerUrl, ServerDbContext db, CertificateManager certManager, HttpClient httpClient)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"Performing full database pulling bootstrap from {peerUrl}...");
|
||||
var token = AuthHelper.GenerateServerToken(certManager);
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{peerUrl.TrimEnd('/')}/api/sync/pull-database");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Full database pull failed: {response.StatusCode}");
|
||||
return;
|
||||
}
|
||||
|
||||
var dump = JsonSerializer.Deserialize<DatabaseDumpPayload>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (dump == null) return;
|
||||
|
||||
db.Users.RemoveRange(await db.Users.ToListAsync());
|
||||
db.Nodes.RemoveRange(await db.Nodes.ToListAsync());
|
||||
db.NodeKeys.RemoveRange(await db.NodeKeys.ToListAsync());
|
||||
db.PendingShares.RemoveRange(await db.PendingShares.ToListAsync());
|
||||
db.AclEntries.RemoveRange(await db.AclEntries.ToListAsync());
|
||||
db.PeerServers.RemoveRange(await db.PeerServers.ToListAsync());
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Users.AddRange(dump.Users);
|
||||
db.Nodes.AddRange(dump.Nodes);
|
||||
db.NodeKeys.AddRange(dump.NodeKeys);
|
||||
db.PendingShares.AddRange(dump.PendingShares);
|
||||
db.AclEntries.AddRange(dump.AclEntries);
|
||||
db.PeerServers.AddRange(dump.PeerServers);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Console.WriteLine("Full database pull bootstrap completed successfully!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error during full database pulling bootstrap: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record RegisterPeerRequest(string PeerUrl);
|
||||
|
||||
public record DatabaseDumpPayload(
|
||||
List<User> Users,
|
||||
List<Node> Nodes,
|
||||
List<NodeKey> NodeKeys,
|
||||
List<PendingShare> PendingShares,
|
||||
List<AclEntry> AclEntries,
|
||||
List<PeerServer> PeerServers
|
||||
);
|
||||
|
||||
public record SyncNodeState(string Uuid, DateTime UpdatedAt, bool IsDeleted);
|
||||
public record SyncExchangeRequest(List<SyncNodeState> Items);
|
||||
public record SyncPushItem(Node Node, List<AclEntry> AclEntries, List<NodeKey> NodeKeys, string? BroadcastId = null);
|
||||
public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest);
|
||||
Reference in New Issue
Block a user