activity: select-only multi-client filter

the freetype client field is gone. the picker is a select-only menu of known clients with multi-select, chips for the active set, and a 32-client cap shared with the server. the api accepts a comma-separated client list and filters any-of with bound parameters. the trigger carets come from phosphor icons, newly adopted. bundle budget rises to 900000 bytes for the picker and the icon dependency.
This commit is contained in:
2026-08-31 17:40:07 +02:00
parent d79dd0bbcb
commit b774b05456
12 changed files with 1093 additions and 130 deletions
+109 -13
View File
@@ -674,7 +674,10 @@ pub const QueryFilter = struct {
/// Matched case-insensitively for ASCII, which is what SQLite's `LIKE`
/// does and what a domain search wants.
domain_substring: ?[]const u8 = null,
client: ?[]const u8 = null,
/// Exact client addresses, matched any-of. Empty means no client filter;
/// one address is the common case and reads the same as the old single
/// filter did. Never more than `max_clients`.
clients: []const []const u8 = &.{},
blocked: ?bool = null,
since: ?i64 = null,
until: ?i64 = null,
@@ -684,6 +687,12 @@ pub const QueryFilter = struct {
/// that forgets cannot ask this connection for the whole table.
pub const max_limit: u32 = 1000;
/// How many addresses one client filter may name. The statement is assembled
/// into a fixed buffer, so this is a hard bound rather than a preference: a
/// household picking more than this from a list is not a case worth widening
/// the buffer for.
pub const max_clients: usize = 32;
const select_head =
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
@@ -697,7 +706,10 @@ const like_escape = '\\';
const where_before = " q.id < ?";
const where_domain = " d.domain LIKE ? ESCAPE '\\'";
const where_client = " q.client_ip = ?";
const where_client_head = " q.client_ip IN (";
const where_client_tail = ")";
/// `?` per address with a comma between, at the widest the cap allows.
const where_client_max = where_client_head.len + 2 * max_clients + where_client_tail.len;
const where_blocked = " q.blocked = ?";
const where_since = " q.timestamp >= ?";
const where_until = " q.timestamp < ?";
@@ -716,7 +728,7 @@ const Sql = struct {
/// `where_keyword` is longer than `and_keyword` and is used at most once,
/// so counting six of it bounds every reachable combination.
const capacity = select_head.len + 6 * where_keyword.len + select_tail.len +
where_before.len + where_domain.len + where_client.len +
where_before.len + where_domain.len + where_client_max +
where_blocked.len + where_since.len + where_until.len;
buf: [capacity]u8 = undefined,
@@ -734,6 +746,19 @@ const Sql = struct {
self.put(fragment);
}
/// One placeholder per address. The addresses themselves are bound, like
/// every other value; only their count reaches this buffer.
fn clientPredicate(self: *Sql, count: usize) void {
self.put(if (self.has_where) and_keyword else where_keyword);
self.has_where = true;
self.put(where_client_head);
for (0..count) |i| {
if (i > 0) self.put(",");
self.put("?");
}
self.put(where_client_tail);
}
fn text(self: *const Sql) []const u8 {
return self.buf[0..self.len];
}
@@ -743,11 +768,16 @@ const Sql = struct {
/// `arena`, including the list's own storage, so the caller frees the whole
/// result by resetting the arena — there is nothing to unwind on failure.
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db.Error!std.ArrayList(QueryRow) {
// The statement buffer is sized for the cap, so a longer list would be a
// buffer overrun rather than a slow query. The handler rejects it first;
// this is the wall behind that, for a caller that skips the handler.
if (filter.clients.len > max_clients) return error.Misuse;
var sql: Sql = .{};
sql.put(select_head);
if (filter.before != null) sql.predicate(where_before);
if (filter.domain_substring != null) sql.predicate(where_domain);
if (filter.client != null) sql.predicate(where_client);
if (filter.clients.len > 0) sql.clientPredicate(filter.clients.len);
if (filter.blocked != null) sql.predicate(where_blocked);
if (filter.since != null) sql.predicate(where_since);
if (filter.until != null) sql.predicate(where_until);
@@ -765,9 +795,9 @@ pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db
idx += 1;
try stmt.bindText(idx, try likePattern(arena, v));
}
if (filter.client) |v| {
for (filter.clients) |client| {
idx += 1;
try stmt.bindText(idx, v);
try stmt.bindText(idx, client);
}
if (filter.blocked) |v| {
idx += 1;
@@ -2031,11 +2061,11 @@ test "each filter narrows the result on its own" {
const by_domain = try selectQueries(&database, arena, .{ .domain_substring = "example.com" });
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(by_domain.items, &buf));
const by_client = try selectQueries(&database, arena, .{ .client = "192.0.2.20" });
const by_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.20"} });
try testing.expectEqualSlices(i64, &.{2}, ids(by_client.items, &buf));
// An exact match, not a prefix: the seeded clients share the first octets.
const no_client = try selectQueries(&database, arena, .{ .client = "192.0.2" });
const no_client = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2"} });
try testing.expectEqual(@as(usize, 0), no_client.items.len);
const only_blocked = try selectQueries(&database, arena, .{ .blocked = true });
@@ -2049,7 +2079,7 @@ test "each filter narrows the result on its own" {
.limit = 10,
.before = 3,
.domain_substring = "ads",
.client = "192.0.2.20",
.clients = &.{"192.0.2.20"},
.blocked = true,
.since = 200,
.until = 300,
@@ -2125,18 +2155,84 @@ test "the built SQL never carries a filter value and fits its buffer" {
sql.put(select_head);
sql.predicate(where_before);
sql.predicate(where_domain);
sql.predicate(where_client);
sql.clientPredicate(max_clients);
sql.predicate(where_blocked);
sql.predicate(where_since);
sql.predicate(where_until);
sql.put(select_tail);
// Every predicate present is the longest reachable statement.
// Every predicate present, with the client list at its cap, is the longest
// reachable statement — which is what the buffer is sized against.
try testing.expect(sql.len <= Sql.capacity);
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, sql.text(), " WHERE"));
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, sql.text(), " AND"));
// Six filters plus the LIMIT, each a bare parameter.
try testing.expectEqual(@as(usize, 7), std.mem.count(u8, sql.text(), "?"));
// Five scalar filters plus the LIMIT, plus one per address, each a bare
// parameter: no value is ever spelled into the statement.
try testing.expectEqual(@as(usize, 6 + max_clients), std.mem.count(u8, sql.text(), "?"));
// The select list has commas of its own, so the client list is measured as
// the difference against the same statement without it.
var without: Sql = .{};
without.put(select_head);
without.predicate(where_before);
without.predicate(where_domain);
without.predicate(where_blocked);
without.predicate(where_since);
without.predicate(where_until);
without.put(select_tail);
try testing.expectEqual(
@as(usize, max_clients - 1),
std.mem.count(u8, sql.text(), ",") - std.mem.count(u8, without.text(), ","),
);
}
test "one client reads as an exact match and several read as any-of" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var database = try openLog();
defer database.close();
var second = plainRow(200, "b.example");
second.client_ip = "192.0.2.11";
var third = plainRow(300, "c.example");
third.client_ip = "192.0.2.12";
try seed(&database, &.{ plainRow(100, "a.example"), second, third });
var buf: [8]i64 = undefined;
const one = try selectQueries(&database, arena, .{ .clients = &.{"192.0.2.11"} });
try testing.expectEqualSlices(i64, &.{2}, ids(one.items, &buf));
// Newest-first, so the higher id leads however the addresses are ordered.
const two = try selectQueries(&database, arena, .{ .clients = &.{ "192.0.2.12", "192.0.2.10" } });
try testing.expectEqualSlices(i64, &.{ 3, 1 }, ids(two.items, &buf));
// An address nothing was logged from narrows to nothing rather than being
// ignored, which is the difference between a filter and a suggestion.
const absent = try selectQueries(&database, arena, .{ .clients = &.{"198.51.100.1"} });
try testing.expectEqual(@as(usize, 0), absent.items.len);
// No addresses at all is no client filter.
const none = try selectQueries(&database, arena, .{});
try testing.expectEqual(@as(usize, 3), none.items.len);
}
test "a client list past the cap is refused rather than truncated" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var database = try openLog();
defer database.close();
var too_many: [max_clients + 1][]const u8 = undefined;
for (&too_many) |*slot| slot.* = "192.0.2.10";
// Dropping the overflow would narrow the filter silently, which answers a
// question the caller did not ask; the statement buffer could not hold it
// either way.
try testing.expectError(error.Misuse, selectQueries(&database, arena, .{ .clients = &too_many }));
}
test "likePattern wraps the needle and neutralises every metacharacter" {
+84 -6
View File
@@ -32,12 +32,34 @@ pub const max_limit: u32 = queries_repo.max_limit;
pub const max_domain_len = logger.max_domain_len;
pub const max_client_len = logger.max_client_len;
/// How many addresses one `client` parameter may name, the repository's cap.
pub const max_clients = queries_repo.max_clients;
/// The longest `client` value that can decode to a full list: every address at
/// its width, separated by commas.
pub const max_client_list_len = max_clients * max_client_len + (max_clients - 1);
/// The buffer that value needs.
///
/// `queryValue` measures the value as it arrives and only then decodes it in
/// place, so the buffer is sized for the percent-encoded form rather than the
/// decoded one. A browser encodes the separating commas, and every colon of an
/// IPv6 address with them, so a legal near-cap selection of IPv6 clients would
/// be refused by a buffer sized for what it decodes to. Three bytes per byte is
/// the worst `%XX` can do.
const max_client_value_len = 3 * max_client_list_len;
/// Where the two string filters are copied to. The parsed filter borrows them,
/// so it must not outlive the buffers — in the handler both live in the same
/// stack frame.
///
/// `client` holds the raw comma-separated value and `clients` indexes into it,
/// so the addresses are never copied a second time: the filter's slices point
/// into the same bytes the query string was decoded into.
pub const Buffers = struct {
domain: [max_domain_len]u8 = undefined,
client: [max_client_len]u8 = undefined,
client: [max_client_value_len]u8 = undefined,
clients: [max_clients][]const u8 = undefined,
};
pub const Page = struct {
@@ -82,8 +104,22 @@ pub fn parseFilter(query: []const u8, buffers: *Buffers) FilterError!queries_rep
if (domain.len != 0) filter.domain_substring = domain;
}
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |client| {
if (client.len != 0) filter.client = client;
// A comma-separated list of exact addresses, matched any-of. An empty entry
// is a malformed list rather than a filter to drop: `?client=a,,b` is a
// client bug, and answering it as `a,b` would hide the bug behind an answer
// to a question nobody asked.
if (http_util.queryValue(query, "client", &buffers.client) catch return error.BadClient) |raw| {
if (raw.len != 0) {
var count: usize = 0;
var it = std.mem.splitScalar(u8, raw, ',');
while (it.next()) |entry| {
if (entry.len == 0 or entry.len > max_client_len) return error.BadClient;
if (count == max_clients) return error.BadClient;
buffers.clients[count] = entry;
count += 1;
}
filter.clients = buffers.clients[0..count];
}
}
filter.blocked = http_util.queryBool(query, "blocked") catch return error.BadBlocked;
@@ -98,7 +134,7 @@ pub fn message(err: FilterError) []const u8 {
error.BadLimit => "limit must be between 1 and 1000",
error.BadBefore => "before must be a positive row id",
error.BadDomain => "domain is not a valid filter",
error.BadClient => "client is not a valid filter",
error.BadClient => "client must be up to 32 comma-separated client addresses",
error.BadBlocked => "blocked must be true or false",
error.BadSince => "since must be a unix timestamp in seconds",
error.BadUntil => "until must be a unix timestamp in seconds",
@@ -228,7 +264,8 @@ test "every filter reaches the repository untouched" {
try testing.expectEqual(@as(u32, 250), filter.limit);
try testing.expectEqual(@as(?i64, 900), filter.before);
try testing.expectEqualStrings("ads.example", filter.domain_substring.?);
try testing.expectEqualStrings("192.0.2.10", filter.client.?);
try testing.expectEqual(@as(usize, 1), filter.clients.len);
try testing.expectEqualStrings("192.0.2.10", filter.clients[0]);
try testing.expectEqual(@as(?bool, true), filter.blocked);
try testing.expectEqual(@as(?i64, 100), filter.since);
try testing.expectEqual(@as(?i64, 200), filter.until);
@@ -238,7 +275,48 @@ test "an empty string filter is no filter at all" {
var buffers: Buffers = .{};
const filter = try parseFilter("domain=&client=", &buffers);
try testing.expectEqual(@as(?[]const u8, null), filter.domain_substring);
try testing.expectEqual(@as(?[]const u8, null), filter.client);
try testing.expectEqual(@as(usize, 0), filter.clients.len);
}
test "a client list is several exact addresses and each entry must be well formed" {
var buffers: Buffers = .{};
const several = try parseFilter("client=192.0.2.10,192.0.2.11,192.0.2.12", &buffers);
try testing.expectEqual(@as(usize, 3), several.clients.len);
try testing.expectEqualStrings("192.0.2.10", several.clients[0]);
try testing.expectEqualStrings("192.0.2.11", several.clients[1]);
try testing.expectEqualStrings("192.0.2.12", several.clients[2]);
// An empty entry would silently widen the filter, so it is malformed input.
try testing.expectError(error.BadClient, parseFilter("client=192.0.2.10,", &buffers));
try testing.expectError(error.BadClient, parseFilter("client=,192.0.2.10", &buffers));
try testing.expectError(error.BadClient, parseFilter("client=192.0.2.10,,192.0.2.11", &buffers));
var many: std.ArrayList(u8) = .empty;
defer many.deinit(testing.allocator);
try many.appendSlice(testing.allocator, "client=192.0.2.1");
for (0..max_clients) |_| try many.appendSlice(testing.allocator, ",192.0.2.1");
try testing.expectError(error.BadClient, parseFilter(many.items, &buffers));
}
test "a full list of percent-encoded IPv6 clients fits" {
// A browser encodes the separators and every colon it puts between them, so
// the value on the wire is several times the length of what it decodes to.
// Sizing the buffer for the decoded form refuses a selection that is legal.
const address = "2001:0db8:0000:0000:0000:0000:0000:0001";
const encoded = "2001%3A0db8%3A0000%3A0000%3A0000%3A0000%3A0000%3A0001";
var query: std.ArrayList(u8) = .empty;
defer query.deinit(testing.allocator);
try query.appendSlice(testing.allocator, "client=");
for (0..max_clients) |index| {
if (index != 0) try query.appendSlice(testing.allocator, "%2C");
try query.appendSlice(testing.allocator, encoded);
}
var buffers: Buffers = .{};
const filter = try parseFilter(query.items, &buffers);
try testing.expectEqual(@as(usize, max_clients), filter.clients.len);
for (filter.clients) |entry| try testing.expectEqualStrings(address, entry);
}
test "each malformed parameter names itself in a 400" {
+4 -2
View File
@@ -200,8 +200,10 @@ paths:
schema: { type: string, maxLength: 253 }
- name: client
in: query
description: Exact client address.
schema: { type: string, maxLength: 64 }
description: >-
Up to 32 comma-separated exact client addresses, matched any-of.
Each entry is at most 45 characters; an empty entry is a 400.
schema: { type: string, maxLength: 1471 }
- name: blocked
in: query
schema: { type: boolean }