Compare commits

...

2 commits

Author SHA1 Message Date
eabe91099a
add refresh POST operation to allow forced symbol refreshes
All checks were successful
Generic zig build / build (push) Successful in 23s
Generic zig build / deploy (push) Successful in 14s
2026-08-13 14:08:25 -07:00
e9826c06ed
split logic into multiple files 2026-08-13 10:20:22 -07:00
4 changed files with 2690 additions and 2373 deletions

255
src/App.zig Normal file
View file

@ -0,0 +1,255 @@
//! The HTTP application: shared state, the request gate, and per-request
//! bookkeeping.
//!
//! Split from `main.zig` so the route handlers and the refresh command can each
//! live in their own module without a cycle: handlers need `App`, `main` needs
//! both, and this file needs neither.
const std = @import("std");
const zfin = @import("zfin");
const httpz = @import("httpz");
const log = std.log.scoped(.@"zfin-server");
pub const App = struct {
io: std.Io,
environ: *const std.process.Environ.Map,
allocator: std.mem.Allocator,
config: zfin.Config,
svc: zfin.DataService,
/// Threshold in milliseconds above which a request is logged
/// as slow. Tunable via `ZFIN_SERVER_SLOW_MS` env var; defaults
/// to 500ms. Captured once at App.init so the dispatch hot path
/// doesn't re-parse on every request.
slow_threshold_ms: u64,
/// Optional shared API key. When set (via `ZFIN_SERVER_API_KEY`),
/// every endpoint except the public surface (`/`, `/help`, and
/// `/:symbol/returns`) requires a matching key, supplied either as
/// an `X-API-Key` header or an `api_key` query parameter. When null
/// (env var unset or empty) the server is fully open - this
/// soft cutover lets the key roll out to clients before enforcement
/// is switched on. Captured once at init.
api_key: ?[]const u8,
/// Serializes the portfolio read-modify-write across concurrent
/// watchlist adds (httpz dispatches requests on multiple threads).
/// Without it two simultaneous adds could both read the old file and
/// the second writer would clobber the first's new symbol.
watch_mutex: std.Io.Mutex = .init,
/// Serializes `POST /refresh`, and does so with a NON-blocking acquire so a
/// concurrent request is rejected (409) rather than queued.
///
/// Queueing would be worse than refusing: each refresh spends provider quota,
/// the request is synchronous, and zfin's HTTP client retries up to three
/// times on a 5xx or transient failure. A refresh slow enough to time out
/// would therefore be retried while the first was still running, and a
/// blocking lock would dutifully run every one of them in turn - turning one
/// slow request into a provider stampede. Refusing tells the caller the truth:
/// a refresh is already in flight, so wait for it.
refresh_mutex: std.Io.Mutex = .init,
pub fn init(io: std.Io, allocator: std.mem.Allocator, environ: *const std.process.Environ.Map) App {
const config = zfin.Config.fromEnv(io, allocator, environ);
const svc = zfin.DataService.init(io, allocator, config);
const slow_threshold_ms = if (environ.get("ZFIN_SERVER_SLOW_MS")) |s|
std.fmt.parseInt(u64, s, 10) catch 500
else
500;
// Treat an empty value as unset so `ZFIN_SERVER_API_KEY=` can't
// accidentally enable enforcement with an empty (never-matching) key.
const api_key: ?[]const u8 = if (environ.get("ZFIN_SERVER_API_KEY")) |v|
(if (v.len == 0) null else v)
else
null;
return .{
.io = io,
.environ = environ,
.allocator = allocator,
.config = config,
.svc = svc,
.slow_threshold_ms = slow_threshold_ms,
.api_key = api_key,
};
}
pub fn deinit(self: *App) void {
self.svc.deinit();
self.config.deinit();
}
/// httpz dispatch hook: every request flows through here so we
/// have a single place to wrap timing and error logging without
/// modifying every handler. Slow requests (above
/// `slow_threshold_ms`) and error responses (status >= 400)
/// emit a structured stderr line; everything else stays silent.
pub fn dispatch(self: *App, action: httpz.Action(*App), req: *httpz.Request, res: *httpz.Response) !void {
// wall-clock required: per-request elapsed for slow-request
// logging. `.awake` (monotonic) avoids spurious negatives
// on system clock skew.
const start_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds;
// Registered before the gate and the action so every exit path -
// a 401 from the gate, an error from the action, or a normal
// response - emits the slow/error log line.
defer {
const elapsed_ns = std.Io.Timestamp.now(self.io, .awake).nanoseconds - start_ns;
const elapsed_ms: u64 = @intCast(@divTrunc(elapsed_ns, std.time.ns_per_ms));
if (shouldLogRequest(elapsed_ms, res.status, self.slow_threshold_ms)) {
// wall-clock required: ts in stderr line lets the
// operator correlate slow requests with cron / system
// events using `date -d @<ts>`.
const ts = std.Io.Timestamp.now(self.io, .real).toSeconds();
log.warn("ts={d} elapsed_ms={d} status={d} method={s} path={s}", .{
ts,
elapsed_ms,
res.status,
@tagName(req.method),
req.url.path,
});
}
}
// API-key gate. Soft cutover: only enforced when a key is
// configured. The public surface (`/`, `/help`, and the
// LibreOffice `/:symbol/returns` endpoint) is never gated.
if (self.api_key) |expected| {
if (!pathIsPublic(req.url.path) and !providedKeyMatches(req, expected)) {
res.status = 401;
res.content_type = httpz.ContentType.TEXT;
res.body = "Unauthorized: missing or invalid API key\n";
return;
}
}
try action(self, req, res);
}
};
/// Pure predicate: should this request emit a stderr log line?
/// Logs slow successes (above threshold) and any error response.
fn shouldLogRequest(elapsed_ms: u64, status: u16, threshold_ms: u64) bool {
return elapsed_ms > threshold_ms or status >= 400;
}
/// Pure predicate: is this request path part of the public surface that
/// never requires an API key? Public = the landing page, the help text,
/// and the LibreOffice returns endpoint (`/:symbol/returns`, including
/// its `?fmt=xml` and `?watch=true` variants - the query string is not
/// part of the path). Everything else (raw SRF cache files, quotes, the
/// symbol list, EDGAR maps, entity facts) is gated.
fn pathIsPublic(path: []const u8) bool {
if (std.mem.eql(u8, path, "/")) return true;
if (std.mem.eql(u8, path, "/help")) return true;
return isSymbolReturnsPath(path);
}
/// True only for paths shaped exactly like `/<symbol>/returns`: a single
/// non-empty symbol segment followed by the literal `returns`. Guards
/// against `/returns` (no symbol) and multi-segment look-alikes such as
/// `/a/b/returns` or `/AAPL/returns/extra`.
fn isSymbolReturnsPath(path: []const u8) bool {
const suffix = "/returns";
if (path.len <= suffix.len) return false;
if (!std.mem.endsWith(u8, path, suffix)) return false;
if (path[0] != '/') return false;
const symbol = path[1 .. path.len - suffix.len];
if (symbol.len == 0) return false;
if (std.mem.indexOfScalar(u8, symbol, '/') != null) return false;
return true;
}
/// Length-checked equality of a caller-supplied key against the
/// configured one. The threat model is casual/incidental traffic, not a
/// timing-attack adversary, so plain `mem.eql` is sufficient. A null
/// (absent header/param) never matches.
fn keyMatches(provided: ?[]const u8, expected: []const u8) bool {
const p = provided orelse return false;
return std.mem.eql(u8, p, expected);
}
/// Read the caller-supplied API key from the `X-API-Key` header
/// (preferred) or an `api_key` query parameter (curl convenience) and
/// compare against the configured key. httpz wants the header name in
/// lowercase. A malformed query string is treated as "no key".
fn providedKeyMatches(req: *httpz.Request, expected: []const u8) bool {
if (keyMatches(req.header("x-api-key"), expected)) return true;
const q = req.query() catch return false;
return keyMatches(q.get("api_key"), expected);
}
// Tests
test "shouldLogRequest: fast 2xx is silent" {
try std.testing.expect(!shouldLogRequest(10, 200, 500));
try std.testing.expect(!shouldLogRequest(499, 200, 500));
try std.testing.expect(!shouldLogRequest(0, 204, 500));
}
test "shouldLogRequest: slow 2xx logs" {
try std.testing.expect(shouldLogRequest(501, 200, 500));
try std.testing.expect(shouldLogRequest(2000, 200, 500));
// Boundary: == threshold is NOT logged (strict >).
try std.testing.expect(!shouldLogRequest(500, 200, 500));
}
test "shouldLogRequest: any error response logs regardless of timing" {
try std.testing.expect(shouldLogRequest(1, 400, 500));
try std.testing.expect(shouldLogRequest(1, 404, 500));
try std.testing.expect(shouldLogRequest(1, 500, 500));
try std.testing.expect(shouldLogRequest(1, 503, 500));
// 3xx is not flagged as error.
try std.testing.expect(!shouldLogRequest(1, 301, 500));
try std.testing.expect(!shouldLogRequest(1, 304, 500));
}
test "shouldLogRequest: custom threshold respected" {
try std.testing.expect(!shouldLogRequest(50, 200, 100));
try std.testing.expect(shouldLogRequest(150, 200, 100));
// Higher threshold (e.g. user sets ZFIN_SERVER_SLOW_MS=2000).
try std.testing.expect(!shouldLogRequest(1500, 200, 2000));
try std.testing.expect(shouldLogRequest(2500, 200, 2000));
}
test "pathIsPublic: public surface (no key required)" {
try std.testing.expect(pathIsPublic("/"));
try std.testing.expect(pathIsPublic("/help"));
try std.testing.expect(pathIsPublic("/AAPL/returns"));
// Symbols with dots (class shares) still match.
try std.testing.expect(pathIsPublic("/BRK.B/returns"));
}
test "pathIsPublic: gated surface (key required)" {
try std.testing.expect(!pathIsPublic("/AAPL/candles"));
try std.testing.expect(!pathIsPublic("/AAPL/quote"));
try std.testing.expect(!pathIsPublic("/symbols"));
try std.testing.expect(!pathIsPublic("/_edgar/tickers_funds"));
try std.testing.expect(!pathIsPublic("/0000320193/entity_facts"));
// Diagnostics leaks the operator's tracked set and cache layout, so it must
// stay gated. It needs no entry in `pathIsPublic` to be gated - the default
// is closed - and this asserts the default rather than trusting it, because
// the cost of that assumption being wrong is silent disclosure.
try std.testing.expect(!pathIsPublic("/AAPL/diagnostics"));
try std.testing.expect(!pathIsPublic("/BRK.B/diagnostics"));
// `/refresh` spends provider quota and writes the cache. It needs no entry
// here to be gated - the default is closed - and this asserts the default
// rather than trusting it, because an unauthenticated refresh trigger is a
// free denial-of-quota for anyone who finds the URL.
try std.testing.expect(!pathIsPublic("/refresh"));
}
test "pathIsPublic: returns look-alikes do not slip through" {
try std.testing.expect(!pathIsPublic("/returns")); // no symbol
try std.testing.expect(!pathIsPublic("/a/b/returns")); // extra segment
try std.testing.expect(!pathIsPublic("/AAPL/returns/extra")); // suffix, not exact
try std.testing.expect(!pathIsPublic("/help/secret")); // help prefix only
try std.testing.expect(!pathIsPublic("//returns")); // empty symbol
}
test "keyMatches" {
try std.testing.expect(keyMatches("s3cret", "s3cret"));
try std.testing.expect(!keyMatches("s3cret", "other"));
try std.testing.expect(!keyMatches(null, "s3cret"));
try std.testing.expect(!keyMatches("", "s3cret"));
// Length-checked: neither a prefix nor an extension matches.
try std.testing.expect(!keyMatches("s3cre", "s3cret"));
try std.testing.expect(!keyMatches("s3cretX", "s3cret"));
}

1177
src/handlers.zig Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1221
src/refresh.zig Normal file

File diff suppressed because it is too large Load diff