Skip to content

Commit 263f5a8

Browse files
Merge pull request #196 from erikdarlingdata/dev
Merge dev: plan sharing and export (#182)
2 parents 923a8e5 + c6734d5 commit 263f5a8

9 files changed

Lines changed: 653 additions & 0 deletions

File tree

server/PlanShare/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
bin/
2+
obj/
3+
data/

server/PlanShare/PlanShare.csproj

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
11+
</ItemGroup>
12+
13+
</Project>

server/PlanShare/Program.cs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using System.Collections.Concurrent;
2+
using System.Security.Cryptography;
3+
using System.Text.Json;
4+
using Microsoft.Data.Sqlite;
5+
6+
var builder = WebApplication.CreateBuilder(args);
7+
8+
// CORS — allow all origins for WASM client
9+
builder.Services.AddCors(options =>
10+
{
11+
options.AddDefaultPolicy(policy =>
12+
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
13+
});
14+
15+
// Database path — data/ subdirectory relative to the binary
16+
var dataDir = Path.Combine(AppContext.BaseDirectory, "data");
17+
Directory.CreateDirectory(dataDir);
18+
var dbPath = Path.Combine(dataDir, "plans.db");
19+
var connectionString = $"Data Source={dbPath}";
20+
21+
// Initialize database
22+
using (var conn = new SqliteConnection(connectionString))
23+
{
24+
conn.Open();
25+
using var cmd = conn.CreateCommand();
26+
cmd.CommandText = """
27+
CREATE TABLE IF NOT EXISTS plans (
28+
id TEXT PRIMARY KEY,
29+
data TEXT NOT NULL,
30+
created_at TEXT NOT NULL,
31+
expires_at TEXT NOT NULL,
32+
delete_token TEXT NOT NULL
33+
)
34+
""";
35+
cmd.ExecuteNonQuery();
36+
}
37+
38+
// Register the cleanup background service
39+
builder.Services.AddSingleton(new PlanDbConfig(connectionString));
40+
builder.Services.AddHostedService<CleanupService>();
41+
42+
// Request size limit (10 MB)
43+
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 10 * 1024 * 1024);
44+
45+
var app = builder.Build();
46+
app.UseCors();
47+
48+
// --- Rate limiter: 10 shares per minute per IP (in-memory) ---
49+
var rateLimiter = new RateLimiter(maxRequests: 10, windowSeconds: 60);
50+
51+
const int MaxTtlDays = 365;
52+
53+
// --- Endpoints ---
54+
55+
app.MapGet("/health", () => Results.Content("OK", "text/plain"));
56+
57+
app.MapPost("/api/share", async (HttpContext ctx) =>
58+
{
59+
// Rate limit by IP
60+
var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
61+
if (!rateLimiter.IsAllowed(ip))
62+
{
63+
return Results.StatusCode(429);
64+
}
65+
66+
// Read raw body
67+
using var reader = new StreamReader(ctx.Request.Body);
68+
var body = await reader.ReadToEndAsync();
69+
70+
if (string.IsNullOrWhiteSpace(body))
71+
{
72+
return Results.BadRequest("Empty body");
73+
}
74+
75+
// Parse and extract ttl_days from the JSON
76+
int ttlDays = 7;
77+
try
78+
{
79+
using var doc = JsonDocument.Parse(body);
80+
if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.TryGetInt32(out var t))
81+
ttlDays = Math.Clamp(t, 1, MaxTtlDays);
82+
}
83+
catch (JsonException)
84+
{
85+
return Results.BadRequest("Invalid JSON");
86+
}
87+
88+
var id = GenerateId();
89+
var deleteToken = GenerateDeleteToken();
90+
var now = DateTime.UtcNow;
91+
var expiresAt = now.AddDays(ttlDays);
92+
93+
using var conn = new SqliteConnection(connectionString);
94+
conn.Open();
95+
using var cmd = conn.CreateCommand();
96+
cmd.CommandText = "INSERT INTO plans (id, data, created_at, expires_at, delete_token) VALUES (@id, @data, @created_at, @expires_at, @delete_token)";
97+
cmd.Parameters.AddWithValue("@id", id);
98+
cmd.Parameters.AddWithValue("@data", body);
99+
cmd.Parameters.AddWithValue("@created_at", now.ToString("o"));
100+
cmd.Parameters.AddWithValue("@expires_at", expiresAt.ToString("o"));
101+
cmd.Parameters.AddWithValue("@delete_token", deleteToken);
102+
cmd.ExecuteNonQuery();
103+
104+
return Results.Content(
105+
$"{{\"id\":\"{id}\",\"delete_token\":\"{deleteToken}\",\"expires_at\":\"{expiresAt:yyyy-MM-dd}\"}}",
106+
"application/json");
107+
});
108+
109+
app.MapGet("/api/plans/{id}", (string id) =>
110+
{
111+
using var conn = new SqliteConnection(connectionString);
112+
conn.Open();
113+
using var cmd = conn.CreateCommand();
114+
cmd.CommandText = "SELECT data FROM plans WHERE id = @id AND expires_at > @now";
115+
cmd.Parameters.AddWithValue("@id", id);
116+
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
117+
118+
var result = cmd.ExecuteScalar() as string;
119+
if (result is null)
120+
{
121+
return Results.NotFound();
122+
}
123+
124+
return Results.Content(result, "application/json");
125+
});
126+
127+
app.MapDelete("/api/plans/{id}", (string id, HttpContext ctx) =>
128+
{
129+
var token = ctx.Request.Query["token"].FirstOrDefault();
130+
if (string.IsNullOrEmpty(token))
131+
{
132+
return Results.BadRequest("Missing delete token");
133+
}
134+
135+
using var conn = new SqliteConnection(connectionString);
136+
conn.Open();
137+
using var cmd = conn.CreateCommand();
138+
cmd.CommandText = "DELETE FROM plans WHERE id = @id AND delete_token = @token";
139+
cmd.Parameters.AddWithValue("@id", id);
140+
cmd.Parameters.AddWithValue("@token", token);
141+
var deleted = cmd.ExecuteNonQuery();
142+
143+
return deleted > 0 ? Results.Ok() : Results.NotFound();
144+
});
145+
146+
app.Run();
147+
148+
// --- Helpers ---
149+
150+
static string GenerateId()
151+
{
152+
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
153+
return new string(Random.Shared.GetItems<char>(chars.AsSpan(), 8));
154+
}
155+
156+
static string GenerateDeleteToken()
157+
{
158+
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower();
159+
}
160+
161+
// --- Supporting types ---
162+
163+
record PlanDbConfig(string ConnectionString);
164+
165+
sealed class CleanupService : BackgroundService
166+
{
167+
private readonly PlanDbConfig _config;
168+
private readonly ILogger<CleanupService> _logger;
169+
170+
public CleanupService(PlanDbConfig config, ILogger<CleanupService> logger)
171+
{
172+
_config = config;
173+
_logger = logger;
174+
}
175+
176+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
177+
{
178+
Cleanup();
179+
180+
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
181+
while (await timer.WaitForNextTickAsync(stoppingToken))
182+
{
183+
Cleanup();
184+
}
185+
}
186+
187+
private void Cleanup()
188+
{
189+
try
190+
{
191+
var now = DateTime.UtcNow.ToString("o");
192+
using var conn = new SqliteConnection(_config.ConnectionString);
193+
conn.Open();
194+
using var cmd = conn.CreateCommand();
195+
cmd.CommandText = "DELETE FROM plans WHERE expires_at < @now";
196+
cmd.Parameters.AddWithValue("@now", now);
197+
var deleted = cmd.ExecuteNonQuery();
198+
if (deleted > 0)
199+
{
200+
_logger.LogInformation("Cleaned up {Count} expired plans", deleted);
201+
}
202+
}
203+
catch (Exception ex)
204+
{
205+
_logger.LogError(ex, "Error during plan cleanup");
206+
}
207+
}
208+
}
209+
210+
sealed class RateLimiter
211+
{
212+
private readonly int _maxRequests;
213+
private readonly int _windowSeconds;
214+
private readonly ConcurrentDictionary<string, List<DateTime>> _requests = new();
215+
216+
public RateLimiter(int maxRequests, int windowSeconds)
217+
{
218+
_maxRequests = maxRequests;
219+
_windowSeconds = windowSeconds;
220+
}
221+
222+
public bool IsAllowed(string key)
223+
{
224+
var now = DateTime.UtcNow;
225+
var cutoff = now.AddSeconds(-_windowSeconds);
226+
227+
var timestamps = _requests.GetOrAdd(key, _ => new List<DateTime>());
228+
229+
lock (timestamps)
230+
{
231+
timestamps.RemoveAll(t => t < cutoff);
232+
233+
if (timestamps.Count >= _maxRequests)
234+
{
235+
return false;
236+
}
237+
238+
timestamps.Add(now);
239+
return true;
240+
}
241+
}
242+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"$schema": "http://json.schemastore.org/launchsettings.json",
3+
"iisSettings": {
4+
"windowsAuthentication": false,
5+
"anonymousAuthentication": true,
6+
"iisExpress": {
7+
"applicationUrl": "http://localhost:60802",
8+
"sslPort": 44322
9+
}
10+
},
11+
"profiles": {
12+
"http": {
13+
"commandName": "Project",
14+
"dotnetRunMessages": true,
15+
"launchBrowser": true,
16+
"applicationUrl": "http://localhost:5271",
17+
"environmentVariables": {
18+
"ASPNETCORE_ENVIRONMENT": "Development"
19+
}
20+
},
21+
"https": {
22+
"commandName": "Project",
23+
"dotnetRunMessages": true,
24+
"launchBrowser": true,
25+
"applicationUrl": "https://localhost:7060;http://localhost:5271",
26+
"environmentVariables": {
27+
"ASPNETCORE_ENVIRONMENT": "Development"
28+
}
29+
},
30+
"IIS Express": {
31+
"commandName": "IISExpress",
32+
"launchBrowser": true,
33+
"environmentVariables": {
34+
"ASPNETCORE_ENVIRONMENT": "Development"
35+
}
36+
}
37+
}
38+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}

server/PlanShare/appsettings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*"
9+
}

0 commit comments

Comments
 (0)