diff --git a/Notemark/Components/Pages/Editor.razor b/Notemark/Components/Pages/Editor.razor index 09952d6..051c857 100644 --- a/Notemark/Components/Pages/Editor.razor +++ b/Notemark/Components/Pages/Editor.razor @@ -73,7 +73,12 @@
- @saveStatus + + @saveStatus @(saveStatus == "Error" ? "⚠️" : "") + @wordCount words • @charCount chars • @readingTime min read @@ -282,11 +287,17 @@ IsOpenChanged="@((val) => showNotesDrawer = val)" CurrentDocumentId="@sessionId" OnSelectDocument="@OnDocumentSelected" /> + + +
@code { [Parameter] public string? DocumentId { get; set; } + private bool showVaultModal = false; private string sessionId = ""; private string documentTitle = "Untitled Note"; private bool isFlagged = false; @@ -296,6 +307,7 @@ private string renderedHtml = ""; private bool showPreview = true; private string saveStatus = "Saved"; + private string? saveErrorMessage = null; private bool showNotesDrawer = false; private bool showExportDropdown = false; private ElementReference editorTextareaRef; @@ -519,6 +531,7 @@ if (string.IsNullOrEmpty(sessionId)) return; saveStatus = "Saving..."; + saveErrorMessage = null; StateHasChanged(); try @@ -529,9 +542,11 @@ saveStatus = "Saved"; await UpdateFlaggedCount(); } - catch + catch (Exception ex) { + saveErrorMessage = ex.Message; saveStatus = "Error"; + Console.Error.WriteLine($"[Notemark Save Error] Session: {sessionId} - {ex}"); } StateHasChanged(); @@ -618,6 +633,11 @@ { await PrintDocument(); } + else if ((e.CtrlKey || e.MetaKey) && (e.ShiftKey || e.AltKey) && e.Key.Equals("a", StringComparison.OrdinalIgnoreCase)) + { + showVaultModal = true; + StateHasChanged(); + } } private async Task ManualSave() diff --git a/Notemark/Components/Pages/Home.razor b/Notemark/Components/Pages/Home.razor index 5ec2266..3c117ef 100644 --- a/Notemark/Components/Pages/Home.razor +++ b/Notemark/Components/Pages/Home.razor @@ -1,5 +1,7 @@ @page "/" +@page "/vault" @rendermode InteractiveServer +@implements IDisposable @using Notemark.Models @using Notemark.Services @inject IDocumentService DocumentService @@ -13,7 +15,7 @@
-
+
@code { private bool isLoading = true; + private bool showVaultModal = false; private string searchQuery = ""; private string selectedTag = ""; private DocumentMetadata? localRecentDoc; private List displayedNotes = new(); private List activeTags = new(); + // Secret Logo Click Tracking + private int logoClickCount = 0; + private Timer? logoClickTimer; + private DotNetObjectReference? dotNetRef; + private List flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList(); private List filteredNotes @@ -250,6 +263,11 @@ protected override async Task OnInitializedAsync() { + if (Navigation.Uri.Contains("/vault", StringComparison.OrdinalIgnoreCase)) + { + showVaultModal = true; + } + await LoadDashboard(); } @@ -259,6 +277,15 @@ { try { + // Register global hotkey callback + dotNetRef = DotNetObjectReference.Create(this); + await JSRuntime.InvokeVoidAsync("eval", @" + window._homeVaultHandler = function() { + DotNet.invokeMethodAsync('Notemark', 'TriggerHomeVault'); + }; + window.addEventListener('open-notemark-vault', window._homeVaultHandler); + "); + // Read last note ID worked on this specific browser/PC var lastDocId = await JSRuntime.InvokeAsync("localStorage.getItem", "notemark-last-doc-id"); if (!string.IsNullOrEmpty(lastDocId)) @@ -275,6 +302,29 @@ } } + private void OnBrandLogoClicked() + { + logoClickCount++; + logoClickTimer?.Dispose(); + logoClickTimer = new Timer(_ => + { + logoClickCount = 0; + }, null, 2000, Timeout.Infinite); + + if (logoClickCount >= 5) + { + logoClickCount = 0; + showVaultModal = true; + StateHasChanged(); + } + } + + public void TriggerVault() + { + showVaultModal = true; + StateHasChanged(); + } + private async Task LoadDashboard() { isLoading = true; @@ -352,4 +402,10 @@ if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; return date.ToLocalTime().ToString("MMM dd, yyyy"); } + + public void Dispose() + { + logoClickTimer?.Dispose(); + dotNetRef?.Dispose(); + } } diff --git a/Notemark/Components/Shared/VaultModal.razor b/Notemark/Components/Shared/VaultModal.razor new file mode 100644 index 0000000..3f14073 --- /dev/null +++ b/Notemark/Components/Shared/VaultModal.razor @@ -0,0 +1,305 @@ +@using Notemark.Models +@using Notemark.Services +@inject IVaultService VaultService +@inject IJSRuntime JSRuntime +@inject NavigationManager Navigation + +@if (IsOpen) +{ +
+
+ @if (!isUnlocked) + { + +
+
🔐
+

Master Vault

+

Enter administrator PIN to access all server documents.

+ +
+ + +
+ + @if (hasError) + { +
⚠️ Incorrect PIN code. Access denied.
+ } + + +
+ } + else + { + +
+
+
+ 🗄️ Master Vault +

All Server Documents (@allDocs.Count)

+
+
+ + +
+
+ +
+ + + @if (allTags.Any()) + { +
+ + @foreach (var tag in allTags) + { + + } +
+ } +
+ +
+ @if (isLoadingDocs) + { +
+
+

Loading documents...

+
+ } + else + { + var filtered = GetFilteredDocs(); + if (!filtered.Any()) + { +
+

No documents found matching your filter.

+
+ } + else + { +
+ @foreach (var doc in filtered) + { +
+
+

@doc.Title

+
+ @if (doc.IsFlagged) + { + 🚩 + } + +
+
+

@doc.Snippet

+ + @if (doc.Tags.Any()) + { +
+ @foreach (var tag in doc.Tags) + { + #@tag + } +
+ } + +
+ Modified @FormatDate(doc.LastModifiedAt) + @doc.Id[..Math.Min(8, doc.Id.Length)]... +
+
+ } +
+ } + } +
+
+ } +
+} + +@code { + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback IsOpenChanged { get; set; } + [Parameter] public EventCallback OnSelectDocument { get; set; } + + private bool isUnlocked = false; + private string pinInput = ""; + private bool hasError = false; + private bool isLoadingDocs = false; + private string searchQuery = ""; + private string selectedTag = ""; + private List allDocs = new(); + private List allTags = new(); + + protected override async Task OnParametersSetAsync() + { + if (IsOpen) + { + try + { + var stored = await JSRuntime.InvokeAsync("sessionStorage.getItem", "notemark_vault_unlocked"); + if (stored == "true") + { + isUnlocked = true; + await LoadAllDocs(); + } + else + { + isUnlocked = false; + pinInput = ""; + hasError = false; + } + } + catch + { + isUnlocked = false; + } + } + } + + private async Task OnPinKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + { + await SubmitPin(); + } + } + + private async Task SubmitPin() + { + hasError = false; + if (VaultService.VerifyPin(pinInput)) + { + isUnlocked = true; + pinInput = ""; + try + { + await JSRuntime.InvokeVoidAsync("sessionStorage.setItem", "notemark_vault_unlocked", "true"); + } + catch { } + await LoadAllDocs(); + } + else + { + hasError = true; + } + } + + private async Task LoadAllDocs() + { + isLoadingDocs = true; + StateHasChanged(); + try + { + allDocs = await VaultService.GetAllDocumentsAsync(); + allTags = allDocs.SelectMany(d => d.Tags) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(t => t) + .ToList(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[Vault Load Error] {ex.Message}"); + } + finally + { + isLoadingDocs = false; + StateHasChanged(); + } + } + + private List GetFilteredDocs() + { + var list = allDocs.AsEnumerable(); + + if (!string.IsNullOrEmpty(selectedTag)) + { + list = list.Where(d => d.Tags.Any(t => t.Equals(selectedTag, StringComparison.OrdinalIgnoreCase))); + } + + if (!string.IsNullOrWhiteSpace(searchQuery)) + { + var q = searchQuery.Trim().ToLowerInvariant(); + list = list.Where(d => d.Title.ToLowerInvariant().Contains(q) || + d.Snippet.ToLowerInvariant().Contains(q) || + d.Id.ToLowerInvariant().Contains(q) || + d.Tags.Any(t => t.ToLowerInvariant().Contains(q))); + } + + return list.OrderByDescending(d => d.LastModifiedAt).ToList(); + } + + private async Task OpenDocument(string id) + { + await OnSelectDocument.InvokeAsync(id); + await Close(); + } + + private async Task DeleteDocument(string id) + { + try + { + await VaultService.DeleteDocumentAsync(id); + await LoadAllDocs(); + } + catch { } + } + + private async Task LockVault() + { + isUnlocked = false; + pinInput = ""; + hasError = false; + try + { + await JSRuntime.InvokeVoidAsync("sessionStorage.removeItem", "notemark_vault_unlocked"); + } + catch { } + } + + private async Task Close() + { + IsOpen = false; + await IsOpenChanged.InvokeAsync(false); + } + + private string FormatDate(DateTime date) + { + var diff = DateTime.UtcNow - date; + if (diff.TotalMinutes < 1) return "Just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + return date.ToLocalTime().ToString("MMM dd, yyyy HH:mm"); + } +} diff --git a/Notemark/Components/Shared/VaultModal.razor.css b/Notemark/Components/Shared/VaultModal.razor.css new file mode 100644 index 0000000..ec192e3 --- /dev/null +++ b/Notemark/Components/Shared/VaultModal.razor.css @@ -0,0 +1,406 @@ +.vault-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + z-index: 10000; + animation: fadeIn 0.15s ease-out; +} + +.vault-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-lg), 0 20px 40px rgba(0, 0, 0, 0.4); + z-index: 10001; + overflow: hidden; + display: flex; + flex-direction: column; + animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); + box-sizing: border-box; +} + +.vault-compact { + width: 90%; + max-width: 400px; + padding: 2rem 1.75rem; +} + +.vault-wide { + width: 90%; + max-width: 960px; + height: 85vh; + max-height: 800px; +} + +/* PIN Screen */ +.vault-pin-screen { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.vault-pin-icon { + font-size: 2.8rem; + margin-bottom: 0.5rem; +} + +.vault-pin-screen h2 { + margin: 0 0 0.5rem 0; + font-size: 1.4rem; + font-weight: 700; + color: var(--primary-text); +} + +.vault-pin-screen p { + margin: 0 0 1.5rem 0; + font-size: 0.875rem; + color: var(--secondary-text); + line-height: 1.5; +} + +.pin-form { + width: 100%; + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.pin-input { + flex: 1; + padding: 0.65rem 1rem; + border-radius: 8px; + border: 1px solid var(--border); + background-color: var(--bg); + color: var(--primary-text); + font-size: 1.1rem; + letter-spacing: 2px; + text-align: center; + outline: none; + transition: all 0.15s ease; +} + +.pin-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-light); +} + +.pin-input.pin-error { + border-color: var(--error); + animation: shake 0.3s ease-in-out; +} + +.btn-unlock { + padding: 0.65rem 1.25rem; + background-color: var(--accent); + color: #ffffff; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.btn-unlock:hover { + background-color: var(--accent-hover); +} + +.pin-error-msg { + font-size: 0.8125rem; + color: var(--error); + margin-bottom: 1rem; +} + +.vault-pin-footer { + margin-top: 0.5rem; +} + +.btn-cancel { + background: none; + border: none; + color: var(--secondary-text); + font-size: 0.85rem; + cursor: pointer; +} + +.btn-cancel:hover { + color: var(--primary-text); +} + +/* Vault Content Unlocked */ +.vault-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.vault-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--border); + background-color: var(--surface); +} + +.vault-header-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.vault-badge { + background-color: var(--accent-light); + color: var(--accent); + font-size: 0.75rem; + font-weight: 700; + padding: 0.25rem 0.5rem; + border-radius: 6px; + text-transform: uppercase; +} + +.vault-header-title h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-text); +} + +.vault-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.btn-lock-vault { + padding: 0.4rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: none; + color: var(--secondary-text); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-lock-vault:hover { + background-color: var(--surface-hover); + color: var(--primary-text); +} + +.btn-close-vault { + background: none; + border: none; + font-size: 1.2rem; + color: var(--secondary-text); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 6px; +} + +.btn-close-vault:hover { + background-color: var(--surface-hover); + color: var(--primary-text); +} + +/* Toolbar */ +.vault-toolbar { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--bg); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.vault-search { + display: flex; + align-items: center; + gap: 0.5rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.45rem 0.75rem; +} + +.vault-search input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--primary-text); + font-size: 0.875rem; +} + +.btn-clear-search { + background: none; + border: none; + color: var(--secondary-text); + cursor: pointer; +} + +.vault-tags { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.tag-chip { + padding: 0.25rem 0.6rem; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--secondary-text); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.tag-chip:hover, +.tag-chip.active { + background-color: var(--accent); + color: #ffffff; + border-color: var(--accent); +} + +/* Body */ +.vault-body { + flex: 1; + overflow-y: auto; + padding: 1.25rem 1.5rem; +} + +.vault-loading, +.vault-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 1rem; + color: var(--secondary-text); +} + +.vault-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.vault-card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.25rem; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; +} + +.vault-card:hover { + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.vault-card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; +} + +.vault-card-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--primary-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-card-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.btn-doc-delete { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + opacity: 0.4; + transition: opacity 0.15s ease; +} + +.btn-doc-delete:hover { + opacity: 1; +} + +.vault-card-snippet { + margin: 0 0 0.75rem 0; + font-size: 0.8125rem; + color: var(--secondary-text); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.vault-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.75rem; +} + +.vault-tag-pill { + font-size: 0.7rem; + background-color: var(--accent-light); + color: var(--accent); + padding: 0.15rem 0.4rem; + border-radius: 4px; + font-weight: 500; +} + +.vault-card-meta { + margin-top: auto; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.72rem; + color: var(--secondary-text); + border-top: 1px solid var(--border-subtle); + padding-top: 0.5rem; +} + +.vault-card-id code { + font-size: 0.68rem; + background: var(--code-bg); + padding: 0.1rem 0.3rem; + border-radius: 3px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes popIn { + from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-6px); } + 40%, 80% { transform: translateX(6px); } +} diff --git a/Notemark/Program.cs b/Notemark/Program.cs index 7d80436..88b65bb 100644 --- a/Notemark/Program.cs +++ b/Notemark/Program.cs @@ -5,7 +5,18 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); + .AddInteractiveServerComponents(options => + { + options.DetailedErrors = true; + }) + .AddHubOptions(options => + { + options.MaximumReceiveMessageSize = 32 * 1024 * 1024; // 32MB for images and large markdown notes + options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); + options.KeepAliveInterval = TimeSpan.FromSeconds(15); + options.HandshakeTimeout = TimeSpan.FromSeconds(30); + options.EnableDetailedErrors = true; + }); // Add HTTP context accessor for session management builder.Services.AddHttpContextAccessor(); @@ -15,16 +26,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); - -// Configure SignalR for better WebSocket support -builder.Services.AddSignalR(options => -{ - // Increase timeouts for large content - options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); - options.KeepAliveInterval = TimeSpan.FromSeconds(30); - options.HandshakeTimeout = TimeSpan.FromSeconds(30); - options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB -}); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/Notemark/Services/DocumentService.cs b/Notemark/Services/DocumentService.cs index 993e5fb..deac963 100644 --- a/Notemark/Services/DocumentService.cs +++ b/Notemark/Services/DocumentService.cs @@ -15,9 +15,22 @@ public partial class DocumentService : IDocumentService public DocumentService(IWebHostEnvironment environment) { - var dataDir = Path.Combine(environment.ContentRootPath, "data"); - Directory.CreateDirectory(dataDir); - _metadataFilePath = Path.Combine(dataDir, "documents.json"); + var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR"); + if (string.IsNullOrWhiteSpace(baseDataPath)) + { + baseDataPath = Path.Combine(environment.ContentRootPath, "data"); + } + + try + { + Directory.CreateDirectory(baseDataPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DocumentService Init Error] Failed to create {baseDataPath}: {ex.Message}"); + } + + _metadataFilePath = Path.Combine(baseDataPath, "documents.json"); _jsonOptions = new JsonSerializerOptions { @@ -265,18 +278,37 @@ public partial class DocumentService : IDocumentService try { - var json = await File.ReadAllTextAsync(_metadataFilePath); - return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); + await using var stream = new FileStream(_metadataFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return await JsonSerializer.DeserializeAsync>(stream, _jsonOptions) ?? new List(); } - catch + catch (Exception ex) { + Console.Error.WriteLine($"[DocumentService Read Error] {_metadataFilePath}: {ex.Message}"); return new List(); } } private async Task WriteMetadataListInternalAsync(List list) { + var dataDir = Path.GetDirectoryName(_metadataFilePath); + if (!string.IsNullOrEmpty(dataDir) && !Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + var json = JsonSerializer.Serialize(list, _jsonOptions); - await File.WriteAllTextAsync(_metadataFilePath, json); + + try + { + await using var fs = new FileStream(_metadataFilePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 4096, useAsync: true); + await using var writer = new StreamWriter(fs, System.Text.Encoding.UTF8); + await writer.WriteAsync(json); + await writer.FlushAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[DocumentService Write Error] {_metadataFilePath}: {ex}"); + throw; + } } } diff --git a/Notemark/Services/IVaultService.cs b/Notemark/Services/IVaultService.cs new file mode 100644 index 0000000..15725a2 --- /dev/null +++ b/Notemark/Services/IVaultService.cs @@ -0,0 +1,10 @@ +using Notemark.Models; + +namespace Notemark.Services; + +public interface IVaultService +{ + bool VerifyPin(string? enteredPin); + Task> GetAllDocumentsAsync(); + Task DeleteDocumentAsync(string id); +} diff --git a/Notemark/Services/SessionService.cs b/Notemark/Services/SessionService.cs index d5e6b56..5e117ff 100644 --- a/Notemark/Services/SessionService.cs +++ b/Notemark/Services/SessionService.cs @@ -9,15 +9,27 @@ public class SessionService : ISessionService public SessionService(IWebHostEnvironment environment) { - _dataPath = Path.Combine(environment.ContentRootPath, "data", "sessions"); - Directory.CreateDirectory(_dataPath); + var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR"); + if (string.IsNullOrWhiteSpace(baseDataPath)) + { + baseDataPath = Path.Combine(environment.ContentRootPath, "data"); + } + _dataPath = Path.Combine(baseDataPath, "sessions"); + try + { + Directory.CreateDirectory(_dataPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SessionService Init Error] Failed to create {_dataPath}: {ex.Message}"); + } } public string GetOrCreateSessionId(HttpContext httpContext) { // Try to get existing session from cookie if (httpContext.Request.Cookies.TryGetValue(SessionCookieName, out var existingSessionId) - && Guid.TryParse(existingSessionId, out _)) + && !string.IsNullOrWhiteSpace(existingSessionId)) { return existingSessionId; } @@ -30,11 +42,18 @@ public class SessionService : ISessionService { Expires = DateTimeOffset.UtcNow.AddDays(30), HttpOnly = true, - Secure = true, + Secure = httpContext.Request.IsHttps, SameSite = SameSiteMode.Lax }; - httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions); + try + { + if (!httpContext.Response.HasStarted) + { + httpContext.Response.Cookies.Append(SessionCookieName, newSessionId, cookieOptions); + } + } + catch { } return newSessionId; } @@ -46,7 +65,17 @@ public class SessionService : ISessionService if (!File.Exists(filePath)) return string.Empty; - return await File.ReadAllTextAsync(filePath); + try + { + await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(fileStream, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[SessionService Load Error] {filePath}: {ex.Message}"); + return string.Empty; + } } public async Task SaveSessionContentAsync(string sessionId, string content) @@ -60,16 +89,15 @@ public class SessionService : ISessionService Directory.CreateDirectory(directory); } - // For large content, use streaming to avoid memory issues + // Write content safely with FileShare.ReadWrite try { if (string.IsNullOrEmpty(content)) { - await File.WriteAllTextAsync(filePath, string.Empty, Encoding.UTF8); + await using var emptyStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite); return; } - // Use FileStream with FileShare.ReadWrite to avoid locking collisions await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.ReadWrite, bufferSize: 8192, useAsync: true); await using var writer = new StreamWriter(fileStream, Encoding.UTF8); @@ -81,19 +109,19 @@ public class SessionService : ISessionService { int length = Math.Min(chunkSize, content.Length - i); await writer.WriteAsync(content.AsMemory(i, length)); - await writer.FlushAsync(); // Ensure data is written + await writer.FlushAsync(); } } else { await writer.WriteAsync(content); + await writer.FlushAsync(); } } - catch (DirectoryNotFoundException) + catch (Exception ex) { - // Retry after creating directory - Directory.CreateDirectory(directory); - await File.WriteAllTextAsync(filePath, content ?? string.Empty, Encoding.UTF8); + Console.Error.WriteLine($"[SessionService Save Error] {filePath}: {ex}"); + throw; } } @@ -105,9 +133,14 @@ public class SessionService : ISessionService private string GetSessionFilePath(string sessionId) { - if (!Guid.TryParse(sessionId, out _)) - throw new ArgumentException("Invalid session ID format", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(sessionId)) + sessionId = Guid.NewGuid().ToString(); + + // Sanitize sessionId for cross-platform file paths + var safeId = string.Concat(sessionId.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_')); + if (string.IsNullOrEmpty(safeId)) + safeId = Guid.NewGuid().ToString(); - return Path.Combine(_dataPath, $"{sessionId}.md"); + return Path.Combine(_dataPath, $"{safeId}.md"); } } \ No newline at end of file diff --git a/Notemark/Services/ShareService.cs b/Notemark/Services/ShareService.cs index 29447a9..0f91821 100644 --- a/Notemark/Services/ShareService.cs +++ b/Notemark/Services/ShareService.cs @@ -9,8 +9,21 @@ public class ShareService : IShareService public ShareService(IWebHostEnvironment environment) { - _dataPath = Path.Combine(environment.ContentRootPath, "data", "shares"); - Directory.CreateDirectory(_dataPath); + var baseDataPath = Environment.GetEnvironmentVariable("NOTEMARK_DATA_DIR"); + if (string.IsNullOrWhiteSpace(baseDataPath)) + { + baseDataPath = Path.Combine(environment.ContentRootPath, "data"); + } + + _dataPath = Path.Combine(baseDataPath, "shares"); + try + { + Directory.CreateDirectory(_dataPath); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[ShareService Init Error] Failed to create {_dataPath}: {ex.Message}"); + } _jsonOptions = new JsonSerializerOptions { @@ -21,8 +34,8 @@ public class ShareService : IShareService public async Task CreateShareTokenAsync(string sessionId, string mode) { - if (!Guid.TryParse(sessionId, out _)) - throw new ArgumentException("Invalid session ID format", nameof(sessionId)); + if (string.IsNullOrWhiteSpace(sessionId)) + throw new ArgumentException("Session ID cannot be empty", nameof(sessionId)); if (mode != "readonly" && mode != "editable") throw new ArgumentException("Mode must be 'readonly' or 'editable'", nameof(mode)); @@ -35,8 +48,14 @@ public class ShareService : IShareService ); var filePath = GetTokenFilePath(token); + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + var json = JsonSerializer.Serialize(shareToken, _jsonOptions); - await File.WriteAllTextAsync(filePath, json); + await File.WriteAllTextAsync(filePath, json, System.Text.Encoding.UTF8); return token; } diff --git a/Notemark/Services/VaultService.cs b/Notemark/Services/VaultService.cs new file mode 100644 index 0000000..92e9767 --- /dev/null +++ b/Notemark/Services/VaultService.cs @@ -0,0 +1,37 @@ +using Notemark.Models; + +namespace Notemark.Services; + +public class VaultService : IVaultService +{ + private readonly IConfiguration _configuration; + private readonly IDocumentService _documentService; + + public VaultService(IConfiguration configuration, IDocumentService documentService) + { + _configuration = configuration; + _documentService = documentService; + } + + public bool VerifyPin(string? enteredPin) + { + if (string.IsNullOrWhiteSpace(enteredPin)) + return false; + + var configuredPin = Environment.GetEnvironmentVariable("NOTEMARK_ADMIN_PIN") + ?? _configuration["AdminPin"] + ?? "12131411"; + + return string.Equals(enteredPin.Trim(), configuredPin.Trim(), StringComparison.Ordinal); + } + + public async Task> GetAllDocumentsAsync() + { + return await _documentService.GetAllDocumentsAsync(); + } + + public async Task DeleteDocumentAsync(string id) + { + await _documentService.DeleteDocumentAsync(id); + } +} diff --git a/Notemark/appsettings.json b/Notemark/appsettings.json index 10f68b8..c0dcaf5 100644 --- a/Notemark/appsettings.json +++ b/Notemark/appsettings.json @@ -1,4 +1,5 @@ { + "AdminPin": "12131411", "Logging": { "LogLevel": { "Default": "Information", diff --git a/Notemark/web.config b/Notemark/web.config new file mode 100644 index 0000000..e822c2b --- /dev/null +++ b/Notemark/web.config @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Notemark/wwwroot/js/editor.js b/Notemark/wwwroot/js/editor.js index 48500e6..b81c845 100644 --- a/Notemark/wwwroot/js/editor.js +++ b/Notemark/wwwroot/js/editor.js @@ -299,3 +299,11 @@ window.notemarkEditor = { URL.revokeObjectURL(url); } }; + +// Global Hotkey for Vault (Ctrl + Shift + A or Ctrl + Alt + A) +window.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && (e.shiftKey || e.altKey) && (e.key === 'a' || e.key === 'A')) { + e.preventDefault(); + window.dispatchEvent(new CustomEvent('open-notemark-vault')); + } +});