diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d2b044f6..1936c1ed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,13 +31,15 @@ jobs:
# stack of '!' patterns matches whenever a file fails ANY one of them, which for
# a docs-only change is always true. Listing what IS code keeps the OR honest.
# PlanViewer.Ssms and PlanViewer.Ssms.Installer stay out: they are not in the
- # solution and ci.yml never built them.
+ # solution and ci.yml never built them. server/PlanShare is in: the test project
+ # references it, so this job builds and tests it.
filters: |
code:
- 'src/PlanViewer.App/**'
- 'src/PlanViewer.Cli/**'
- 'src/PlanViewer.Core/**'
- 'src/PlanViewer.Web/**'
+ - 'server/PlanShare/**'
- 'src/Directory.Build.props'
- 'tests/**'
- 'PlanViewer.sln'
diff --git a/server/PlanShare/ClientKey.cs b/server/PlanShare/ClientKey.cs
new file mode 100644
index 00000000..54dfdda7
--- /dev/null
+++ b/server/PlanShare/ClientKey.cs
@@ -0,0 +1,33 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace PlanShare;
+
+///
+/// The one key every per-client limit (share, analytics, read, upload budget) is counted under.
+/// An IPv4 address is its own key. An IPv4-mapped IPv6 address (how a dual-stack socket reports
+/// an IPv4 caller) becomes that IPv4 address, so one caller has one key. Any other IPv6 address
+/// becomes its /64 prefix: a single subscriber is normally given a whole /64, so counting per /64
+/// makes one subscriber one client, however many addresses they use.
+///
+internal static class ClientKey
+{
+ public const string Unknown = "unknown";
+
+ public static string From(IPAddress? address)
+ {
+ if (address is null)
+ return Unknown;
+
+ if (address.IsIPv4MappedToIPv6)
+ address = address.MapToIPv4();
+
+ if (address.AddressFamily != AddressFamily.InterNetworkV6)
+ return address.ToString();
+
+ Span bytes = stackalloc byte[16];
+ address.TryWriteBytes(bytes, out _);
+ bytes[8..].Clear();
+ return $"{new IPAddress(bytes)}/64";
+ }
+}
diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj
index 0d1f7756..92abd428 100644
--- a/server/PlanShare/PlanShare.csproj
+++ b/server/PlanShare/PlanShare.csproj
@@ -18,4 +18,9 @@
+
+
+
+
+
diff --git a/server/PlanShare/Program.cs b/server/PlanShare/Program.cs
index a144de34..d70657b4 100644
--- a/server/PlanShare/Program.cs
+++ b/server/PlanShare/Program.cs
@@ -4,6 +4,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite;
+using PlanShare;
var builder = WebApplication.CreateBuilder(args);
@@ -14,8 +15,9 @@
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
});
-// Database path — data/ subdirectory relative to the binary
-var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
+// Database path — data/ subdirectory relative to the binary. PlanShare:DataDir moves it
+// (the endpoint tests and local runs point it at a temp folder).
+var dataDir = builder.Configuration["PlanShare:DataDir"] ?? Path.Combine(AppContext.BaseDirectory, "data");
Directory.CreateDirectory(dataDir);
var dbPath = Path.Combine(dataDir, "plans.db");
var connectionString = $"Data Source={dbPath}";
@@ -71,9 +73,23 @@ created_at TEXT NOT NULL
// store; 120/min per IP is far above any human's browsing rate.
var readRateLimiter = new RateLimiter(maxRequests: 120, windowSeconds: 60);
+// --- Storage limit and daily upload budget ---
+// The production disk is 40 GB, so shares are refused (507) once the database uses 10 GB of pages.
+// The budget is the most plan data one client key can store per UTC day (429 after that).
+// PlanShare:MaxDatabaseBytes and PlanShare:DailyUploadBytes override the two limits, which lets
+// the endpoint tests use small values.
+const long MaxDatabaseBytes = 10L * 1024 * 1024 * 1024;
+const long DailyUploadBytes = 100L * 1024 * 1024;
+var storageCheck = new StorageCheck(
+ connectionString,
+ builder.Configuration.GetValue("PlanShare:MaxDatabaseBytes") ?? MaxDatabaseBytes);
+var uploadBudget = new UploadBudget(
+ builder.Configuration.GetValue("PlanShare:DailyUploadBytes") ?? DailyUploadBytes);
+
// Register the cleanup background service
builder.Services.AddSingleton(new PlanDbConfig(connectionString));
builder.Services.AddSingleton(new RateLimiters(rateLimiter, analyticsRateLimiter, readRateLimiter));
+builder.Services.AddSingleton(uploadBudget);
builder.Services.AddHostedService();
// Request size limit (10 MB)
@@ -105,6 +121,9 @@ created_at TEXT NOT NULL
const int MaxTtlDays = 365;
+// Longest page path an analytics event may carry. Real paths are a few characters.
+const int MaxEventPathLength = 512;
+
// Depth ceiling for parsing an uploaded share, mirroring PlanViewer.Core's
// AnalysisJson.MaxDepth — that class is the source of truth for how deep a serialized
// AnalysisResult can go (#431: an operator costs two JSON levels, so the JsonDocument
@@ -121,11 +140,11 @@ created_at TEXT NOT NULL
app.MapPost("/api/share", async (HttpContext ctx) =>
{
- // Rate limit by IP
- var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
- if (!rateLimiter.IsAllowed(ip))
+ // Rate limit by client key (IPv4 address, or the /64 of an IPv6 address)
+ var client = ClientKey.From(ctx.Connection.RemoteIpAddress);
+ if (!rateLimiter.IsAllowed(client))
{
- return Results.StatusCode(429);
+ return Error(429, "Too many shares from your network. Please wait a minute and try again.");
}
// Read raw body
@@ -134,7 +153,7 @@ created_at TEXT NOT NULL
if (string.IsNullOrWhiteSpace(body))
{
- return Results.BadRequest("Empty body");
+ return Error(400, "Empty body");
}
// Parse and extract ttl_days from the JSON. shareDocumentOptions, not defaults: this body
@@ -144,12 +163,31 @@ created_at TEXT NOT NULL
try
{
using var doc = JsonDocument.Parse(body, shareDocumentOptions);
- if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.TryGetInt32(out var t))
- ttlDays = Math.Clamp(t, 1, MaxTtlDays);
+ // TryGetProperty and TryGetInt32 throw (a 500) on the wrong kind of element, so check the kind first
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return Error(400, "The request body must be a JSON object.");
+ if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.ValueKind != JsonValueKind.Null)
+ {
+ if (ttlProp.ValueKind != JsonValueKind.Number)
+ return Error(400, "ttl_days must be a number.");
+ if (ttlProp.TryGetInt32(out var t))
+ ttlDays = Math.Clamp(t, 1, MaxTtlDays);
+ }
}
catch (JsonException)
{
- return Results.BadRequest("Invalid JSON");
+ return Error(400, "Invalid JSON");
+ }
+
+ if (storageCheck.IsFull())
+ {
+ return Error(507, "Plan sharing is full right now. Please try again later.");
+ }
+
+ // The stored size is the UTF-8 length of the body; ContentLength can be absent
+ if (!uploadBudget.TryCharge(client, Encoding.UTF8.GetByteCount(body)))
+ {
+ return Error(429, "Daily sharing limit reached for your network. Please try again tomorrow.");
}
var id = GenerateId();
@@ -175,8 +213,8 @@ created_at TEXT NOT NULL
app.MapGet("/api/plans/{id}", (string id, HttpContext ctx) =>
{
- var readIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
- if (!readRateLimiter.IsAllowed(readIp))
+ var readClient = ClientKey.From(ctx.Connection.RemoteIpAddress);
+ if (!readRateLimiter.IsAllowed(readClient))
return Results.StatusCode(429);
using var conn = new SqliteConnection(connectionString);
@@ -199,9 +237,8 @@ created_at TEXT NOT NULL
app.MapPost("/api/event", async (HttpContext ctx) =>
{
- // Rate limit: 30 events/min per IP (generous — covers page nav + shares)
- var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
- if (!analyticsRateLimiter.IsAllowed(ip))
+ // Rate limit: 30 events/min per client key (generous — covers page nav + shares)
+ if (!analyticsRateLimiter.IsAllowed(ClientKey.From(ctx.Connection.RemoteIpAddress)))
return Results.StatusCode(429);
using var reader = new StreamReader(ctx.Request.Body);
@@ -212,16 +249,32 @@ created_at TEXT NOT NULL
try
{
using var doc = JsonDocument.Parse(body);
- if (doc.RootElement.TryGetProperty("path", out var p))
+ // GetString() throws (a 500) on a non-string element, so check the kind first.
+ // JSON null counts as "not sent", like a missing property.
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ return Error(400, "The request body must be a JSON object.");
+ if (doc.RootElement.TryGetProperty("path", out var p) && p.ValueKind != JsonValueKind.Null)
+ {
+ if (p.ValueKind != JsonValueKind.String)
+ return Error(400, "path must be a string.");
path = p.GetString() ?? "/";
- if (doc.RootElement.TryGetProperty("referrer", out var r))
+ }
+ if (doc.RootElement.TryGetProperty("referrer", out var r) && r.ValueKind != JsonValueKind.Null)
+ {
+ if (r.ValueKind != JsonValueKind.String)
+ return Error(400, "referrer must be a string.");
referrer = r.GetString();
+ }
}
- catch (JsonException)
+ catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
- return Results.BadRequest("Invalid JSON");
+ // InvalidOperationException: GetString() on a string with a lone surrogate escape
+ return Error(400, "Invalid JSON");
}
+ if (path.Length > MaxEventPathLength)
+ return Error(400, $"path must be {MaxEventPathLength} characters or fewer.");
+
// Strip referrer to domain only (no full URLs with query params).
// If it doesn't parse as an absolute URL, drop it — never persist raw
// client-supplied strings, since the dashboard renders referrers in HTML.
@@ -237,11 +290,18 @@ created_at TEXT NOT NULL
// space (IPv4 = 2^32, guessable UA, known date) is small enough to brute
// force straight back to the source IP, so an unsalted digest would still be
// personal data despite looking like a hash.
+ // The hash takes the full address, not the client key: a /64 would count every host in
+ // it as one visitor.
+ var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var ua = ctx.Request.Headers.UserAgent.FirstOrDefault() ?? "";
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
var visitorHash = Convert.ToHexString(
HMACSHA256.HashData(visitorSalt, Encoding.UTF8.GetBytes($"{ip}|{ua}|{day}"))).ToLower()[..16];
+ // A full store drops the event and still answers 200: analytics must never show an error
+ if (storageCheck.IsFull())
+ return Results.Ok();
+
using var conn = new SqliteConnection(connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
@@ -409,10 +469,14 @@ GROUP BY referrer ORDER BY count DESC LIMIT 10
app.MapDelete("/api/plans/{id}", (string id, HttpContext ctx) =>
{
- var token = ctx.Request.Query["token"].FirstOrDefault();
+ // Header first: a ?token= query string is written to the nginx access log. The query form
+ // stays for clients built before the header existed.
+ var token = ctx.Request.Headers["X-Delete-Token"].FirstOrDefault();
+ if (string.IsNullOrEmpty(token))
+ token = ctx.Request.Query["token"].FirstOrDefault();
if (string.IsNullOrEmpty(token))
{
- return Results.BadRequest("Missing delete token");
+ return Error(400, "Missing delete token");
}
using var conn = new SqliteConnection(connectionString);
@@ -443,6 +507,12 @@ static string GenerateDeleteToken()
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower();
}
+// A refusal the web client can show: it reads the "error" text out of the body.
+static IResult Error(int statusCode, string message)
+{
+ return Results.Json(new { error = message }, statusCode: statusCode);
+}
+
// --- Supporting types ---
record PlanDbConfig(string ConnectionString);
@@ -453,12 +523,14 @@ sealed class CleanupService : BackgroundService
{
private readonly PlanDbConfig _config;
private readonly RateLimiters _rateLimiters;
+ private readonly UploadBudget _uploadBudget;
private readonly ILogger _logger;
- public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, ILogger logger)
+ public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, UploadBudget uploadBudget, ILogger logger)
{
_config = config;
_rateLimiters = rateLimiters;
+ _uploadBudget = uploadBudget;
_logger = logger;
}
@@ -503,14 +575,16 @@ private void Cleanup()
// Evict stale rate-limiter keys so the dictionaries don't grow forever.
// Every limiter must be swept here: an unswept one keeps a permanent
- // entry per unique client IP for the lifetime of the process.
+ // entry per unique client IP for the lifetime of the process. The upload
+ // budget is swept the same way (its entries go stale at the UTC day change).
var shareEvicted = _rateLimiters.Share.Sweep();
var analyticsEvicted = _rateLimiters.Analytics.Sweep();
var readEvicted = _rateLimiters.Read.Sweep();
- if (shareEvicted + analyticsEvicted + readEvicted > 0)
+ var budgetEvicted = _uploadBudget.Sweep();
+ if (shareEvicted + analyticsEvicted + readEvicted + budgetEvicted > 0)
_logger.LogInformation(
- "Evicted {Share} share + {Analytics} analytics + {Read} read rate-limit keys",
- shareEvicted, analyticsEvicted, readEvicted);
+ "Evicted {Share} share + {Analytics} analytics + {Read} read rate-limit keys and {Budget} upload-budget keys",
+ shareEvicted, analyticsEvicted, readEvicted, budgetEvicted);
}
catch (Exception ex)
{
diff --git a/server/PlanShare/StorageCheck.cs b/server/PlanShare/StorageCheck.cs
new file mode 100644
index 00000000..7e66cede
--- /dev/null
+++ b/server/PlanShare/StorageCheck.cs
@@ -0,0 +1,41 @@
+using Microsoft.Data.Sqlite;
+
+namespace PlanShare;
+
+///
+/// Tells whether the plan database has reached its size limit. Size means the pages in use:
+/// (page_count - freelist_count) * page_size. The freelist is left out on purpose, because
+/// deleting expired plans returns their pages to it and the file itself does not shrink, so
+/// the file length would stay above the limit after cleanup had freed the room.
+///
+internal sealed class StorageCheck
+{
+ private readonly string _connectionString;
+ private readonly long _maxBytes;
+
+ public StorageCheck(string connectionString, long maxBytes)
+ {
+ _connectionString = connectionString;
+ _maxBytes = maxBytes;
+ }
+
+ public long UsedBytes()
+ {
+ using var conn = new SqliteConnection(_connectionString);
+ conn.Open();
+ var pageCount = Pragma(conn, "page_count");
+ var freePages = Pragma(conn, "freelist_count");
+ var pageSize = Pragma(conn, "page_size");
+ return (pageCount - freePages) * pageSize;
+ }
+
+ /// True at or above the limit.
+ public bool IsFull() => UsedBytes() >= _maxBytes;
+
+ private static long Pragma(SqliteConnection conn, string name)
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = $"PRAGMA {name};";
+ return (long)cmd.ExecuteScalar()!;
+ }
+}
diff --git a/server/PlanShare/UploadBudget.cs b/server/PlanShare/UploadBudget.cs
new file mode 100644
index 00000000..10a9f3d4
--- /dev/null
+++ b/server/PlanShare/UploadBudget.cs
@@ -0,0 +1,88 @@
+using System.Collections.Concurrent;
+
+namespace PlanShare;
+
+///
+/// Caps how many bytes of plan data one client key can store per UTC day. In memory like
+/// RateLimiter, so a restart forgets it. A charge is made before the insert and is not refunded
+/// if the insert then fails: refunding would need a second code path for a rare error, and the
+/// most a client can lose that way is one upload's worth of its allowance.
+///
+internal sealed class UploadBudget
+{
+ private sealed class Usage
+ {
+ public DateOnly Day;
+ public long Bytes;
+ public bool Evicted;
+ }
+
+ private readonly long _dailyLimitBytes;
+ private readonly TimeProvider _clock;
+ private readonly ConcurrentDictionary _usage = new();
+
+ public UploadBudget(long dailyLimitBytes, TimeProvider? clock = null)
+ {
+ _dailyLimitBytes = dailyLimitBytes;
+ _clock = clock ?? TimeProvider.System;
+ }
+
+ private DateOnly Today() => DateOnly.FromDateTime(_clock.GetUtcNow().UtcDateTime);
+
+ ///
+ /// Adds to the key's total for today and returns true, or returns
+ /// false and adds nothing when that would take the total over the daily limit.
+ ///
+ public bool TryCharge(string key, long bytes)
+ {
+ var today = Today();
+
+ while (true)
+ {
+ var usage = _usage.GetOrAdd(key, _ => new Usage { Day = today });
+
+ lock (usage)
+ {
+ // Sweep() removed this entry between GetOrAdd and the lock; charging it now
+ // would be lost, so fetch or create the live one.
+ if (usage.Evicted)
+ continue;
+
+ if (usage.Day != today)
+ {
+ usage.Day = today;
+ usage.Bytes = 0;
+ }
+
+ if (usage.Bytes + bytes > _dailyLimitBytes)
+ return false;
+
+ usage.Bytes += bytes;
+ return true;
+ }
+ }
+ }
+
+ ///
+ /// Evicts keys whose last charge was on an earlier UTC day. Call periodically so the
+ /// dictionary doesn't grow forever across unique clients.
+ /// Returns the number of keys evicted.
+ ///
+ public int Sweep()
+ {
+ var today = Today();
+ var evicted = 0;
+ foreach (var kvp in _usage)
+ {
+ lock (kvp.Value)
+ {
+ if (kvp.Value.Day != today && _usage.TryRemove(kvp))
+ {
+ kvp.Value.Evicted = true;
+ evicted++;
+ }
+ }
+ }
+ return evicted;
+ }
+}
diff --git a/server/PlanShare/dashboard.html b/server/PlanShare/dashboard.html
index 3b24ac0a..3b458157 100644
--- a/server/PlanShare/dashboard.html
+++ b/server/PlanShare/dashboard.html
@@ -235,7 +235,9 @@
Top Referrers (30 days)
if (m) {
var t = decodeURIComponent(m[1]);
try { localStorage.setItem("planshare_stats_token", t); } catch (e) { /* private mode */ }
- history.replaceState(null, "", window.location.pathname + window.location.search);
+ // window.history: a bare "history" here is the stats array declared at the top of this
+ // script, which has no replaceState, so the call threw and left #token= in the address bar.
+ window.history.replaceState(null, "", window.location.pathname + window.location.search);
return t;
}
try { return localStorage.getItem("planshare_stats_token") || ""; } catch (e) { return ""; }
diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor
index e9b6b09d..eec1ec21 100644
--- a/src/PlanViewer.Web/Pages/Index.razor
+++ b/src/PlanViewer.Web/Pages/Index.razor
@@ -76,7 +76,16 @@ else
Share Plan Analysis
-
Your plan analysis will be uploaded and stored on a server. Anyone with the link can view the full analysis, including query text, operator details, and warnings.
+
Your plan analysis will be uploaded and stored on a server. Anyone with the link can read all of it. The upload includes:
+
+
The file name of the plan
+
The query text
+
The operator details and the warnings
+
The compiled and runtime values of the parameters
+
The missing index suggestions, with database, schema, and table names
+
The full text report
+
+
Query text and parameter values can contain private data. Check the analysis on this page before you share it.