milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+231 -26
View File
@@ -5,7 +5,8 @@
//! facts a running server produces; an insert leaves them at their column
//! defaults so two exports taken minutes apart stay identical.
//!
//! Only list / insert / deleteAll / count exist.
//! The import path is list / insert / deleteAll / count; the runtime columns and
//! Phase 8's REST surface follow it, both keyed by row id.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -14,6 +15,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const InsertContext = context.InsertContext;
@@ -91,6 +93,9 @@ pub const SourceRow = struct {
url: []const u8,
name: []const u8,
enabled: bool,
/// Defaulted because the blocklist manager builds `SourceRow` values from
/// the refresh columns alone; the REST layer is what reads this one.
is_suggested: bool = false,
last_updated: ?i64,
domain_count: i64,
wildcard_count: i64,
@@ -107,12 +112,15 @@ pub const SourceStats = struct {
checksum: []const u8,
};
const list_rows_sql =
const row_columns_sql =
\\SELECT id, url, name, enabled, last_updated,
\\ domain_count, wildcard_count, skipped_regex_count, checksum
\\ FROM blocklist_sources ORDER BY url
\\ domain_count, wildcard_count, skipped_regex_count, checksum,
\\ is_suggested
\\ FROM blocklist_sources
;
const list_rows_sql = row_columns_sql ++ " ORDER BY url";
/// Every source with its row id and its runtime columns, in the same `url`
/// order `listBlocklistSources` uses. Every string is a heap copy owned by
/// `gpa`; free the whole list with `freeSourceRows` and then `deinit` the list.
@@ -127,33 +135,42 @@ pub fn listSourceRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(S
errdefer freeSourceRows(gpa, out.items);
while (try stmt.step()) {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
errdefer if (checksum) |value| gpa.free(value);
try out.append(gpa, .{
.id = stmt.columnInt(0),
.url = url,
.name = name,
.enabled = stmt.columnBool(3),
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6),
.skipped_regex_count = stmt.columnInt(7),
.checksum = checksum,
});
const row = try readSourceRow(&stmt, gpa);
errdefer freeSourceRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
fn readSourceRow(stmt: *db.Stmt, gpa: Allocator) db.Error!SourceRow {
const url = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(url);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const checksum = try stmt.columnTextAllocOrNull(gpa, 8);
errdefer if (checksum) |value| gpa.free(value);
return .{
.id = stmt.columnInt(0),
.url = url,
.name = name,
.enabled = stmt.columnBool(3),
.is_suggested = stmt.columnBool(9),
.last_updated = if (stmt.isNull(4)) null else stmt.columnInt(4),
.domain_count = stmt.columnInt(5),
.wildcard_count = stmt.columnInt(6),
.skipped_regex_count = stmt.columnInt(7),
.checksum = checksum,
};
}
pub fn freeSourceRow(gpa: Allocator, row: SourceRow) void {
gpa.free(row.url);
gpa.free(row.name);
if (row.checksum) |value| gpa.free(value);
}
pub fn freeSourceRows(gpa: Allocator, items: []const SourceRow) void {
for (items) |item| {
gpa.free(item.url);
gpa.free(item.name);
if (item.checksum) |value| gpa.free(value);
}
for (items) |item| freeSourceRow(gpa, item);
}
const update_stats_sql =
@@ -177,6 +194,71 @@ pub fn updateSourceStats(database: *db.Db, id: i64, stats: SourceStats) db.Error
try stmt.exec();
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/blocklists` is this table. The read shape is `SourceRow` above — the
// UI wants the counters next to the configuration — and the write shape is
// `model.BlocklistSource`, whose four fields are the four columns an operator
// may set.
pub fn getSource(database: *db.Db, gpa: Allocator, id: i64) db.Error!?SourceRow {
var stmt = try database.prepare(row_columns_sql ++ " WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readSourceRow(&stmt, gpa);
}
/// The runtime columns stay at their defaults, so a source added through the
/// API looks exactly like an imported one until the first refresh.
///
/// `error.Constraint`: `blocklist_sources.url` is UNIQUE.
pub fn insertSourceRow(database: *db.Db, item: model.BlocklistSource) db.Error!i64 {
var stmt = try database.prepare(insert_sql);
defer stmt.deinit();
try stmt.bindText(1, item.url);
try stmt.bindText(2, item.name);
try stmt.bindBool(3, item.enabled);
try stmt.bindBool(4, item.is_suggested);
try stmt.exec();
return database.lastInsertRowid();
}
const update_source_sql =
\\UPDATE blocklist_sources
\\ SET url = ?2, name = ?3, enabled = ?4, is_suggested = ?5
\\ WHERE id = ?1
;
/// Writes the four configuration columns. The runtime columns are the refresh
/// path's and stay as they are — even when `url` changes, because the next
/// refresh compares checksums and replaces them anyway.
///
/// `error.NotFound`: no source holds `id`. `error.Constraint`:
/// `blocklist_sources.url` is UNIQUE.
pub fn updateSource(database: *db.Db, id: i64, item: model.BlocklistSource) db.Error!void {
var stmt = try database.prepare(update_source_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.url);
try stmt.bindText(3, item.name);
try stmt.bindBool(4, item.enabled);
try stmt.bindBool(5, item.is_suggested);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no source holds `id`. `group_sources` references it
/// `ON DELETE CASCADE`, so every group's assignment loses it silently and no
/// constraint can fire.
pub fn deleteSource(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM blocklist_sources WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -361,3 +443,126 @@ fn listSourceRowsUnderFailure(gpa: Allocator) !void {
test "listSourceRows is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, listSourceRowsUnderFailure, .{});
}
// --- REST surface ----------------------------------------------------------
test "a blocklist source round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{
.url = "https://lists.example/hosts.txt",
.name = "Example",
.is_suggested = true,
});
const fetched = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("https://lists.example/hosts.txt", fetched.url);
try testing.expectEqualStrings("Example", fetched.name);
try testing.expect(fetched.enabled);
try testing.expect(fetched.is_suggested);
try testing.expectEqual(@as(?i64, null), fetched.last_updated);
try updateSource(&database, id, .{
.url = "https://lists.example/hosts.txt",
.name = "Example list",
.enabled = false,
});
const updated = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, updated);
try testing.expectEqualStrings("Example list", updated.name);
try testing.expect(!updated.enabled);
try testing.expect(!updated.is_suggested);
try deleteSource(&database, id);
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countBlocklistSources(&database));
}
test "updateSource leaves the runtime columns where the refresh path left them" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
try updateSourceStats(&database, id, .{
.last_updated = 1_700_000_000,
.domain_count = 12,
.wildcard_count = 3,
.skipped_regex_count = 1,
.checksum = "c" ** 64,
});
try updateSource(&database, id, .{ .url = "https://lists.example/b.txt", .name = "B" });
const row = (try getSource(&database, testing.allocator, id)).?;
defer freeSourceRow(testing.allocator, row);
try testing.expectEqualStrings("https://lists.example/b.txt", row.url);
try testing.expectEqual(@as(?i64, 1_700_000_000), row.last_updated);
try testing.expectEqual(@as(i64, 12), row.domain_count);
try testing.expectEqualStrings("c" ** 64, row.checksum.?);
}
test "source update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(
error.NotFound,
updateSource(&database, 404, .{ .url = "https://lists.example/a.txt", .name = "A" }),
);
try testing.expectError(error.NotFound, deleteSource(&database, 404));
try testing.expectEqual(@as(?SourceRow, null), try getSource(&database, testing.allocator, 404));
}
test "a duplicate source url surfaces as error.Constraint on insert and on update" {
var database = try openMigrated();
defer database.close();
_ = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
const other = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
try testing.expectError(
error.Constraint,
insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "again" }),
);
try testing.expectError(
error.Constraint,
updateSource(&database, other, .{ .url = "https://lists.example/a.txt", .name = "B" }),
);
try testing.expectEqual(@as(i64, 2), try countBlocklistSources(&database));
}
test "deleting a source drops it from every group assignment" {
var database = try openMigrated();
defer database.close();
const id = try insertSourceRow(&database, .{ .url = "https://lists.example/a.txt", .name = "A" });
const keep = try insertSourceRow(&database, .{ .url = "https://lists.example/b.txt", .name = "B" });
try database.exec("INSERT INTO group_sources (group_id, source_id) VALUES (1, 1), (1, 2);");
try deleteSource(&database, id);
try testing.expectEqual(@as(i64, 1), try countGroupSources(&database));
try testing.expectEqual(
keep,
try database.queryInt("SELECT source_id FROM group_sources"),
);
}
fn countGroupSources(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM group_sources");
}
fn sourceRowUnderFailure(gpa: Allocator) !void {
var database = try openMigrated();
defer database.close();
try seedSources(&database);
const one = (try getSource(&database, gpa, 1)).?;
defer freeSourceRow(gpa, one);
}
test "getSource is leak-safe under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, sourceRowUnderFailure, .{});
}