mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 10:21:08 -04:00
fix(server): reject duplicate nest ids, invalid metadata and bare --database
- POST /api/nests with an existing id (active or deleted) now returns 409 with the current record instead of a 500 from the UNIQUE constraint. - Uploaded nest metadata must have string text fields and a defined status; null fields and undefined statuses (e.g. 99) are 400 instead of a 500 or a stored invalid value. - --database <path> is accepted alongside --database=<path>; a missing or repeated path fails startup instead of silently using OPENNEST_DB/default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a7073d38c
commit
5dff83cfb8
7 files changed
+309
-37
No files matched your search
@@ -142,6 +142,9 @@ public sealed partial class NestDatabase : IDisposable
|
||||
lock (_sync)
|
||||
{
|
||||
using var transaction = _connection.BeginTransaction();
|
||||
// Ids are client-allocated: an existing id, active or deleted, is a conflict, not a replacement.
|
||||
if ((Get(id) ?? Get(id, deleted: true)) is { } existing)
|
||||
throw new RevisionConflictException(existing, "A nest with this id already exists.");
|
||||
using var command = _connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = """
|
||||
@@ -488,7 +491,8 @@ public sealed partial class NestDatabase : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RevisionConflictException(NestRecord current) : Exception("The nest has changed. Reload or Save As.")
|
||||
public sealed class RevisionConflictException(NestRecord current, string message = "The nest has changed. Reload or Save As.")
|
||||
: Exception(message)
|
||||
{
|
||||
public NestRecord Current { get; } = current;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.Data;
|
||||
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and checks uploaded nest metadata. Text fields must be present and the status
|
||||
/// must be a defined value, so invalid metadata is a 400 and never reaches storage.
|
||||
/// </summary>
|
||||
internal static class NestMetadata
|
||||
{
|
||||
// Integer statuses stay accepted for existing callers; GetError refuses undefined ones.
|
||||
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
|
||||
public static bool TryRead(string? json, out NestRecord record, out string error)
|
||||
{
|
||||
record = new NestRecord();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
error = "Missing 'metadata' part.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonSerializer.Deserialize<NestRecord>(json, Options) is not { } parsed)
|
||||
{
|
||||
error = "Missing metadata.";
|
||||
return false;
|
||||
}
|
||||
|
||||
record = parsed;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
error = "Invalid metadata JSON.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = GetError(record) ?? "";
|
||||
return error.Length == 0;
|
||||
}
|
||||
|
||||
public static string? GetError(NestRecord record)
|
||||
{
|
||||
if (record.Name is null) return "name must be a string.";
|
||||
if (record.Customer is null) return "customer must be a string.";
|
||||
if (record.Material is null) return "material must be a string.";
|
||||
if (record.Comments is null) return "comments must be a string.";
|
||||
if (record.MadeBy is null) return "madeBy must be a string.";
|
||||
if (!Enum.IsDefined(record.Status)) return "status must be quote, toBeCut or hasBeenCut.";
|
||||
if (!double.IsFinite(record.Thickness)) return "thickness must be a finite number.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,8 @@ builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(converter);
|
||||
});
|
||||
|
||||
// Storage path: --database <path> or OPENNEST_DB, default ./data/nests.db (a Docker volume).
|
||||
var databasePath =
|
||||
args.FirstOrDefault(a => a.StartsWith("--database=", StringComparison.Ordinal))?.Split('=', 2)[1]
|
||||
?? Environment.GetEnvironmentVariable("OPENNEST_DB")
|
||||
?? Path.Combine("data", "nests.db");
|
||||
// Storage path: --database=<path> or --database <path>, then OPENNEST_DB, default ./data/nests.db (a Docker volume).
|
||||
var databasePath = ServerOptions.DatabasePath(args, Environment.GetEnvironmentVariable(ServerOptions.DatabaseVariable));
|
||||
builder.Services.AddSingleton(_ => new NestDatabase(databasePath));
|
||||
|
||||
// Listen port: --urls or ASPNETCORE_URLS; Dockerfile defaults to 8090.
|
||||
@@ -60,7 +57,8 @@ app.MapPost("/api/nests", async (HttpContext http, NestDatabase db) =>
|
||||
return error;
|
||||
|
||||
var id = record!.Id == Guid.Empty ? Guid.NewGuid() : record.Id;
|
||||
return Results.Ok(db.Insert(id, record, file!));
|
||||
try { return Results.Ok(db.Insert(id, record, file!)); }
|
||||
catch (RevisionConflictException conflict) { return Results.Json(conflict.Current, statusCode: 409); }
|
||||
});
|
||||
app.MapGet("/api/nests/{id:guid}/revisions", (Guid id, HttpRequest request, NestDatabase db) =>
|
||||
ReadHistory(id, request, db));
|
||||
@@ -90,10 +88,11 @@ app.MapPut("/api/nests/{id:guid}/metadata", async (Guid id, HttpRequest request,
|
||||
var error = IfMatch.Read(request, out var expected);
|
||||
if (error is not null) return error;
|
||||
NestRecord? record;
|
||||
try { record = await request.ReadFromJsonAsync<NestRecord>(); }
|
||||
try { record = await request.ReadFromJsonAsync<NestRecord>(NestMetadata.Options); }
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or BadHttpRequestException)
|
||||
{ return Results.BadRequest("Invalid metadata JSON."); }
|
||||
if (record is null) return Results.BadRequest("Missing metadata.");
|
||||
if (NestMetadata.GetError(record) is { } invalid) return Results.BadRequest(invalid);
|
||||
try
|
||||
{
|
||||
var updated = db.UpdateMetadata(id, record, expected);
|
||||
@@ -184,22 +183,8 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip
|
||||
return (null, null, Results.BadRequest("Malformed multipart body."));
|
||||
}
|
||||
|
||||
var metadataPart = form["metadata"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(metadataPart))
|
||||
return (null, null, Results.BadRequest("Missing 'metadata' part."));
|
||||
|
||||
NestRecord? record;
|
||||
try
|
||||
{
|
||||
record = JsonSerializer.Deserialize<NestRecord>(metadataPart, MultipartJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (null, null, Results.BadRequest("Invalid metadata JSON."));
|
||||
}
|
||||
|
||||
if (record is null)
|
||||
return (null, null, Results.BadRequest("Missing metadata."));
|
||||
if (!NestMetadata.TryRead(form["metadata"].FirstOrDefault(), out var record, out var metadataError))
|
||||
return (null, null, Results.BadRequest(metadataError));
|
||||
|
||||
var filePart = form.Files["file"];
|
||||
if (filePart is null || filePart.Length == 0)
|
||||
@@ -211,14 +196,5 @@ static async Task<(NestRecord? Record, byte[]? File, IResult? Error)> ReadMultip
|
||||
return (record, buffer.ToArray(), null);
|
||||
}
|
||||
|
||||
internal static class MultipartJson
|
||||
{
|
||||
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
|
||||
};
|
||||
}
|
||||
|
||||
// Entry-point type for in-memory integration tests (WebApplicationFactory<Program>).
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace OpenNest.Server;
|
||||
|
||||
/// <summary>Startup settings read from the command line and environment.</summary>
|
||||
public static class ServerOptions
|
||||
{
|
||||
public const string DatabaseVariable = "OPENNEST_DB";
|
||||
|
||||
/// <summary>
|
||||
/// The database path: <c>--database=<path></c> or <c>--database <path></c>, then
|
||||
/// <c>OPENNEST_DB</c>, then <c>./data/nests.db</c>. A <c>--database</c> without a path,
|
||||
/// or given twice, fails startup instead of silently storing nests elsewhere.
|
||||
/// </summary>
|
||||
public static string DatabasePath(IReadOnlyList<string> args, string? environmentPath)
|
||||
{
|
||||
string? path = null;
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
{
|
||||
string value;
|
||||
if (args[i] == "--database")
|
||||
{
|
||||
if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
throw new ArgumentException("--database requires a path.", nameof(args));
|
||||
value = args[++i];
|
||||
}
|
||||
else if (args[i].StartsWith("--database=", StringComparison.Ordinal))
|
||||
{
|
||||
value = args[i]["--database=".Length..];
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("--database requires a path.", nameof(args));
|
||||
if (path is not null)
|
||||
throw new ArgumentException("--database was given more than once.", nameof(args));
|
||||
path = value;
|
||||
}
|
||||
|
||||
return path ?? environmentPath ?? Path.Combine("data", "nests.db");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user