diff --git a/.gitignore b/.gitignore index f6adf54..3e34ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .vscode/ bin/ obj/ +*.db +*.tmp \ No newline at end of file diff --git a/Client/SNote/Data/ClientDbContext.cs b/Client/SNote/Data/ClientDbContext.cs new file mode 100644 index 0000000..7bd769a --- /dev/null +++ b/Client/SNote/Data/ClientDbContext.cs @@ -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 Nodes => Set(); + public DbSet Configs => Set(); + public DbSet UserDirectory => Set(); + + 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; +} diff --git a/Client/SNote/Models/Models.cs b/Client/SNote/Models/Models.cs new file mode 100644 index 0000000..2d889a5 --- /dev/null +++ b/Client/SNote/Models/Models.cs @@ -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; +} diff --git a/Client/SNote/SNote.csproj b/Client/SNote/SNote.csproj index 8a03b5e..649fa45 100644 --- a/Client/SNote/SNote.csproj +++ b/Client/SNote/SNote.csproj @@ -1,4 +1,4 @@ - + net10.0 enable @@ -19,5 +19,7 @@ All + + diff --git a/Client/SNote/Services/CryptoService.cs b/Client/SNote/Services/CryptoService.cs new file mode 100644 index 0000000..f29e8ee --- /dev/null +++ b/Client/SNote/Services/CryptoService.cs @@ -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; + } + } +} diff --git a/Client/SNote/Services/SyncService.cs b/Client/SNote/Services/SyncService.cs new file mode 100644 index 0000000..84ac686 --- /dev/null +++ b/Client/SNote/Services/SyncService.cs @@ -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>(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 Items); +public record SyncPushItem(Node Node, List AclEntries, List NodeKeys); +public record SyncExchangeResponse(List ToPush, List 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 +); diff --git a/Client/SNote/ViewModels/MainViewModel.cs b/Client/SNote/ViewModels/MainViewModel.cs index 5a83dcb..b112143 100644 --- a/Client/SNote/ViewModels/MainViewModel.cs +++ b/Client/SNote/ViewModels/MainViewModel.cs @@ -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 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 NotebookTree { get; } = new(); + public ObservableCollection CurrentNotesList { get; } = new(); + public ObservableCollection AllFoldersList { get; } = new(); // Used in Link to... dialog + public ObservableCollection 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(); + + // Build tree links + foreach (var folder in folders) + { + if (itemMap.TryGetValue(folder.Uuid, out var folderItem)) + { + try + { + var childrenList = JsonSerializer.Deserialize>(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>(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>(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>(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>(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 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(); + } } diff --git a/Client/SNote/Views/AcceptShareDialog.axaml b/Client/SNote/Views/AcceptShareDialog.axaml new file mode 100644 index 0000000..3092d66 --- /dev/null +++ b/Client/SNote/Views/AcceptShareDialog.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/SNote/Views/FolderItemView.axaml.cs b/Client/SNote/Views/FolderItemView.axaml.cs new file mode 100644 index 0000000..7db31f4 --- /dev/null +++ b/Client/SNote/Views/FolderItemView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace SNote.Views; + +public partial class FolderItemView : UserControl +{ + public FolderItemView() + { + InitializeComponent(); + } +} diff --git a/Client/SNote/Views/LinkFolderDialog.axaml b/Client/SNote/Views/LinkFolderDialog.axaml new file mode 100644 index 0000000..518e2f0 --- /dev/null +++ b/Client/SNote/Views/LinkFolderDialog.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/Client/SNote/Views/NoteItemView.axaml.cs b/Client/SNote/Views/NoteItemView.axaml.cs new file mode 100644 index 0000000..1d3a4e9 --- /dev/null +++ b/Client/SNote/Views/NoteItemView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace SNote.Views; + +public partial class NoteItemView : UserControl +{ + public NoteItemView() + { + InitializeComponent(); + } +} diff --git a/Client/SNote/Views/RenameDialog.axaml b/Client/SNote/Views/RenameDialog.axaml new file mode 100644 index 0000000..8b2103a --- /dev/null +++ b/Client/SNote/Views/RenameDialog.axaml @@ -0,0 +1,47 @@ + + + + + + + + + + + +