Compare commits
No commits in common. "eabe91099ab887d75b81238d4dfb2671ffd687b8" and "d1ae5f4d171b369a24d9e3131f4170c0b0742d09" have entirely different histories.
eabe91099a
...
d1ae5f4d17
4 changed files with 2373 additions and 2690 deletions
255
src/App.zig
255
src/App.zig
|
|
@ -1,255 +0,0 @@
|
|||
//! 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
1177
src/handlers.zig
File diff suppressed because it is too large
Load diff
2410
src/main.zig
2410
src/main.zig
File diff suppressed because it is too large
Load diff
1221
src/refresh.zig
1221
src/refresh.zig
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue