Implemented with Antigravity.

This commit is contained in:
Creeper Lv
2026-06-01 05:09:20 +10:00
parent aaad155a30
commit e8ab8e0684
38 changed files with 3908 additions and 47 deletions
+2
View File
@@ -2,3 +2,5 @@
.vscode/
bin/
obj/
*.db
*.tmp
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.IO;
using Microsoft.EntityFrameworkCore;
using SNote.Models;
namespace SNote.Data;
public class ClientDbContext : DbContext
{
public static string ActiveDbName { get; set; } = "snote_client.db";
public static string? ActiveDbPassword { get; set; } = null;
public DbSet<Node> Nodes => Set<Node>();
public DbSet<LocalConfig> Configs => Set<LocalConfig>();
public DbSet<UserDirectoryEntry> UserDirectory => Set<UserDirectoryEntry>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var dbPath = Path.Combine(path, "SNote");
if (!Directory.Exists(dbPath))
{
Directory.CreateDirectory(dbPath);
}
var fullPath = Path.Combine(dbPath, ActiveDbName);
if (string.IsNullOrEmpty(ActiveDbPassword))
{
optionsBuilder.UseSqlite($"Data Source={fullPath}");
}
else
{
optionsBuilder.UseSqlite($"Data Source={fullPath};Password={ActiveDbPassword}");
}
}
}
}
public class LocalConfig
{
public int Id { get; set; }
public string ServerUrl { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string MasterKey { get; set; } = string.Empty; // decrypted master key (k_master)
public bool IsOnlineMode { get; set; } = false;
}
+69
View File
@@ -0,0 +1,69 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace SNote.Models;
public class User
{
[Key]
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string EncryptedKey { get; set; } = string.Empty; // User's master key (k_master) encrypted by password
}
public class Node
{
[Key]
public string Uuid { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = "Note"; // "Folder" or "Note"
public string Content { get; set; } = string.Empty; // Encrypted text/JSON or plaintext (if everyone has View)
public string OwnerUsername { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; } = false;
}
public class NodeKey
{
[Key]
public int Id { get; set; }
public string NodeUuid { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string EncryptedNodeKey { get; set; } = string.Empty; // k_0 encrypted by User's master key (k_master)
}
public class PendingShare
{
[Key]
public int Id { get; set; }
public string NodeUuid { get; set; } = string.Empty;
public string RecipientUsername { get; set; } = string.Empty;
public string EncryptedNodeKeyWithK1 { get; set; } = string.Empty; // k_0 encrypted by passkey k_1
}
public class AclEntry
{
[Key]
public int Id { get; set; }
public string NodeUuid { get; set; } = string.Empty;
public string UserOrRole { get; set; } = string.Empty; // username or "everyone"
public bool CanView { get; set; } = false;
public bool CanEdit { get; set; } = false;
public bool CanDelete { get; set; } = false;
public bool CanRename { get; set; } = false;
}
public class PeerServer
{
[Key]
public string Url { get; set; } = string.Empty;
public bool IsDestination { get; set; } = false;
}
public class UserDirectoryEntry
{
[Key]
public string Username { get; set; } = string.Empty;
public string UserGuid { get; set; } = string.Empty;
public string RemoteServerUrl { get; set; } = string.Empty;
}
+3 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
@@ -19,5 +19,7 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" />
</ItemGroup>
</Project>
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SNote.Services;
public class CryptoService
{
private const int Iterations = 10000;
private const int KeySize = 32; // 256 bit
// Derives an AES-256 key from a password and a salt (derived from username)
public string DeriveKeyFromPassword(string password, string username)
{
var salt = Encoding.UTF8.GetBytes(username.ToLower() + "-snote-salt-system");
using var algorithm = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA256);
return Convert.ToBase64String(algorithm.GetBytes(KeySize));
}
// Generates a new random cryptographically secure master key (Base64 string)
public string GenerateMasterKey()
{
using var aes = Aes.Create();
aes.GenerateKey();
return Convert.ToBase64String(aes.Key);
}
// Encrypts plaintext using AES-256 with a derived key
public string Encrypt(string plaintext, string base64Key)
{
if (string.IsNullOrEmpty(plaintext)) return plaintext;
try
{
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.GenerateIV();
var iv = aes.IV;
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
using var ms = new MemoryStream();
// Prepend IV
ms.Write(iv, 0, iv.Length);
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
using (var sw = new StreamWriter(cs, Encoding.UTF8))
{
sw.Write(plaintext);
}
return Convert.ToBase64String(ms.ToArray());
}
catch (Exception ex)
{
Console.WriteLine($"Client encryption error: {ex.Message}");
return plaintext;
}
}
// Decrypts ciphertext (prepended with IV) using a derived key
public string Decrypt(string ciphertext, string base64Key)
{
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
try
{
var fullCipher = Convert.FromBase64String(ciphertext);
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
var iv = new byte[16];
var cipherTextBytes = new byte[fullCipher.Length - 16];
Buffer.BlockCopy(fullCipher, 0, iv, 0, 16);
Buffer.BlockCopy(fullCipher, 16, cipherTextBytes, 0, cipherTextBytes.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
using var ms = new MemoryStream(cipherTextBytes);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var sr = new StreamReader(cs, Encoding.UTF8);
return sr.ReadToEnd();
}
catch (Exception ex)
{
Console.WriteLine($"Client decryption error: {ex.Message}");
return ciphertext;
}
}
}
+379
View File
@@ -0,0 +1,379 @@
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.EntityFrameworkCore;
using SNote.Data;
using SNote.Models;
namespace SNote.Services;
public class SyncService
{
private readonly HttpClient _httpClient;
private readonly CryptoService _cryptoService;
public SyncService(CryptoService cryptoService)
{
_httpClient = new HttpClient();
_cryptoService = cryptoService;
}
// Register a new account on the server
public async Task<(bool success, string error)> RegisterAsync(string username, string password, string serverUrl)
{
try
{
var cleanUrl = serverUrl.TrimEnd('/') + "/api/auth/register";
// Generate a random master key for the user (k_master)
var masterKey = _cryptoService.GenerateMasterKey();
// Derive key from password to encrypt the master key
var derivedKey = _cryptoService.DeriveKeyFromPassword(password, username);
// Encrypt the master key using the derived key
var encryptedKey = _cryptoService.Encrypt(masterKey, derivedKey);
var payload = new
{
username,
password, // For server verification / hashing
encryptedKey
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(cleanUrl, content);
if (response.IsSuccessStatusCode)
{
return (true, string.Empty);
}
var errorMsg = await response.Content.ReadAsStringAsync();
return (false, string.IsNullOrEmpty(errorMsg) ? response.StatusCode.ToString() : errorMsg);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
// Login and retrieve the encrypted master key
public async Task<(bool success, string error, LocalConfig? config)> LoginAsync(string username, string password, string serverUrl)
{
try
{
var cleanUrl = serverUrl.TrimEnd('/') + "/api/auth/login";
var payload = new
{
username,
password
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(cleanUrl, content);
if (!response.IsSuccessStatusCode)
{
return (false, "Invalid username or password.", null);
}
var responseJson = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(responseJson);
var root = doc.RootElement;
var token = root.GetProperty("token").GetString() ?? string.Empty;
var encryptedKey = root.GetProperty("encryptedKey").GetString() ?? string.Empty;
// Decrypt the master key locally using the derived key
var derivedKey = _cryptoService.DeriveKeyFromPassword(password, username);
var masterKey = _cryptoService.Decrypt(encryptedKey, derivedKey);
var config = new LocalConfig
{
Username = username,
ServerUrl = serverUrl,
AuthToken = token,
MasterKey = masterKey,
IsOnlineMode = true
};
return (true, string.Empty, config);
}
catch (Exception ex)
{
return (false, ex.Message, null);
}
}
// Accept a cryptographic node share using Guid + k1
public async Task<(bool success, string error)> AcceptShareAsync(string guid, string k1, LocalConfig config)
{
try
{
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes/accept-share";
var payload = new
{
nodeUuid = guid,
passkeyK1 = k1
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
request.Headers.Add("X-Encryption-Key", config.MasterKey);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return (true, string.Empty);
}
var error = await response.Content.ReadAsStringAsync();
return (false, error);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
// Share a node with another user
public async Task<(bool success, string error, string guid)> ShareNodeAsync(string nodeUuid, string recipient, string k1, LocalConfig config)
{
try
{
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes/share";
var payload = new
{
nodeUuid,
recipientUsername = recipient,
passkeyK1 = k1
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
request.Headers.Add("X-Encryption-Key", config.MasterKey);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var respContent = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(respContent);
var guidStr = doc.RootElement.GetProperty("guid").GetString() ?? nodeUuid;
return (true, string.Empty, guidStr);
}
var error = await response.Content.ReadAsStringAsync();
return (false, error, nodeUuid);
}
catch (Exception ex)
{
return (false, ex.Message, nodeUuid);
}
}
// Sync local offline SQLite database with server
public async Task<(bool success, string error)> SyncNodesAsync(LocalConfig config)
{
try
{
using var db = new ClientDbContext();
db.Database.EnsureCreated();
// 1. Fetch remote nodes list
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes";
var request = new HttpRequestMessage(HttpMethod.Get, cleanUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
request.Headers.Add("X-Encryption-Key", config.MasterKey);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return (false, $"Server returned error: {response.StatusCode}");
}
var responseJson = await response.Content.ReadAsStringAsync();
var remoteNodes = JsonSerializer.Deserialize<List<NodeDto>>(responseJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (remoteNodes == null) return (false, "Failed to deserialize remote nodes.");
// 2. Fetch local nodes list
var localNodes = await db.Nodes.ToListAsync();
// 3. Process differences
foreach (var remote in remoteNodes)
{
var local = localNodes.FirstOrDefault(l => l.Uuid == remote.Uuid);
if (local == null)
{
// Add remote node locally
db.Nodes.Add(new Node
{
Uuid = remote.Uuid,
Name = remote.Name,
Type = remote.Type,
Content = remote.Content,
OwnerUsername = remote.OwnerUsername,
UpdatedAt = remote.UpdatedAt,
IsDeleted = false
});
}
else if (local.UpdatedAt < remote.UpdatedAt)
{
// Update local node with newer remote content
local.Name = remote.Name;
local.Type = remote.Type;
local.Content = remote.Content;
local.OwnerUsername = remote.OwnerUsername;
local.UpdatedAt = remote.UpdatedAt;
local.IsDeleted = false; // Sync undeletes if remote is active
}
else if (local.UpdatedAt > remote.UpdatedAt)
{
// Local is newer: push update to server
await PushNodeToServerAsync(local, config);
}
}
// Check if there are local nodes that the server doesn't have
var remoteUuids = remoteNodes.Select(r => r.Uuid).ToHashSet();
foreach (var local in localNodes)
{
if (!remoteUuids.Contains(local.Uuid) && !local.IsDeleted)
{
// Node exists only locally, upload it to the server
await UploadNewNodeToServerAsync(local, config);
}
else if (local.IsDeleted && remoteUuids.Contains(local.Uuid))
{
// Soft deleted locally, notify server
await DeleteNodeOnServerAsync(local.Uuid, config);
}
}
await db.SaveChangesAsync();
return (true, string.Empty);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private async Task PushNodeToServerAsync(Node localNode, LocalConfig config)
{
try
{
var cleanUrl = config.ServerUrl.TrimEnd('/') + $"/api/nodes/{localNode.Uuid}";
var payload = new
{
name = localNode.Name,
content = localNode.Content
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Put, cleanUrl)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
request.Headers.Add("X-Encryption-Key", config.MasterKey);
await _httpClient.SendAsync(request);
}
catch (Exception ex)
{
Console.WriteLine($"Error pushing node {localNode.Uuid} to server: {ex.Message}");
}
}
private async Task UploadNewNodeToServerAsync(Node localNode, LocalConfig config)
{
try
{
var cleanUrl = config.ServerUrl.TrimEnd('/') + "/api/nodes";
var payload = new
{
uuid = localNode.Uuid,
name = localNode.Name,
type = localNode.Type,
content = localNode.Content
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, cleanUrl)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
request.Headers.Add("X-Encryption-Key", config.MasterKey);
await _httpClient.SendAsync(request);
}
catch (Exception ex)
{
Console.WriteLine($"Error uploading node {localNode.Uuid} to server: {ex.Message}");
}
}
private async Task DeleteNodeOnServerAsync(string uuid, LocalConfig config)
{
try
{
var cleanUrl = config.ServerUrl.TrimEnd('/') + $"/api/nodes/{uuid}";
var request = new HttpRequestMessage(HttpMethod.Delete, cleanUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", config.AuthToken);
await _httpClient.SendAsync(request);
}
catch (Exception ex)
{
Console.WriteLine($"Error deleting node {uuid} on server: {ex.Message}");
}
}
}
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);
public record SyncExchangeResponse(List<SyncPushItem> ToPush, List<string> ToRequest);
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
);
+869 -3
View File
@@ -1,9 +1,875 @@
using CommunityToolkit.Mvvm.ComponentModel;
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using SNote.Data;
using SNote.Models;
using SNote.Services;
namespace SNote.ViewModels;
public class NotebookItem
{
public Node Node { get; set; }
public string Uuid => Node.Uuid;
public string Name => Node.Name;
public string Type => Node.Type;
public bool IsFolder => Type == "Folder";
public bool IsNote => Type == "Note";
public ObservableCollection<NotebookItem> Children { get; } = new();
public NotebookItem(Node node)
{
Node = node;
}
}
public partial class MainViewModel : ViewModelBase
{
[ObservableProperty]
private string _greeting = "Welcome to Avalonia!";
private readonly CryptoService _cryptoService;
private readonly SyncService _syncService;
private LocalConfig? _config;
// View layout responsive state
[ObservableProperty] private bool _isOnline = false;
[ObservableProperty] private bool _isLoggedIn = false;
[ObservableProperty] private string _username = "Offline User";
[ObservableProperty] private string _serverUrl = "http://localhost:5000";
[ObservableProperty] private string _loginPassword = "";
[ObservableProperty] private string _syncStatus = "Offline Mode";
// Narrow vs Full mode states
[ObservableProperty] private bool _isNarrowMode = false;
[ObservableProperty] private bool _isSidebarVisible = true;
[ObservableProperty] private bool _isContentVisible = false;
// Editor / Selection state
[ObservableProperty] private Node? _selectedNode;
[ObservableProperty] private string _noteName = "";
[ObservableProperty] private string _noteContent = "";
// Dialog state controllers (shown via VM binding or simple toggles)
[ObservableProperty] private bool _isLinkDialogVisible = false;
[ObservableProperty] private bool _isShareDialogVisible = false;
[ObservableProperty] private bool _isAcceptDialogVisible = false;
[ObservableProperty] private bool _isCreateFolderDialogVisible = false;
[ObservableProperty] private string _newFolderName = "";
// New properties for Rename Dialog and Note Migration Dialog
[ObservableProperty] private bool _isRenameDialogVisible = false;
[ObservableProperty] private string _renameTargetName = "";
[ObservableProperty] private bool _isMigrateDialogVisible = false;
[ObservableProperty] private string? _selectedLocalAccount;
// Share results info
[ObservableProperty] private string _shareGuid = "";
[ObservableProperty] private string _sharePasskey = "";
// Accept Share entry
[ObservableProperty] private string _acceptGuid = "";
[ObservableProperty] private string _acceptPasskey = "";
// Tree selection
[ObservableProperty] private NotebookItem? _selectedNotebookItem;
partial void OnSelectedNotebookItemChanged(NotebookItem? value)
{
if (value != null)
{
SelectNode(value.Node);
}
}
public ObservableCollection<NotebookItem> NotebookTree { get; } = new();
public ObservableCollection<Node> CurrentNotesList { get; } = new();
public ObservableCollection<Node> AllFoldersList { get; } = new(); // Used in Link to... dialog
public ObservableCollection<string> LocalAccounts { get; } = new();
partial void OnSelectedLocalAccountChanged(string? value)
{
if (!string.IsNullOrEmpty(value))
{
Username = value;
}
}
public void ScanLocalAccounts()
{
LocalAccounts.Clear();
try
{
var folder = Environment.SpecialFolder.LocalApplicationData;
var path = Environment.GetFolderPath(folder);
var dbPath = Path.Combine(path, "SNote");
if (Directory.Exists(dbPath))
{
using var globalDb = new ClientDbContext();
globalDb.Database.EnsureCreated();
var entries = globalDb.UserDirectory.ToList();
foreach (var entry in entries)
{
var file = Path.Combine(dbPath, $"snote_{entry.UserGuid}.db");
if (File.Exists(file) && !LocalAccounts.Contains(entry.Username))
{
LocalAccounts.Add(entry.Username);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error scanning local accounts: {ex.Message}");
}
}
public MainViewModel()
{
_cryptoService = new CryptoService();
_syncService = new SyncService(_cryptoService);
// Bootstrap local SQLite DB
using var db = new ClientDbContext();
db.Database.EnsureCreated();
// Load configuration if exists
_config = db.Configs.OrderByDescending(c => c.Id).FirstOrDefault();
if (_config != null)
{
Username = _config.Username;
ServerUrl = _config.ServerUrl;
IsOnline = _config.IsOnlineMode;
}
RefreshNotebookTree();
ScanLocalAccounts();
}
// Handles window resizing and adaptive column toggles
public void UpdateWidthMode(double width)
{
IsNarrowMode = width < 700;
if (IsNarrowMode)
{
// In narrow mode, if editing, hide sidebar. Otherwise, show sidebar.
IsSidebarVisible = SelectedNode == null;
IsContentVisible = SelectedNode != null;
}
else
{
IsSidebarVisible = true;
IsContentVisible = true;
}
}
[RelayCommand]
private void GoBack()
{
SelectedNode = null;
if (IsNarrowMode)
{
IsSidebarVisible = true;
IsContentVisible = false;
}
}
// Initialize local database with some default folders if empty
public void RefreshNotebookTree()
{
NotebookTree.Clear();
CurrentNotesList.Clear();
AllFoldersList.Clear();
using var db = new ClientDbContext();
var allNodes = db.Nodes.ToList();
var activeNodes = allNodes.Where(n => !n.IsDeleted).ToList();
// Populate folders for dialog select
var folders = activeNodes.Where(n => n.Type == "Folder").ToList();
foreach (var folder in folders)
{
AllFoldersList.Add(folder);
}
// Map Node Uuid -> NotebookItem wrapper
var itemMap = activeNodes.ToDictionary(n => n.Uuid, n => new NotebookItem(n));
var childUuids = new System.Collections.Generic.HashSet<string>();
// Build tree links
foreach (var folder in folders)
{
if (itemMap.TryGetValue(folder.Uuid, out var folderItem))
{
try
{
var childrenList = JsonSerializer.Deserialize<List<string>>(folder.Content) ?? new();
foreach (var childUuid in childrenList)
{
if (itemMap.TryGetValue(childUuid, out var childItem))
{
folderItem.Children.Add(childItem);
childUuids.Add(childUuid);
}
}
}
catch { }
}
}
// Roots are all items that are not children of any folder
var roots = itemMap.Values.Where(item => !childUuids.Contains(item.Uuid)).ToList();
foreach (var root in roots)
{
NotebookTree.Add(root);
}
// If a node was selected, refresh it
if (SelectedNode != null)
{
var refreshed = allNodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid && !n.IsDeleted);
if (refreshed != null)
{
SelectNode(refreshed);
}
else
{
SelectedNode = null;
}
}
}
[RelayCommand]
public void SelectNode(Node node)
{
SelectedNode = node;
NoteName = node.Name;
if (node.Type == "Folder")
{
CurrentNotesList.Clear();
using var db = new ClientDbContext();
// Fetch children
try
{
var childrenUuids = JsonSerializer.Deserialize<List<string>>(node.Content) ?? new();
var children = db.Nodes.Where(n => childrenUuids.Contains(n.Uuid) && !n.IsDeleted).ToList();
foreach (var child in children)
{
CurrentNotesList.Add(child);
}
}
catch { }
}
else
{
// Note node: bind content
NoteContent = node.Content;
}
if (IsNarrowMode)
{
IsSidebarVisible = false;
IsContentVisible = true;
}
}
[RelayCommand]
private void OpenCreateFolderDialog()
{
NewFolderName = "New Folder";
IsCreateFolderDialogVisible = true;
}
[RelayCommand]
private void ConfirmCreateFolder()
{
if (string.IsNullOrWhiteSpace(NewFolderName)) return;
CreateFolder(NewFolderName);
IsCreateFolderDialogVisible = false;
}
[RelayCommand]
private void CreateFolder(string name)
{
if (string.IsNullOrWhiteSpace(name)) name = "New Folder";
using var db = new ClientDbContext();
string uuid;
do
{
uuid = Guid.NewGuid().ToString();
} while (db.Nodes.Any(n => n.Uuid == uuid));
var folder = new Node
{
Uuid = uuid,
Name = name,
Type = "Folder",
Content = "[]",
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow
};
db.Nodes.Add(folder);
// If we have an active folder selected, link this new folder inside it
if (SelectedNode != null && SelectedNode.Type == "Folder")
{
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
childList.Add(folder.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
}
catch { }
}
}
db.SaveChanges();
RefreshNotebookTree();
}
[RelayCommand]
private void CreateNote(string name)
{
if (string.IsNullOrWhiteSpace(name)) name = "New Note";
using var db = new ClientDbContext();
string uuid;
do
{
uuid = Guid.NewGuid().ToString();
} while (db.Nodes.Any(n => n.Uuid == uuid));
var note = new Node
{
Uuid = uuid,
Name = name,
Type = "Note",
Content = "Write something here...",
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow
};
db.Nodes.Add(note);
// Must link inside active folder
if (SelectedNode != null && SelectedNode.Type == "Folder")
{
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
childList.Add(note.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
}
catch { }
}
}
db.SaveChanges();
RefreshNotebookTree();
SelectNode(note);
}
[RelayCommand]
private void SaveActiveNote()
{
if (SelectedNode == null || SelectedNode.Type == "Folder") return;
using var db = new ClientDbContext();
var note = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (note != null)
{
note.Name = NoteName;
note.Content = NoteContent;
note.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
SyncStatus = "Saved locally.";
RefreshNotebookTree();
}
}
[RelayCommand]
private void RenameNode(string newName)
{
if (SelectedNode == null || string.IsNullOrWhiteSpace(newName)) return;
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.Name = newName;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
RefreshNotebookTree();
}
}
[RelayCommand]
private void DeleteNode()
{
if (SelectedNode == null) return;
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.IsDeleted = true;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
SelectedNode = null;
RefreshNotebookTree();
GoBack();
}
}
[RelayCommand]
private void OpenLinkDialog()
{
if (SelectedNode == null) return;
IsLinkDialogVisible = true;
}
[RelayCommand]
private void CloseLinkDialog()
{
IsLinkDialogVisible = false;
}
// Symbolic linking of folders/notes
[RelayCommand]
public void LinkNodeToFolder(Node targetFolder)
{
if (SelectedNode == null || targetFolder == null || targetFolder.Uuid == SelectedNode.Uuid) return;
using var db = new ClientDbContext();
var parent = db.Nodes.FirstOrDefault(n => n.Uuid == targetFolder.Uuid);
if (parent != null)
{
try
{
var childList = JsonSerializer.Deserialize<List<string>>(parent.Content) ?? new();
if (!childList.Contains(SelectedNode.Uuid))
{
childList.Add(SelectedNode.Uuid);
parent.Content = JsonSerializer.Serialize(childList);
parent.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
}
}
catch { }
}
IsLinkDialogVisible = false;
RefreshNotebookTree();
SyncStatus = $"Linked successfully to {targetFolder.Name}.";
}
// Start sharing flow: prompts the dialog
[RelayCommand]
private void OpenShareDialog()
{
if (SelectedNode == null) return;
ShareGuid = SelectedNode.Uuid;
SharePasskey = "share-" + new Random().Next(1000, 9999);
IsShareDialogVisible = true;
}
// Initiate sharing on the server
[RelayCommand]
private async Task ConfirmShareNode(string recipient)
{
if (SelectedNode == null || _config == null || string.IsNullOrEmpty(recipient)) return;
SyncStatus = "Sharing with server...";
var (success, error, guid) = await _syncService.ShareNodeAsync(SelectedNode.Uuid, recipient, SharePasskey, _config);
if (success)
{
SyncStatus = "Node shared! Share GUID and passkey shown.";
ShareGuid = guid; // Display GUID for user copy
}
else
{
SyncStatus = $"Share failed: {error}";
IsShareDialogVisible = false;
}
}
[RelayCommand]
private void OpenAcceptDialog()
{
IsAcceptDialogVisible = true;
}
// Accept share flow
[RelayCommand]
private async Task ConfirmAcceptShare()
{
if (_config == null || string.IsNullOrWhiteSpace(AcceptGuid) || string.IsNullOrWhiteSpace(AcceptPasskey)) return;
SyncStatus = "Accepting share from server...";
var (success, error) = await _syncService.AcceptShareAsync(AcceptGuid, AcceptPasskey, _config);
if (success)
{
SyncStatus = "Share accepted. Synchronizing to download...";
// Sync now to pull it
await TriggerSync();
IsAcceptDialogVisible = false;
AcceptGuid = "";
AcceptPasskey = "";
}
else
{
SyncStatus = $"Failed to accept share: {error}";
}
}
// Rename Dialog Commands
[RelayCommand]
private void OpenRenameDialog(NotebookItem item)
{
if (item == null) return;
SelectedNotebookItem = item;
RenameTargetName = item.Node.Name;
IsRenameDialogVisible = true;
}
[RelayCommand]
private void ConfirmRename()
{
if (SelectedNode == null || string.IsNullOrWhiteSpace(RenameTargetName))
{
IsRenameDialogVisible = false;
return;
}
using var db = new ClientDbContext();
var node = db.Nodes.FirstOrDefault(n => n.Uuid == SelectedNode.Uuid);
if (node != null)
{
node.Name = RenameTargetName;
node.UpdatedAt = DateTime.UtcNow;
db.SaveChanges();
RefreshNotebookTree();
}
IsRenameDialogVisible = false;
}
[RelayCommand]
private void CancelRename()
{
IsRenameDialogVisible = false;
}
// Migration Commands
[RelayCommand]
private async Task ConfirmMigration()
{
IsMigrateDialogVisible = false;
SyncStatus = "Migrating offline notes to encrypted database...";
try
{
var userGuid = "";
using (var globalDb = new ClientDbContext())
{
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry != null)
{
userGuid = entry.UserGuid;
}
}
if (string.IsNullOrEmpty(userGuid))
{
SyncStatus = "Migration failed: User directory mapping not found.";
return;
}
// Load all nodes from the local offline database
List<Node> localNodes;
using (var globalDb = new ClientDbContext())
{
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
localNodes = globalDb.Nodes.Where(n => !n.IsDeleted).ToList();
}
// Switch back to the secure DB
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
var userMasterKey = _config?.MasterKey;
if (string.IsNullOrEmpty(userMasterKey))
{
SyncStatus = "Migration failed: Master key not loaded.";
return;
}
foreach (var node in localNodes)
{
// Encrypt node content with a newly generated node-specific key k_0
using var aes = System.Security.Cryptography.Aes.Create();
aes.GenerateKey();
var k0 = Convert.ToBase64String(aes.Key);
var secureNode = new Node
{
Uuid = node.Uuid,
Name = node.Name,
Type = node.Type,
OwnerUsername = Username,
UpdatedAt = DateTime.UtcNow,
IsDeleted = false
};
// Encrypt node content using raw key k0
secureNode.Content = _cryptoService.Encrypt(node.Content, k0);
// Create the encrypted node key mapping k0 encrypted by k_master
var secureKey = new NodeKey
{
NodeUuid = node.Uuid,
Username = Username,
EncryptedNodeKey = _cryptoService.Encrypt(k0, userMasterKey)
};
// Add permission
var acl = new AclEntry
{
NodeUuid = node.Uuid,
UserOrRole = Username,
CanView = true,
CanEdit = true,
CanDelete = true,
CanRename = true
};
userDb.Nodes.Add(secureNode);
userDb.Database.ExecuteSqlRaw(
"INSERT OR IGNORE INTO NodeKeys (NodeUuid, Username, EncryptedNodeKey) VALUES ({0}, {1}, {2})",
secureKey.NodeUuid, secureKey.Username, secureKey.EncryptedNodeKey);
}
await userDb.SaveChangesAsync();
}
SyncStatus = "Local notes migrated successfully! Triggering sync upload...";
await TriggerSync();
}
catch (Exception ex)
{
SyncStatus = $"Migration error: {ex.Message}";
Console.WriteLine($"Migration error: {ex.Message}");
}
}
[RelayCommand]
private async Task CancelMigration()
{
IsMigrateDialogVisible = false;
SyncStatus = "Migration skipped. Syncing empty account...";
await TriggerSync();
}
// Account registration
[RelayCommand]
private async Task TriggerRegister(string password)
{
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(password)) return;
SyncStatus = "Registering on server...";
var (success, error) = await _syncService.RegisterAsync(Username, password, ServerUrl);
if (success)
{
SyncStatus = "Registered! You can now log in.";
// Also pre-create their UserDirectoryEntry so they have a GUID pre-generated!
using (var globalDb = new ClientDbContext())
{
globalDb.Database.EnsureCreated();
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry == null)
{
var userGuid = Guid.NewGuid().ToString();
entry = new UserDirectoryEntry
{
Username = Username,
UserGuid = userGuid,
RemoteServerUrl = ServerUrl
};
globalDb.UserDirectory.Add(entry);
globalDb.SaveChanges();
}
}
ScanLocalAccounts();
}
else
{
SyncStatus = $"Registration failed: {error}";
}
}
// Login and online activation
[RelayCommand]
private async Task TriggerLogin()
{
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(LoginPassword)) return;
SyncStatus = "Logging in to server...";
var (success, error, config) = await _syncService.LoginAsync(Username, LoginPassword, ServerUrl);
if (success && config != null)
{
_config = config;
// Switch database to the secure account database!
string userGuid;
using (var globalDb = new ClientDbContext())
{
globalDb.Database.EnsureCreated();
var entry = globalDb.UserDirectory.FirstOrDefault(u => u.Username.ToLower() == Username.ToLower());
if (entry == null)
{
userGuid = Guid.NewGuid().ToString();
entry = new UserDirectoryEntry
{
Username = Username,
UserGuid = userGuid,
RemoteServerUrl = ServerUrl
};
globalDb.UserDirectory.Add(entry);
globalDb.SaveChanges();
}
else
{
userGuid = entry.UserGuid;
}
}
// Derive key from password for SQLCipher
var encryptionKey = _cryptoService.DeriveKeyFromPassword(LoginPassword, Username);
// Configure ClientDbContext to open the encrypted database
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
// Check if this database file is newly created / has 0 nodes
bool databaseExistsAndEmpty = false;
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
databaseExistsAndEmpty = !userDb.Nodes.Any();
}
// Check if the global local database has nodes to migrate
bool hasLocalNotes = false;
using (var globalDb = new ClientDbContext())
{
// Temporarily switch back to read from global DB
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
hasLocalNotes = globalDb.Nodes.Any(n => !n.IsDeleted);
}
// Restore active DB configuration to user secure database
ClientDbContext.ActiveDbName = $"snote_{userGuid}.db";
ClientDbContext.ActiveDbPassword = encryptionKey;
IsLoggedIn = true;
IsOnline = true;
SyncStatus = "Logged in successfully.";
// Save config to user secure database so it works online
using (var userDb = new ClientDbContext())
{
userDb.Database.EnsureCreated();
userDb.Configs.Add(_config);
userDb.SaveChanges();
}
// First-time login migration check
if (databaseExistsAndEmpty && hasLocalNotes)
{
IsMigrateDialogVisible = true;
}
else
{
// Run Sync directly
await TriggerSync();
}
ScanLocalAccounts();
}
else
{
SyncStatus = $"Login failed: {error}";
}
}
[RelayCommand]
private async Task TriggerSync()
{
if (_config == null)
{
SyncStatus = "Please log in to sync.";
return;
}
SyncStatus = "Synchronizing notes...";
var (success, error) = await _syncService.SyncNodesAsync(_config);
if (success)
{
SyncStatus = $"Synchronized at {DateTime.Now:HH:mm:ss}";
RefreshNotebookTree();
}
else
{
SyncStatus = $"Sync failed: {error}";
}
}
[RelayCommand]
private void DisconnectOnline()
{
IsLoggedIn = false;
IsOnline = false;
LoginPassword = "";
_config = null;
// Switch back to unencrypted default local DB snote_client.db
ClientDbContext.ActiveDbName = "snote_client.db";
ClientDbContext.ActiveDbPassword = null;
SyncStatus = "Switched to local offline mode.";
RefreshNotebookTree();
}
}
@@ -0,0 +1,43 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
x:Class="SNote.Views.AcceptShareDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="380" Height="280"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Add Shared Node"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,15"/>
<StackPanel Grid.Row="1" Spacing="10">
<!-- Shared Item GUID -->
<TextBlock Text="Shared Item GUID" Foreground="#A9B2C3" FontSize="12"/>
<TextBox Text="{Binding AcceptGuid}" Watermark="Enter shared item GUID..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
<!-- Passkey k1 -->
<TextBlock Text="Sharing Passkey (k1)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
<TextBox Text="{Binding AcceptPasskey}" Watermark="Enter passkey k1..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right" Margin="0,15,0,0">
<Button Content="Cancel"
Click="Cancel_Click"/>
<Button Content="Add Note"
Command="{Binding ConfirmAcceptShareCommand}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using SNote.ViewModels;
namespace SNote.Views;
public partial class AcceptShareDialog : UserControl
{
public AcceptShareDialog()
{
InitializeComponent();
}
private void Cancel_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.IsAcceptDialogVisible = false;
vm.AcceptGuid = "";
vm.AcceptPasskey = "";
}
}
}
@@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
x:Class="SNote.Views.CreateFolderDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="360" Height="180"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Create New Folder"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,15"/>
<TextBox Grid.Row="1"
Text="{Binding NewFolderName}"
Watermark="Enter folder name..."
CornerRadius="6"
Background="#0D1B2A"
BorderThickness="0"
Foreground="White"
Padding="8"
VerticalAlignment="Center"/>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right"
Margin="0,15,0,0">
<Button Content="Cancel"
Click="Cancel_Click"/>
<Button Content="Create"
Command="{Binding ConfirmCreateFolderCommand}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,22 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using SNote.ViewModels;
namespace SNote.Views;
public partial class CreateFolderDialog : UserControl
{
public CreateFolderDialog()
{
InitializeComponent();
}
private void Cancel_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.IsCreateFolderDialogVisible = false;
vm.NewFolderName = "";
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:SNote.Models"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="40"
x:Class="SNote.Views.FolderItemView"
x:CompileBindings="False">
<Button Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="8,6"
CornerRadius="6"
Command="{Binding $parent[UserControl].DataContext.SelectNodeCommand}"
CommandParameter="{Binding}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Link to..."
Command="{Binding $parent[UserControl].DataContext.OpenLinkDialogCommand}" />
<MenuItem Header="Share folder..."
Command="{Binding $parent[UserControl].DataContext.OpenShareDialogCommand}" />
<MenuItem Header="Delete"
Foreground="Red"
Command="{Binding $parent[UserControl].DataContext.DeleteNodeCommand}" />
</ContextMenu>
</Button.ContextMenu>
<StackPanel Orientation="Horizontal" Spacing="10">
<!-- Sleek Folder Icon -->
<Path Data="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
Fill="#E9C46A"
Width="16" Height="16"
Stretch="Uniform"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold"
Foreground="#E0E1DD"
FontSize="13"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace SNote.Views;
public partial class FolderItemView : UserControl
{
public FolderItemView()
{
InitializeComponent();
}
}
+52
View File
@@ -0,0 +1,52 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="300"
x:Class="SNote.Views.LinkFolderDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="360" Height="300"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Link Node to Folder"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,15"/>
<!-- Folders list selector -->
<ScrollViewer Grid.Row="1" Margin="0,0,0,15">
<ItemsControl ItemsSource="{Binding AllFoldersList}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Name}"
HorizontalAlignment="Stretch"
Background="Transparent"
BorderThickness="0"
Padding="10,8"
CornerRadius="6"
Foreground="#E0E1DD"
Command="{Binding $parent[UserControl].DataContext.LinkNodeToFolderCommand, DataType={x:Type vm:MainViewModel}}"
CommandParameter="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Cancel action -->
<Button Grid.Row="2"
Content="Cancel"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding CloseLinkDialogCommand}"/>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace SNote.Views;
public partial class LinkFolderDialog : UserControl
{
public LinkFolderDialog()
{
InitializeComponent();
}
}
+258 -8
View File
@@ -3,14 +3,264 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:views="using:SNote.Views"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
x:Class="SNote.Views.MainView"
x:DataType="vm:MainViewModel">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainViewModel />
</Design.DataContext>
x:DataType="vm:MainViewModel"
Background="#0D1B2A"
SizeChanged="UserControl_SizeChanged">
<TextBlock Text="{Binding Greeting}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Grid>
<!-- Main Application Split Layout -->
<Grid ColumnDefinitions="Auto,*" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<!-- 1. LEFT COLUMN: SIDEBAR -->
<Border Grid.Column="0"
Width="280"
Background="#1B263B"
BorderBrush="#3E5C76"
BorderThickness="0,0,1,0"
IsVisible="{Binding IsSidebarVisible}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="15">
<!-- Header / Brand -->
<StackPanel Grid.Row="0" Spacing="5" Margin="0,0,0,15">
<TextBlock Text="SNote Notebook"
FontSize="18"
FontWeight="Bold"
Foreground="#F0F3F4"/>
<Border Background="#415A77" CornerRadius="4" Padding="6,3" HorizontalAlignment="Left">
<TextBlock Text="{Binding SyncStatus}"
FontSize="10"
Foreground="#E0E1DD"
FontWeight="SemiBold"/>
</Border>
</StackPanel>
<!-- Server Login / Settings Panel -->
<Border Grid.Row="1"
Background="#0D1B2A"
CornerRadius="8"
Padding="12"
Margin="0,0,0,15"
BorderBrush="#3E5C76"
BorderThickness="1">
<StackPanel Spacing="8">
<TextBlock Text="Connection &amp; Sync" FontSize="11" FontWeight="Bold" Foreground="#A9B2C3"/>
<!-- If Logged In -->
<StackPanel Spacing="6" IsVisible="{Binding IsLoggedIn}">
<TextBlock Text="{Binding Username}" FontWeight="SemiBold" Foreground="#2A9D8F" FontSize="13"/>
<TextBlock Text="{Binding ServerUrl}" Foreground="#8D99AE" FontSize="10" TextTrimming="CharacterEllipsis"/>
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0" Content="Sync Now" Command="{Binding TriggerSyncCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
<Button Grid.Column="1" Content="Disconnect" Margin="5,0,0,0" Command="{Binding DisconnectOnlineCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
</Grid>
</StackPanel>
<!-- If Logged Out (Local Mode) -->
<StackPanel Spacing="6" IsVisible="{Binding !IsLoggedIn}">
<!-- Local Accounts Selector -->
<StackPanel Spacing="4" Margin="0,0,0,5" IsVisible="{Binding LocalAccounts.Count}">
<TextBlock Text="Select local account:" FontSize="10" Foreground="#8D99AE" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding LocalAccounts}"
SelectedItem="{Binding SelectedLocalAccount}"
PlaceholderText="Select user..."
HorizontalAlignment="Stretch"
Background="#1B263B"
BorderThickness="0"
CornerRadius="4"/>
</StackPanel>
<TextBox Text="{Binding Username}" PlaceholderText="Username" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
<TextBox Text="{Binding LoginPassword}" PasswordChar="*" PlaceholderText="Password" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
<TextBox Text="{Binding ServerUrl}" PlaceholderText="Server Url" Background="#1B263B" Foreground="White" BorderThickness="0" CornerRadius="4" Padding="6,4" FontSize="11"/>
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0" Content="Login" Command="{Binding TriggerLoginCommand}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
<Button Grid.Column="1" Content="Register" Margin="5,0,0,0" Command="{Binding TriggerRegisterCommand}" CommandParameter="{Binding LoginPassword}" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
</Grid>
</StackPanel>
</StackPanel>
</Border>
<!-- Tree structured view of folders and notes -->
<Grid Grid.Row="2" RowDefinitions="Auto,*" RowSpacing="10">
<!-- Header actions -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="0,0,0,5">
<TextBlock Grid.Column="0" Text="Notebook Tree" FontSize="12" FontWeight="Bold" Foreground="#A9B2C3" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="+ Folder" Command="{Binding OpenCreateFolderDialogCommand}"/>
<Button Grid.Column="2" Content="+ Note" Margin="5,0,0,0" Command="{Binding CreateNoteCommand}" CommandParameter="New Note"/>
<Button Grid.Column="3" Content="Accept" Margin="5,0,0,0" Command="{Binding OpenAcceptDialogCommand}"/>
</Grid>
<!-- Recursive TreeView -->
<TreeView Grid.Row="1"
ItemsSource="{Binding NotebookTree}"
SelectedItem="{Binding SelectedNotebookItem, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
x:CompileBindings="False">
<TreeView.DataTemplates>
<TreeDataTemplate DataType="{x:Type vm:NotebookItem}" ItemsSource="{Binding Children}">
<!-- Tree Node container with context menu -->
<Grid ColumnDefinitions="Auto,*" Background="Transparent">
<Grid.ContextMenu>
<ContextMenu>
<MenuItem Header="Rename" Command="{Binding $parent[TreeView].DataContext.OpenRenameDialogCommand}" CommandParameter="{Binding}"/>
<MenuItem Header="Link to..." Command="{Binding $parent[TreeView].DataContext.OpenLinkDialogCommand}"/>
<MenuItem Header="Share..." Command="{Binding $parent[TreeView].DataContext.OpenShareDialogCommand}"/>
<MenuItem Header="Delete" Command="{Binding $parent[TreeView].DataContext.DeleteNodeCommand}"/>
</ContextMenu>
</Grid.ContextMenu>
<!-- Icon indicator based on node type -->
<Path Grid.Column="0"
Data="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
Fill="#E9C46A"
Width="14" Height="14"
Stretch="Uniform"
VerticalAlignment="Center"
Margin="0,0,8,0"
IsVisible="{Binding IsFolder}"/>
<Path Grid.Column="0"
Data="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"
Fill="#2A9D8F"
Width="14" Height="14"
Stretch="Uniform"
VerticalAlignment="Center"
Margin="0,0,8,0"
IsVisible="{Binding IsNote}"/>
<TextBlock Grid.Column="1"
Text="{Binding Name}"
Foreground="#E0E1DD"
FontSize="13"
VerticalAlignment="Center"/>
</Grid>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
</Grid>
<!-- Bottom offline footer -->
<TextBlock Grid.Row="3"
Text="SNote v1.0.0"
FontSize="10"
Foreground="#8D99AE"
HorizontalAlignment="Center"
Margin="0,10,0,0"/>
</Grid>
</Border>
<!-- 2. RIGHT COLUMN: EDITOR PANEL -->
<Border Grid.Column="1"
Background="#0D1B2A"
IsVisible="{Binding IsContentVisible}">
<Grid>
<!-- When NO note is selected -->
<Grid IsVisible="{Binding SelectedNode, Converter={x:Static ObjectConverters.IsNull}}"
VerticalAlignment="Center" HorizontalAlignment="Center">
<StackPanel Spacing="15" HorizontalAlignment="Center">
<Path Data="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10H7v-2h10v2zm0-4H7V7h10v2z"
Fill="#415A77"
Width="64" Height="64"
HorizontalAlignment="Center"
Stretch="Uniform"/>
<TextBlock Text="Select a note or folder to start"
FontSize="15"
Foreground="#A9B2C3"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
<!-- Note Editor Panel -->
<Grid IsVisible="{Binding SelectedNode, Converter={x:Static ObjectConverters.IsNotNull}}"
RowDefinitions="Auto,*" Margin="20">
<!-- Editor Title bar -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto,Auto,Auto" Margin="0,0,0,15">
<!-- Back button (Narrow mode only) -->
<Button Grid.Column="0"
Content="&lt; Back"
Margin="0,0,10,0"
IsVisible="{Binding IsNarrowMode}"
Command="{Binding GoBackCommand}"/>
<TextBox Grid.Column="1"
Text="{Binding NoteName}"
FontSize="16"
FontWeight="Bold"
Background="Transparent"
BorderThickness="0"
Foreground="White"
VerticalAlignment="Center"/>
<!-- Save Note action -->
<Button Grid.Column="2"
Content="Save Note"
Margin="5,0"
Command="{Binding SaveActiveNoteCommand}"/>
<!-- Share action -->
<Button Grid.Column="3"
Content="Share..."
Margin="5,0"
Command="{Binding OpenShareDialogCommand}"/>
<!-- Delete action -->
<Button Grid.Column="4"
Content="Delete"
Command="{Binding DeleteNodeCommand}"/>
</Grid>
<!-- Text editor body -->
<TextBox Grid.Row="1"
Text="{Binding NoteContent}"
PlaceholderText="Type your notes here..."
AcceptsReturn="True"
TextWrapping="Wrap"
Background="#1B263B"
BorderThickness="0"
CornerRadius="10"
Padding="15"
Foreground="White"
FontSize="14"/>
</Grid>
</Grid>
</Border>
</Grid>
<!-- 3. DIALOG OVERLAY COVERS -->
<!-- Create Folder Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsCreateFolderDialogVisible}">
<views:CreateFolderDialog DataContext="{Binding}"/>
</Grid>
<!-- Link to Folder Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsLinkDialogVisible}">
<views:LinkFolderDialog DataContext="{Binding}"/>
</Grid>
<!-- Share Node Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsShareDialogVisible}">
<views:ShareNodeDialog DataContext="{Binding}"/>
</Grid>
<!-- Accept Share Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsAcceptDialogVisible}">
<views:AcceptShareDialog DataContext="{Binding}"/>
</Grid>
<!-- Rename Item Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsRenameDialogVisible}">
<views:RenameDialog DataContext="{Binding}"/>
</Grid>
<!-- Migrate Notes Dialog -->
<Grid Background="#B0000000" IsVisible="{Binding IsMigrateDialogVisible}">
<views:MigrateNotesDialog DataContext="{Binding}"/>
</Grid>
</Grid>
</UserControl>
+9
View File
@@ -1,4 +1,5 @@
using Avalonia.Controls;
using SNote.ViewModels;
namespace SNote.Views;
@@ -8,4 +9,12 @@ public partial class MainView : UserControl
{
InitializeComponent();
}
private void UserControl_SizeChanged(object? sender, SizeChangedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.UpdateWidthMode(e.NewSize.Width);
}
}
}
@@ -0,0 +1,44 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="220"
x:Class="SNote.Views.MigrateNotesDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="400" Height="190"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Migrate Local Notes?"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,10"/>
<TextBlock Grid.Row="1"
Text="It looks like this is the first time you are logging into this remote account, and you have offline notes saved locally. Do you want to migrate your local notes to this remote account?"
FontSize="12"
Foreground="#C2C5BB"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right"
Margin="0,15,0,0">
<Button Content="Keep Empty"
Command="{Binding CancelMigrationCommand}"/>
<Button Content="Migrate Notes"
Command="{Binding ConfirmMigrationCommand}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace SNote.Views;
public partial class MigrateNotesDialog : UserControl
{
public MigrateNotesDialog()
{
InitializeComponent();
}
}
+46
View File
@@ -0,0 +1,46 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:SNote.Models"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="40"
x:Class="SNote.Views.NoteItemView"
x:CompileBindings="False">
<Button Background="Transparent"
BorderThickness="0"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="8,6"
CornerRadius="6"
Command="{Binding $parent[UserControl].DataContext.SelectNodeCommand}"
CommandParameter="{Binding}">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="Link to..."
Command="{Binding $parent[UserControl].DataContext.OpenLinkDialogCommand}" />
<MenuItem Header="Share note..."
Command="{Binding $parent[UserControl].DataContext.OpenShareDialogCommand}" />
<MenuItem Header="Delete"
Foreground="Red"
Command="{Binding $parent[UserControl].DataContext.DeleteNodeCommand}" />
</ContextMenu>
</Button.ContextMenu>
<StackPanel Orientation="Horizontal" Spacing="10">
<!-- Sleek Note Icon -->
<Path Data="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"
Fill="#2A9D8F"
Width="16" Height="16"
Stretch="Uniform"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}"
Foreground="#E0E1DD"
FontSize="13"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace SNote.Views;
public partial class NoteItemView : UserControl
{
public NoteItemView()
{
InitializeComponent();
}
}
+47
View File
@@ -0,0 +1,47 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
x:Class="SNote.Views.RenameDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="360" Height="180"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Rename Item"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,15"/>
<TextBox Grid.Row="1"
Text="{Binding RenameTargetName}"
Watermark="Enter new name..."
CornerRadius="6"
Background="#0D1B2A"
BorderThickness="0"
Foreground="White"
Padding="8"
VerticalAlignment="Center"/>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right"
Margin="0,15,0,0">
<Button Content="Cancel"
Command="{Binding CancelRenameCommand}"/>
<Button Content="Rename"
Command="{Binding ConfirmRenameCommand}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace SNote.Views;
public partial class RenameDialog : UserControl
{
public RenameDialog()
{
InitializeComponent();
}
}
+51
View File
@@ -0,0 +1,51 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:SNote.ViewModels"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350"
x:Class="SNote.Views.ShareNodeDialog"
x:DataType="vm:MainViewModel">
<Border Background="#1D2D44"
CornerRadius="12"
Padding="20"
Width="380" Height="360"
HorizontalAlignment="Center" VerticalAlignment="Center"
BoxShadow="0 4 20 #000000">
<Grid RowDefinitions="Auto,*,Auto" VerticalAlignment="Stretch">
<TextBlock Grid.Row="0"
Text="Cryptographic Share Node"
FontSize="16" FontWeight="Bold"
Foreground="#F0F3F4"
Margin="0,0,0,15"/>
<StackPanel Grid.Row="1" Spacing="10">
<!-- Recipient Username -->
<TextBlock Text="Recipient Username" Foreground="#A9B2C3" FontSize="12"/>
<TextBox x:Name="RecipientInput" Watermark="Enter recipient username..." CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
<!-- Passkey k1 -->
<TextBlock Text="Sharing Passkey (k1)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
<TextBox Text="{Binding SharePasskey}" CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="White" Padding="8"/>
<!-- Display Shared GUID after share completes -->
<TextBlock Text="Shared Item GUID (Copy to recipient)" Foreground="#A9B2C3" FontSize="12" Margin="0,5,0,0"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding ShareGuid, Mode=OneWay}" IsReadOnly="True" CornerRadius="6" Background="#0D1B2A" BorderThickness="0" Foreground="#2A9D8F" Padding="8"/>
<Button Grid.Column="1" Content="Copy" Margin="5,0,0,0" Click="CopyGuid_Click"/>
</Grid>
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right" Margin="0,15,0,0">
<Button Content="Close"
Click="Close_Click"/>
<Button Content="Confirm Share"
Command="{Binding ConfirmShareNodeCommand}"
CommandParameter="{Binding #RecipientInput.Text}"/>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,36 @@
using System;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Input.Platform;
using SNote.ViewModels;
namespace SNote.Views;
public partial class ShareNodeDialog : UserControl
{
public ShareNodeDialog()
{
InitializeComponent();
}
private async void CopyGuid_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && !string.IsNullOrEmpty(vm.ShareGuid))
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.Clipboard != null)
{
await topLevel.Clipboard.SetTextAsync(vm.ShareGuid);
}
}
}
private void Close_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is MainViewModel vm)
{
vm.IsShareDialogVisible = false;
}
}
}
+4
View File
@@ -17,6 +17,10 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="9.0.0" />
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.8" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.15" />
</ItemGroup>
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using SNote.Models;
namespace SNote.Server.Data;
public class ServerDbContext : DbContext
{
public ServerDbContext(DbContextOptions<ServerDbContext> options) : base(options)
{
}
public DbSet<User> Users => Set<User>();
public DbSet<Node> Nodes => Set<Node>();
public DbSet<NodeKey> NodeKeys => Set<NodeKey>();
public DbSet<PendingShare> PendingShares => Set<PendingShare>();
public DbSet<AclEntry> AclEntries => Set<AclEntry>();
public DbSet<PeerServer> PeerServers => Set<PeerServer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
}
}
+99
View File
@@ -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);
+590
View File
@@ -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);
+500
View File
@@ -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);
+91 -32
View File
@@ -1,5 +1,17 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SNote.Server.Data;
using SNote.Server.Endpoints;
using SNote.Server.Security;
namespace SNote.Server;
@@ -7,50 +19,97 @@ public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateSlimBuilder(args);
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
// Add Configuration & DB Context (SQLite)
var dbPath = Path.Combine(AppContext.BaseDirectory, "snote_server.db");
builder.Services.AddDbContext<ServerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
// Register custom security managers & HTTP utilities
builder.Services.AddSingleton<CertificateManager>();
builder.Services.AddSingleton<PeerCache>();
builder.Services.AddSingleton<HttpClient>();
// Register OpenAPI explorer
builder.Services.AddOpenApi();
var app = builder.Build();
// 1. Initialize SQLite Database Schema
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
db.Database.EnsureCreated();
}
// Configure Development Tools
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
Todo[] sampleTodos =
[
new(1, "Walk the dog"),
new(2, "Do the dishes", DateOnly.FromDateTime(DateTime.Now)),
new(3, "Do the laundry", DateOnly.FromDateTime(DateTime.Now.AddDays(1))),
new(4, "Clean the bathroom"),
new(5, "Clean the car", DateOnly.FromDateTime(DateTime.Now.AddDays(2)))
];
// 2. Map Modular API Endpoint Classes
app.MapAuthEndpoints();
app.MapNodeEndpoints();
app.MapSyncEndpoints();
var todosApi = app.MapGroup("/todos");
todosApi.MapGet("/", () => sampleTodos)
.WithName("GetTodos");
// Welcome / Healthcheck root
app.MapGet("/", () => Results.Ok(new
{
service = "SNote Server",
status = "Online",
time = DateTime.UtcNow
}));
todosApi.MapGet("/{id}", Results<Ok<Todo>, NotFound> (int id) =>
sampleTodos.FirstOrDefault(a => a.Id == id) is { } todo
? TypedResults.Ok(todo)
: TypedResults.NotFound())
.WithName("GetTodoById");
// 3. Start Background Sync Runner
var hostApplicationLifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
var services = app.Services;
// Dynamic Bootstrapping check on startup
var config = app.Configuration;
var bootstrapUrl = config["BootstrapFromPeer"];
if (!string.IsNullOrEmpty(bootstrapUrl))
{
Task.Run(async () =>
{
// Wait briefly for server startup
await Task.Delay(2000);
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ServerDbContext>();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
await SyncEndpoints.BootstrapFromPeerServerAsync(bootstrapUrl, db, certManager, client);
});
}
// Dynamic Handshake: Connect to target destination server and register local address on startup
var destUrl = config["Sync:DestinationServerUrl"];
var localUrl = config["Sync:LocalServerUrl"];
if (!string.IsNullOrEmpty(destUrl) && !string.IsNullOrEmpty(localUrl))
{
Task.Run(async () =>
{
// Wait briefly for server startup
await Task.Delay(2000);
using var scope = services.CreateScope();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
await SyncEndpoints.RegisterPeerWithDestinationAsync(destUrl, localUrl, certManager, client);
});
}
// Heartbeat Loop: Periodic Downstream Heartbeat pinger pings downstream peers every 30 seconds
Task.Run(async () =>
{
var token = hostApplicationLifetime.ApplicationStopping;
using var scope = services.CreateScope();
var peerCache = scope.ServiceProvider.GetRequiredService<PeerCache>();
var certManager = scope.ServiceProvider.GetRequiredService<CertificateManager>();
var client = scope.ServiceProvider.GetRequiredService<HttpClient>();
await SyncEndpoints.StartHeartbeatPingerAsync(peerCache, certManager, client, token);
});
app.Run();
}
}
public record Todo(int Id, string? Title, DateOnly? DueBy = null, bool IsComplete = false);
[JsonSerializable(typeof(Todo[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
+8 -2
View File
@@ -4,12 +4,18 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<InvariantGlobalization>false</InvariantGlobalization>
<PublishAot>false</PublishAot>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Client\SNote\Models\Models.cs" Link="Models\Models.cs" />
</ItemGroup>
</Project>
+99
View File
@@ -0,0 +1,99 @@
using System;
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Tokens;
using SNote.Server.Security;
namespace SNote.Server.Security;
public static class AuthHelper
{
public static string? GetAuthenticatedUser(HttpContext context, CertificateManager certificateManager)
{
var token = GetBearerToken(context);
if (string.IsNullOrEmpty(token)) return null;
try
{
var principal = ValidateToken(token, certificateManager);
// Verify it has a Name claim and is not just a server token
var isServer = principal.HasClaim(c => c.Type == "IsServer" && c.Value == "true");
if (isServer) return null;
return principal.Identity?.Name;
}
catch
{
return null;
}
}
public static bool IsServerAuthenticated(HttpContext context, CertificateManager certificateManager)
{
var token = GetBearerToken(context);
if (string.IsNullOrEmpty(token)) return false;
try
{
var principal = ValidateToken(token, certificateManager);
return principal.HasClaim(c => c.Type == "IsServer" && c.Value == "true");
}
catch
{
return false;
}
}
// Helper to generate a server token for outgoing sync requests
public static string GenerateServerToken(CertificateManager certificateManager)
{
var cert = certificateManager.GetCertificate();
var privateKey = new X509SecurityKey(cert);
var claims = new[]
{
new Claim(ClaimTypes.Name, "SNoteServerNetwork"),
new Claim("IsServer", "true")
};
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(15), // Short-lived for security
SigningCredentials = new SigningCredentials(privateKey, SecurityAlgorithms.RsaSha256)
};
var tokenHandler = new JwtSecurityTokenHandler();
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private static string? GetBearerToken(HttpContext context)
{
var authHeader = context.Request.Headers["Authorization"].ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return authHeader.Substring("Bearer ".Length).Trim();
}
private static ClaimsPrincipal ValidateToken(string token, CertificateManager certificateManager)
{
var cert = certificateManager.GetCertificate();
var publicKey = new X509SecurityKey(cert);
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = publicKey,
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.FromMinutes(5)
};
var handler = new JwtSecurityTokenHandler();
return handler.ValidateToken(token, validationParameters, out _);
}
}
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
namespace SNote.Server.Security;
public class CertificateManager
{
private readonly string _certPath;
private readonly string _certPassword = "snote-password";
private X509Certificate2? _certificate;
public CertificateManager(IConfiguration configuration)
{
var path = configuration["SharedCertificatePath"];
if (string.IsNullOrEmpty(path))
{
path = Path.Combine(AppContext.BaseDirectory, "snote-shared.pfx");
}
_certPath = path;
}
public X509Certificate2 GetCertificate()
{
if (_certificate != null) return _certificate;
if (File.Exists(_certPath))
{
try
{
var bytes = File.ReadAllBytes(_certPath);
// EphemeralKeySet is standard and cross-platform for loading certificates from memory
_certificate = new X509Certificate2(bytes, _certPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
return _certificate;
}
catch (Exception ex)
{
Console.WriteLine($"Error loading certificate at {_certPath}: {ex.Message}. Re-generating.");
}
}
// Generate new self-signed certificate
_certificate = GenerateCertificate();
return _certificate;
}
private X509Certificate2 GenerateCertificate()
{
Console.WriteLine($"Generating a new shared network certificate at {_certPath}...");
using var rsa = RSA.Create(2048);
var req = new CertificateRequest("cn=SNoteSharedNetwork", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
var selfSigned = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(20));
var pfxBytes = selfSigned.Export(X509ContentType.Pfx, _certPassword);
var dir = Path.GetDirectoryName(_certPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
File.WriteAllBytes(_certPath, pfxBytes);
// Reload it to ensure it contains exportable keys and correct storage flags
return new X509Certificate2(pfxBytes, _certPassword, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet);
}
}
+33
View File
@@ -0,0 +1,33 @@
using System;
using System.Security.Cryptography;
namespace SNote.Server.Security;
public static class PasswordHasher
{
private const int SaltSize = 16; // 128 bit
private const int KeySize = 32; // 256 bit
private const int Iterations = 10000;
public static string HashPassword(string password)
{
using var algorithm = new Rfc2898DeriveBytes(password, SaltSize, Iterations, HashAlgorithmName.SHA256);
var key = Convert.ToBase64String(algorithm.GetBytes(KeySize));
var salt = Convert.ToBase64String(algorithm.Salt);
return $"{Iterations}.{salt}.{key}";
}
public static bool VerifyPassword(string hash, string password)
{
var parts = hash.Split('.', 3);
if (parts.Length != 3) return false;
var iterations = int.Parse(parts[0]);
var salt = Convert.FromBase64String(parts[1]);
var key = Convert.FromBase64String(parts[2]);
using var algorithm = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
var keyToCheck = algorithm.GetBytes(KeySize);
return CryptographicOperations.FixedTimeEquals(key, keyToCheck);
}
}
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace SNote.Server.Security;
public class PeerCache
{
private readonly ConcurrentDictionary<string, byte> _downstreamPeers = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DateTime> _recentBroadcastIds = new(StringComparer.OrdinalIgnoreCase);
public void RegisterPeer(string peerUrl)
{
if (string.IsNullOrWhiteSpace(peerUrl)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/');
_downstreamPeers.TryAdd(cleanUrl, 0);
Console.WriteLine($"[PeerCache] Registered downstream peer node: {cleanUrl}");
}
public void RemovePeer(string peerUrl)
{
if (string.IsNullOrWhiteSpace(peerUrl)) return;
var cleanUrl = peerUrl.Trim().TrimEnd('/');
if (_downstreamPeers.TryRemove(cleanUrl, out _))
{
Console.WriteLine($"[PeerCache] Evicted offline peer node: {cleanUrl}");
}
}
public List<string> GetPeers()
{
return new List<string>(_downstreamPeers.Keys);
}
public bool TryProcessBroadcast(string broadcastId)
{
if (string.IsNullOrWhiteSpace(broadcastId)) return false;
// Clean up old entries to prevent infinite memory growth (older than 10 minutes)
var cutoff = DateTime.UtcNow.AddMinutes(-10);
foreach (var kvp in _recentBroadcastIds)
{
if (kvp.Value < cutoff)
{
_recentBroadcastIds.TryRemove(kvp.Key, out _);
}
}
return _recentBroadcastIds.TryAdd(broadcastId, DateTime.UtcNow);
}
}
+80
View File
@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SNote.Server.Security;
public static class SecurityHelper
{
// Encrypts plaintext using AES-256 with a randomized IV, prepending IV to the ciphertext.
public static string Encrypt(string plaintext, string base64Key)
{
if (string.IsNullOrEmpty(plaintext)) return plaintext;
try
{
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.GenerateIV();
var iv = aes.IV;
using var encryptor = aes.CreateEncryptor(aes.Key, aes.IV);
using var ms = new MemoryStream();
// Prepend IV
ms.Write(iv, 0, iv.Length);
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write))
using (var sw = new StreamWriter(cs, Encoding.UTF8))
{
sw.Write(plaintext);
}
return Convert.ToBase64String(ms.ToArray());
}
catch (Exception ex)
{
Console.WriteLine($"Encryption error: {ex.Message}");
return plaintext;
}
}
// Decrypts ciphertext (which has prepended IV) using AES-256.
public static string Decrypt(string ciphertext, string base64Key)
{
if (string.IsNullOrEmpty(ciphertext)) return ciphertext;
try
{
var fullCipher = Convert.FromBase64String(ciphertext);
var keyBytes = Convert.FromBase64String(base64Key);
using var aes = Aes.Create();
aes.Key = keyBytes;
var iv = new byte[16];
var cipherTextBytes = new byte[fullCipher.Length - 16];
Buffer.BlockCopy(fullCipher, 0, iv, 0, 16);
Buffer.BlockCopy(fullCipher, 16, cipherTextBytes, 0, cipherTextBytes.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor(aes.Key, aes.IV);
using var ms = new MemoryStream(cipherTextBytes);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var sr = new StreamReader(cs, Encoding.UTF8);
return sr.ReadToEnd();
}
catch (Exception ex)
{
Console.WriteLine($"Decryption error: {ex.Message}. Returning ciphertext as-is.");
return ciphertext;
}
}
// Server-to-server signing token or helper (optional, or we do JWTs)
}
+6 -1
View File
@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Sync": {
"DestinationServerUrl": "",
"LocalServerUrl": ""
},
"EnableFullDbPull": false
}