vault mode

This commit is contained in:
Michal Vrba
2026-09-22 12:53:08 +02:00
parent 29696cea9f
commit 471c823bf7
13 changed files with 993 additions and 43 deletions
+22 -2
View File
@@ -73,7 +73,12 @@
</div>
<div class="top-bar-center">
<span class="save-status @saveStatus.ToLower()">@saveStatus</span>
<span class="save-status @saveStatus.ToLower()"
title="@(saveErrorMessage != null ? $"Save Error: {saveErrorMessage} (Click to retry)" : (saveStatus == "Saved" ? "All changes saved" : saveStatus))"
@onclick="ManualSave"
style="@(saveStatus == "Error" ? "cursor: pointer;" : "")">
@saveStatus @(saveStatus == "Error" ? "⚠️" : "")
</span>
<span class="doc-stats" title="Words • Characters • Reading Time">
@wordCount words &bull; @charCount chars &bull; @readingTime min read
</span>
@@ -282,11 +287,17 @@
IsOpenChanged="@((val) => showNotesDrawer = val)"
CurrentDocumentId="@sessionId"
OnSelectDocument="@OnDocumentSelected" />
<!-- Secret Master Vault Modal -->
<VaultModal IsOpen="@showVaultModal"
IsOpenChanged="@((val) => showVaultModal = val)"
OnSelectDocument="@OnDocumentSelected" />
</div>
@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()
+57 -1
View File
@@ -1,5 +1,7 @@
@page "/"
@page "/vault"
@rendermode InteractiveServer
@implements IDisposable
@using Notemark.Models
@using Notemark.Services
@inject IDocumentService DocumentService
@@ -13,7 +15,7 @@
<div class="home-container">
<!-- Top Navigation Bar -->
<header class="home-topbar">
<div class="topbar-brand">
<div class="topbar-brand" @onclick="OnBrandLogoClicked" style="cursor: pointer;" title="Notemark">
<div class="brand-logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
@@ -210,16 +212,27 @@
</section>
}
</main>
<!-- Secret Master Vault Modal -->
<VaultModal IsOpen="@showVaultModal"
IsOpenChanged="@((val) => showVaultModal = val)"
OnSelectDocument="@OpenNote" />
</div>
@code {
private bool isLoading = true;
private bool showVaultModal = false;
private string searchQuery = "";
private string selectedTag = "";
private DocumentMetadata? localRecentDoc;
private List<DocumentMetadata> displayedNotes = new();
private List<string> activeTags = new();
// Secret Logo Click Tracking
private int logoClickCount = 0;
private Timer? logoClickTimer;
private DotNetObjectReference<Home>? dotNetRef;
private List<DocumentMetadata> flaggedDocs => displayedNotes.Where(d => d.IsFlagged).OrderByDescending(d => d.LastModifiedAt).ToList();
private List<DocumentMetadata> 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<string?>("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();
}
}
+305
View File
@@ -0,0 +1,305 @@
@using Notemark.Models
@using Notemark.Services
@inject IVaultService VaultService
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@if (IsOpen)
{
<div class="vault-backdrop" @onclick="Close"></div>
<div class="vault-modal @(isUnlocked ? "vault-wide" : "vault-compact")">
@if (!isUnlocked)
{
<!-- Locked PIN Entry Screen -->
<div class="vault-pin-screen">
<div class="vault-pin-icon">🔐</div>
<h2>Master Vault</h2>
<p>Enter administrator PIN to access all server documents.</p>
<div class="pin-form">
<input type="password"
class="pin-input @(hasError ? "pin-error" : "")"
placeholder="Enter PIN..."
@bind="pinInput"
@bind:event="oninput"
@onkeydown="OnPinKeyDown"
autofocus />
<button type="button" class="btn-unlock" @onclick="SubmitPin">
Unlock
</button>
</div>
@if (hasError)
{
<div class="pin-error-msg">⚠️ Incorrect PIN code. Access denied.</div>
}
<div class="vault-pin-footer">
<button type="button" class="btn-cancel" @onclick="Close">Cancel</button>
</div>
</div>
}
else
{
<!-- Unlocked Document Archive -->
<div class="vault-content">
<div class="vault-header">
<div class="vault-header-title">
<span class="vault-badge">🗄️ Master Vault</span>
<h2>All Server Documents (@allDocs.Count)</h2>
</div>
<div class="vault-header-actions">
<button type="button" class="btn-lock-vault" @onclick="LockVault" title="Lock the vault again">
🔒 Lock Vault
</button>
<button type="button" class="btn-close-vault" @onclick="Close" title="Close">✕</button>
</div>
</div>
<div class="vault-toolbar">
<div class="vault-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" placeholder="Search across all server documents, snippets or #tags..." @bind="searchQuery" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn-clear-search" @onclick="() => searchQuery = string.Empty">✕</button>
}
</div>
@if (allTags.Any())
{
<div class="vault-tags">
<button class="tag-chip @(string.IsNullOrEmpty(selectedTag) ? "active" : "")" @onclick="() => selectedTag = string.Empty">
All
</button>
@foreach (var tag in allTags)
{
<button class="tag-chip @(selectedTag == tag ? "active" : "")" @onclick="() => selectedTag = (selectedTag == tag ? string.Empty : tag)">
#@tag
</button>
}
</div>
}
</div>
<div class="vault-body">
@if (isLoadingDocs)
{
<div class="vault-loading">
<div class="spinner"></div>
<p>Loading documents...</p>
</div>
}
else
{
var filtered = GetFilteredDocs();
if (!filtered.Any())
{
<div class="vault-empty">
<p>No documents found matching your filter.</p>
</div>
}
else
{
<div class="vault-grid">
@foreach (var doc in filtered)
{
<div class="vault-card" @onclick="() => OpenDocument(doc.Id)">
<div class="vault-card-top">
<h3 class="vault-card-title">@doc.Title</h3>
<div class="vault-card-actions" @onclick:stopPropagation="true">
@if (doc.IsFlagged)
{
<span class="flag-icon" title="Flagged">🚩</span>
}
<button class="btn-doc-delete" @onclick="() => DeleteDocument(doc.Id)" title="Delete note permanently">
🗑️
</button>
</div>
</div>
<p class="vault-card-snippet">@doc.Snippet</p>
@if (doc.Tags.Any())
{
<div class="vault-card-tags" @onclick:stopPropagation="true">
@foreach (var tag in doc.Tags)
{
<span class="vault-tag-pill" @onclick="() => selectedTag = tag">#@tag</span>
}
</div>
}
<div class="vault-card-meta">
<span class="vault-card-date">Modified @FormatDate(doc.LastModifiedAt)</span>
<span class="vault-card-id" title="Session ID"><code>@doc.Id[..Math.Min(8, doc.Id.Length)]...</code></span>
</div>
</div>
}
</div>
}
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
[Parameter] public EventCallback<string> 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<DocumentMetadata> allDocs = new();
private List<string> allTags = new();
protected override async Task OnParametersSetAsync()
{
if (IsOpen)
{
try
{
var stored = await JSRuntime.InvokeAsync<string?>("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<DocumentMetadata> 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");
}
}
@@ -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); }
}
+13 -11
View File
@@ -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<IMarkdownService, MarkdownService>();
builder.Services.AddSingleton<IDocumentService, DocumentService>();
builder.Services.AddScoped<ISessionService, SessionService>();
builder.Services.AddScoped<IShareService, ShareService>();
// 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<IVaultService, VaultService>();
var app = builder.Build();
+39 -7
View File
@@ -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<List<DocumentMetadata>>(json, _jsonOptions) ?? new List<DocumentMetadata>();
await using var stream = new FileStream(_metadataFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return await JsonSerializer.DeserializeAsync<List<DocumentMetadata>>(stream, _jsonOptions) ?? new List<DocumentMetadata>();
}
catch
catch (Exception ex)
{
Console.Error.WriteLine($"[DocumentService Read Error] {_metadataFilePath}: {ex.Message}");
return new List<DocumentMetadata>();
}
}
private async Task WriteMetadataListInternalAsync(List<DocumentMetadata> 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;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
using Notemark.Models;
namespace Notemark.Services;
public interface IVaultService
{
bool VerifyPin(string? enteredPin);
Task<List<DocumentMetadata>> GetAllDocumentsAsync();
Task DeleteDocumentAsync(string id);
}
+48 -15
View File
@@ -9,15 +9,27 @@ public class SessionService : ISessionService
public SessionService(IWebHostEnvironment environment)
{
_dataPath = Path.Combine(environment.ContentRootPath, "data", "sessions");
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
};
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();
return Path.Combine(_dataPath, $"{sessionId}.md");
// 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, $"{safeId}.md");
}
}
+23 -4
View File
@@ -9,8 +9,21 @@ public class ShareService : IShareService
public ShareService(IWebHostEnvironment environment)
{
_dataPath = Path.Combine(environment.ContentRootPath, "data", "shares");
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<string> 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;
}
+37
View File
@@ -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<List<DocumentMetadata>> GetAllDocumentsAsync()
{
return await _documentService.GetAllDocumentsAsync();
}
public async Task DeleteDocumentAsync(string id)
{
await _documentService.DeleteDocumentAsync(id);
}
}
+1
View File
@@ -1,4 +1,5 @@
{
"AdminPin": "12131411",
"Logging": {
"LogLevel": {
"Default": "Information",
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet" arguments=".\Notemark.dll" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
</environmentVariables>
</aspNetCore>
<security>
<requestFiltering>
<!-- Allow up to 100 MB uploads (base64 images etc.) -->
<requestLimits maxAllowedContentLength="104857600" />
</requestFiltering>
</security>
</system.webServer>
</location>
</configuration>
+8
View File
@@ -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'));
}
});