From 9347bc880c1d2670a4f0fba0eff21d0fb102bc7c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:50:20 -0400 Subject: [PATCH] Add plan sharing via server API (issue #182, Option A) Share button with two-phase consent dialog, user-configurable TTL (1 day to 1 year), and one-chance delete button. Server API deployed on Hetzner (cpx11, Ashburn) with SQLite storage and auto-expiry. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/PlanShare/.gitignore | 3 + server/PlanShare/PlanShare.csproj | 13 + server/PlanShare/Program.cs | 242 ++++++++++++++++++ .../PlanShare/Properties/launchSettings.json | 38 +++ server/PlanShare/appsettings.Development.json | 8 + server/PlanShare/appsettings.json | 9 + src/PlanViewer.Web/Pages/Index.razor | 208 +++++++++++++++ src/PlanViewer.Web/wwwroot/css/app.css | 123 +++++++++ src/PlanViewer.Web/wwwroot/index.html | 9 + 9 files changed, 653 insertions(+) create mode 100644 server/PlanShare/.gitignore create mode 100644 server/PlanShare/PlanShare.csproj create mode 100644 server/PlanShare/Program.cs create mode 100644 server/PlanShare/Properties/launchSettings.json create mode 100644 server/PlanShare/appsettings.Development.json create mode 100644 server/PlanShare/appsettings.json diff --git a/server/PlanShare/.gitignore b/server/PlanShare/.gitignore new file mode 100644 index 0000000..337ba47 --- /dev/null +++ b/server/PlanShare/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +data/ diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj new file mode 100644 index 0000000..854439d --- /dev/null +++ b/server/PlanShare/PlanShare.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/server/PlanShare/Program.cs b/server/PlanShare/Program.cs new file mode 100644 index 0000000..d321c6c --- /dev/null +++ b/server/PlanShare/Program.cs @@ -0,0 +1,242 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Data.Sqlite; + +var builder = WebApplication.CreateBuilder(args); + +// CORS — allow all origins for WASM client +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); +}); + +// Database path — data/ subdirectory relative to the binary +var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); +Directory.CreateDirectory(dataDir); +var dbPath = Path.Combine(dataDir, "plans.db"); +var connectionString = $"Data Source={dbPath}"; + +// Initialize database +using (var conn = new SqliteConnection(connectionString)) +{ + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + CREATE TABLE IF NOT EXISTS plans ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + delete_token TEXT NOT NULL + ) + """; + cmd.ExecuteNonQuery(); +} + +// Register the cleanup background service +builder.Services.AddSingleton(new PlanDbConfig(connectionString)); +builder.Services.AddHostedService(); + +// Request size limit (10 MB) +builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 10 * 1024 * 1024); + +var app = builder.Build(); +app.UseCors(); + +// --- Rate limiter: 10 shares per minute per IP (in-memory) --- +var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60); + +const int MaxTtlDays = 365; + +// --- Endpoints --- + +app.MapGet("/health", () => Results.Content("OK", "text/plain")); + +app.MapPost("/api/share", async (HttpContext ctx) => +{ + // Rate limit by IP + var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + if (!rateLimiter.IsAllowed(ip)) + { + return Results.StatusCode(429); + } + + // Read raw body + using var reader = new StreamReader(ctx.Request.Body); + var body = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(body)) + { + return Results.BadRequest("Empty body"); + } + + // Parse and extract ttl_days from the JSON + int ttlDays = 7; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.TryGetInt32(out var t)) + ttlDays = Math.Clamp(t, 1, MaxTtlDays); + } + catch (JsonException) + { + return Results.BadRequest("Invalid JSON"); + } + + var id = GenerateId(); + var deleteToken = GenerateDeleteToken(); + var now = DateTime.UtcNow; + var expiresAt = now.AddDays(ttlDays); + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO plans (id, data, created_at, expires_at, delete_token) VALUES (@id, @data, @created_at, @expires_at, @delete_token)"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@data", body); + cmd.Parameters.AddWithValue("@created_at", now.ToString("o")); + cmd.Parameters.AddWithValue("@expires_at", expiresAt.ToString("o")); + cmd.Parameters.AddWithValue("@delete_token", deleteToken); + cmd.ExecuteNonQuery(); + + return Results.Content( + $"{{\"id\":\"{id}\",\"delete_token\":\"{deleteToken}\",\"expires_at\":\"{expiresAt:yyyy-MM-dd}\"}}", + "application/json"); +}); + +app.MapGet("/api/plans/{id}", (string id) => +{ + using var conn = new SqliteConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT data FROM plans WHERE id = @id AND expires_at > @now"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o")); + + var result = cmd.ExecuteScalar() as string; + if (result is null) + { + return Results.NotFound(); + } + + return Results.Content(result, "application/json"); +}); + +app.MapDelete("/api/plans/{id}", (string id, HttpContext ctx) => +{ + var token = ctx.Request.Query["token"].FirstOrDefault(); + if (string.IsNullOrEmpty(token)) + { + return Results.BadRequest("Missing delete token"); + } + + using var conn = new SqliteConnection(connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM plans WHERE id = @id AND delete_token = @token"; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@token", token); + var deleted = cmd.ExecuteNonQuery(); + + return deleted > 0 ? Results.Ok() : Results.NotFound(); +}); + +app.Run(); + +// --- Helpers --- + +static string GenerateId() +{ + const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return new string(Random.Shared.GetItems(chars.AsSpan(), 8)); +} + +static string GenerateDeleteToken() +{ + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower(); +} + +// --- Supporting types --- + +record PlanDbConfig(string ConnectionString); + +sealed class CleanupService : BackgroundService +{ + private readonly PlanDbConfig _config; + private readonly ILogger _logger; + + public CleanupService(PlanDbConfig config, ILogger logger) + { + _config = config; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Cleanup(); + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + Cleanup(); + } + } + + private void Cleanup() + { + try + { + var now = DateTime.UtcNow.ToString("o"); + using var conn = new SqliteConnection(_config.ConnectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM plans WHERE expires_at < @now"; + cmd.Parameters.AddWithValue("@now", now); + var deleted = cmd.ExecuteNonQuery(); + if (deleted > 0) + { + _logger.LogInformation("Cleaned up {Count} expired plans", deleted); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during plan cleanup"); + } + } +} + +sealed class RateLimiter +{ + private readonly int _maxRequests; + private readonly int _windowSeconds; + private readonly ConcurrentDictionary> _requests = new(); + + public RateLimiter(int maxRequests, int windowSeconds) + { + _maxRequests = maxRequests; + _windowSeconds = windowSeconds; + } + + public bool IsAllowed(string key) + { + var now = DateTime.UtcNow; + var cutoff = now.AddSeconds(-_windowSeconds); + + var timestamps = _requests.GetOrAdd(key, _ => new List()); + + lock (timestamps) + { + timestamps.RemoveAll(t => t < cutoff); + + if (timestamps.Count >= _maxRequests) + { + return false; + } + + timestamps.Add(now); + return true; + } + } +} diff --git a/server/PlanShare/Properties/launchSettings.json b/server/PlanShare/Properties/launchSettings.json new file mode 100644 index 0000000..26c588f --- /dev/null +++ b/server/PlanShare/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:60802", + "sslPort": 44322 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5271", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7060;http://localhost:5271", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/server/PlanShare/appsettings.Development.json b/server/PlanShare/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/server/PlanShare/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/server/PlanShare/appsettings.json b/server/PlanShare/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/server/PlanShare/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index 7fa47bf..91154d3 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -47,6 +47,7 @@ else
+ @sourceLabel @(result.Summary.HasActualStats ? "Actual Plan" : "Estimated Plan") @@ -55,8 +56,60 @@ else { @result.SqlServerBuild } + @if (shareUrl != null) + { + + } + @if (deleteToken != null) + { + + }
+ @if (sharePhase == 1) + { + + } + + @if (sharePhase == 2) + { + + } + @* Statement selector for multi-statement plans *@ @if (result.Statements.Count > 1) { @@ -1581,6 +1634,8 @@ else } @code { + private const string ShareApiBase = "http://87.99.137.152:8080"; + private string activeTab = "paste"; private string planXml = ""; private string? errorMessage; @@ -1590,6 +1645,13 @@ else private string? textOutput; private string? sourceLabel; private int activeStatement = 0; + private int sharePhase; // 0=hidden, 1=consent, 2=confirm + private bool isSharing; + private bool isDeleting; + private string? shareUrl; + private string? shareId; + private string? deleteToken; + private int shareTtlDays = 7; private PlanNode? selectedNode; private StatementResult? ActiveStmt => result?.Statements.ElementAtOrDefault(activeStatement); @@ -1686,6 +1748,10 @@ else planXml = ""; activeStatement = 0; selectedNode = null; + shareUrl = null; + shareId = null; + deleteToken = null; + sharePhase = 0; } private async Task ExportHtml() @@ -1698,6 +1764,148 @@ else await JS.InvokeVoidAsync("downloadFile", fileName, html); } + private void ShowShareDialog() + { + sharePhase = 1; + shareTtlDays = 7; + } + + private void HideShareDialog() => sharePhase = 0; + private void AdvanceSharePhase() => sharePhase = 2; + + private async Task SharePlan() + { + if (result == null || textOutput == null) return; + isSharing = true; + sharePhase = 0; + errorMessage = null; + StateHasChanged(); + + try + { + using var http = new HttpClient(); + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + result = result, + text = textOutput, + ttl_days = shareTtlDays + }); + var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json"); + var response = await http.PostAsync($"{ShareApiBase}/api/share", content); + + if (!response.IsSuccessStatusCode) + { + errorMessage = $"Share failed: server returned {(int)response.StatusCode}"; + return; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(json); + shareId = doc.RootElement.GetProperty("id").GetString(); + deleteToken = doc.RootElement.GetProperty("delete_token").GetString(); + var baseUrl = await JS.InvokeAsync("getBaseUrl"); + shareUrl = $"{baseUrl}?share={shareId}"; + await JS.InvokeVoidAsync("copyToClipboard", shareUrl); + } + catch (Exception ex) + { + errorMessage = $"Share failed: {ex.Message}"; + } + finally + { + isSharing = false; + } + } + + private async Task DeleteSharedPlan() + { + if (shareId == null || deleteToken == null) return; + isDeleting = true; + StateHasChanged(); + + try + { + using var http = new HttpClient(); + var response = await http.DeleteAsync($"{ShareApiBase}/api/plans/{shareId}?token={deleteToken}"); + if (response.IsSuccessStatusCode) + { + shareUrl = null; + shareId = null; + deleteToken = null; + errorMessage = null; + } + else + { + errorMessage = "Failed to delete shared plan."; + } + } + catch (Exception ex) + { + errorMessage = $"Delete failed: {ex.Message}"; + } + finally + { + isDeleting = false; + } + } + + private static string FormatTtl(int days) => days switch + { + 1 => "1 day", + < 30 => $"{days} days", + 30 => "1 month", + 90 => "3 months", + 180 => "6 months", + 365 => "1 year", + _ => $"{days} days" + }; + + protected override async Task OnInitializedAsync() + { + var uri = await JS.InvokeAsync("getQueryParam", "share"); + if (!string.IsNullOrEmpty(uri)) + { + await LoadSharedPlan(uri); + } + } + + private async Task LoadSharedPlan(string id) + { + isAnalyzing = true; + StateHasChanged(); + + try + { + using var http = new HttpClient(); + var response = await http.GetAsync($"{ShareApiBase}/api/plans/{id}"); + if (!response.IsSuccessStatusCode) + { + errorMessage = response.StatusCode == System.Net.HttpStatusCode.NotFound + ? "This shared plan has expired or does not exist." + : $"Failed to load shared plan: {(int)response.StatusCode}"; + isAnalyzing = false; + return; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + + result = System.Text.Json.JsonSerializer.Deserialize( + root.GetProperty("result").GetRawText()); + textOutput = root.GetProperty("text").GetString(); + sourceLabel = "shared plan"; + } + catch (Exception ex) + { + errorMessage = $"Failed to load shared plan: {ex.Message}"; + } + finally + { + isAnalyzing = false; + } + } + private RenderFragment RenderPlanNodes(PlanNode node, bool isRoot) => builder => { var height = PlanLayoutEngine.GetNodeHeight(node); diff --git a/src/PlanViewer.Web/wwwroot/css/app.css b/src/PlanViewer.Web/wwwroot/css/app.css index 95b0cb4..0e5569e 100644 --- a/src/PlanViewer.Web/wwwroot/css/app.css +++ b/src/PlanViewer.Web/wwwroot/css/app.css @@ -284,6 +284,129 @@ textarea::placeholder { color: var(--accent); } +.share-btn { + padding: 0.3rem 0.75rem; + background: var(--accent); + color: #fff; + border: 1px solid var(--accent); + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 0.8rem; +} + +.share-btn:hover:not(:disabled) { + background: var(--accent-hover); +} + +.share-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.share-url { + font-size: 0.8rem; + margin-left: auto; +} + +.share-url a { + color: var(--accent); + text-decoration: none; + font-weight: 500; +} + +.share-url a:hover { + text-decoration: underline; +} + +/* Share consent modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--bg); + border-radius: 8px; + padding: 1.5rem; + max-width: 480px; + width: 90%; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); +} + +.modal h3 { + font-size: 1.1rem; + margin-bottom: 0.75rem; +} + +.modal p { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; + line-height: 1.5; +} + +.modal-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.modal-actions button { + padding: 0.6rem 1.5rem; + font-size: 0.9rem; +} + +.ttl-picker { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.75rem; + font-size: 0.9rem; +} + +.ttl-picker label { + color: var(--text-secondary); + font-weight: 500; +} + +.ttl-picker select { + padding: 0.3rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + font-family: inherit; + font-size: 0.85rem; + background: var(--bg); + color: var(--text); +} + +.delete-share-btn { + padding: 0.3rem 0.75rem; + background: none; + color: var(--critical); + border: 1px solid var(--critical); + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 0.8rem; +} + +.delete-share-btn:hover:not(:disabled) { + background: var(--critical); + color: #fff; +} + +.delete-share-btn:disabled { + opacity: 0.5; + cursor: default; +} + .toolbar-source { font-size: 0.85rem; color: var(--text); diff --git a/src/PlanViewer.Web/wwwroot/index.html b/src/PlanViewer.Web/wwwroot/index.html index a9b9670..079a21e 100644 --- a/src/PlanViewer.Web/wwwroot/index.html +++ b/src/PlanViewer.Web/wwwroot/index.html @@ -33,6 +33,15 @@ document.body.removeChild(a); URL.revokeObjectURL(url); } + function getBaseUrl() { + return window.location.origin + window.location.pathname; + } + function getQueryParam(name) { + return new URLSearchParams(window.location.search).get(name) || ''; + } + function copyToClipboard(text) { + navigator.clipboard.writeText(text).catch(() => {}); + }