//! Static asset serving (milestone-8 ruling 24). //! //! Production serves from `admin_assets`, the module the build generates from //! `-Dadmin-dist`: bytes, content type and a strong ETag per file, plus a //! `.gz` sibling entry where compressing at build time paid off. Serving //! is a linear scan over a handful of immutable entries — no allocation, no //! clock, no disk. //! //! `ETag`/`If-None-Match` is the whole caching story. There is no //! `Last-Modified` and no `Date`: std has no RFC 1123 formatter, and a strong //! content hash validates an embedded immutable asset strictly better than a //! timestamp would. //! //! An unknown path outside `/api` answers with index.html, 200 — the SPA owns //! client-side routes, and its router needs the shell to load on a deep link. //! `.gz` entries are reachable only through content negotiation, never as //! paths of their own; each is a representation of its base file, with its own //! ETag so a `304` is always judged against the representation that would be //! served. //! //! Dev mode (`nxdns run --admin-dev `, wired by the CLI) serves from disk //! with no cache headers, so a UI developer sees an edit on reload. const std = @import("std"); const assets = @import("admin_assets"); const http_util = @import("http_util.zig"); const server = @import("server.zig"); const log = std.log.scoped(.web_static); pub const File = assets.File; /// What the build embedded. Entries are sorted by path and immutable. pub const embedded: []const File = assets.files; pub const index_path = "/index.html"; /// A disk asset a dev-mode request may read. Matches the embed limit in /// tools/gen_admin_assets.zig. pub const max_disk_asset_bytes = 64 * 1024 * 1024; pub const Selection = struct { file: *const File, /// True when `file` is the gzip sibling and the response must carry /// `content-encoding: gzip`. gzip: bool, }; /// Resolves a raw request path against `files`: exact match, `/` → index, /// gzip sibling when the client accepts it. Null means no asset claims the /// path and the caller decides between the SPA fallback and a 404. pub fn select(files: []const File, raw_path: []const u8, accept_encoding: []const u8) ?Selection { const path = if (raw_path.len == 0 or std.mem.eql(u8, raw_path, "/")) index_path else raw_path; // A `.gz` entry is a representation, not an address. if (std.mem.endsWith(u8, path, ".gz")) return null; const file = find(files, path) orelse return null; if (acceptsGzip(accept_encoding)) { var buf: [http_util.max_target_len + 3]u8 = undefined; const sibling = std.fmt.bufPrint(&buf, "{s}.gz", .{path}) catch return .{ .file = file, .gzip = false }; if (find(files, sibling)) |gz| return .{ .file = gz, .gzip = true }; } return .{ .file = file, .gzip = false }; } fn find(files: []const File, path: []const u8) ?*const File { for (files) |*file| { if (std.mem.eql(u8, file.path, path)) return file; } return null; } /// Whether `accept-encoding` admits gzip. Every comma-separated entry is /// scanned; a `gzip` entry decides over `*`; `q=0` refuses; an entry whose /// parameters fall outside the grammar is unusable and refuses. An empty /// header (or one the connection budget dropped) reads as identity-only, /// which degrades to the uncompressed entry. fn acceptsGzip(header: []const u8) bool { var gzip_entry: ?bool = null; var wildcard_entry: ?bool = null; var tokens = std.mem.splitScalar(u8, header, ','); while (tokens.next()) |token| { var parts = std.mem.splitScalar(u8, token, ';'); const name = std.mem.trim(u8, parts.next().?, " \t"); const is_gzip = std.ascii.eqlIgnoreCase(name, "gzip"); if (!is_gzip and !std.mem.eql(u8, name, "*")) continue; // The grammar admits one parameter and it is the weight. var acceptable = true; var saw_weight = false; while (parts.next()) |param| { const trimmed = std.mem.trim(u8, param, " \t"); if (saw_weight or !std.ascii.startsWithIgnoreCase(trimmed, "q=")) { acceptable = false; break; } saw_weight = true; acceptable = qualityAccepts(trimmed[2..]); } if (is_gzip) gzip_entry = acceptable else wildcard_entry = acceptable; } return gzip_entry orelse wildcard_entry orelse false; } /// A well-formed nonzero qvalue: `0` or `1`, optionally `.` and up to three /// digits, never exceeding 1. Malformed reads as not acceptable. fn qualityAccepts(value: []const u8) bool { if (value.len == 0 or value.len > 5) return false; if (value[0] != '0' and value[0] != '1') return false; if (value.len > 1 and value[1] != '.') return false; var nonzero = value[0] == '1'; if (value.len > 2) for (value[2..]) |c| { if (!std.ascii.isDigit(c)) return false; if (value[0] == '1' and c != '0') return false; if (c != '0') nonzero = true; }; return nonzero; } /// Whether an `if-none-match` header names `etag` (which carries its quotes). /// Weak validators compare by content: a `W/` prefix on the wire still matches, /// because the bytes behind a content hash are the content. fn etagMatches(header: []const u8, etag: []const u8) bool { var tokens = std.mem.splitScalar(u8, header, ','); while (tokens.next()) |token| { var candidate = std.mem.trim(u8, token, " \t"); if (std.mem.eql(u8, candidate, "*")) return true; if (std.mem.startsWith(u8, candidate, "W/")) candidate = candidate[2..]; if (std.mem.eql(u8, candidate, etag)) return true; } return false; } /// The SPA fallback handler (ruling 24): every non-`/api` path no route /// claimed. W9 wires it as `WebState.fallback`. pub fn fallback( state: *server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { _ = state; _ = io; if (request.method != .GET and request.method != .HEAD) return http_util.respondError(request, .not_found, "not found"); const selection = select(embedded, request.raw_path, request.accept_encoding) orelse select(embedded, index_path, request.accept_encoding) orelse return http_util.respondError(request, .not_found, "not found"); return respondAsset(request, selection); } fn respondAsset(request: *http_util.Request, selection: Selection) http_util.HandlerError!void { const file = selection.file; if (etagMatches(request.if_none_match, file.etag)) { return request.http.respond("", .{ .status = .not_modified, .extra_headers = &.{ .{ .name = "etag", .value = file.etag }, .{ .name = "vary", .value = "accept-encoding" }, }, }); } var headers_buf: [3]std.http.Header = .{ .{ .name = "etag", .value = file.etag }, .{ .name = "vary", .value = "accept-encoding" }, .{ .name = "content-encoding", .value = "gzip" }, }; const headers: []const std.http.Header = headers_buf[0..if (selection.gzip) 3 else 2]; return http_util.respondBytes(request, .ok, file.bytes, file.content_type, headers); } /// Joins decoded path segments back into a relative disk path, or null when /// any segment could escape the root. Segments were split before percent /// decoding, so a decoded segment may contain `/` — that and `..` are the two /// traversal shapes, and both are refused rather than normalized. fn diskRelativePath(buf: []u8, segments: []const []const u8) ?[]const u8 { if (segments.len == 0) return index_path[1..]; var writer: std.Io.Writer = .fixed(buf); for (segments, 0..) |segment, index| { if (std.mem.eql(u8, segment, "..") or std.mem.eql(u8, segment, ".")) return null; if (std.mem.findScalar(u8, segment, '/') != null) return null; if (std.mem.findScalar(u8, segment, '\\') != null) return null; if (std.mem.findScalar(u8, segment, 0) != null) return null; if (index != 0) writer.writeAll("/") catch return null; writer.writeAll(segment) catch return null; } return writer.buffered(); } /// Dev-mode disk serving for `--admin-dev` (ruling 24). No cache headers: the /// point of the flag is that an edit shows up on the next reload. The CLI /// wiring (W9) closes over the directory and passes it here. pub fn serveFromDisk( root: []const u8, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { if (request.method != .GET and request.method != .HEAD) return http_util.respondError(request, .not_found, "not found"); var path_buf: [http_util.max_target_len]u8 = undefined; const relative = diskRelativePath(&path_buf, request.path.segments()) orelse return http_util.respondError(request, .not_found, "not found"); var dir = std.Io.Dir.cwd().openDir(io, root, .{}) catch |err| { log.warn("admin-dev directory '{s}' is unreadable: {t}", .{ root, err }); return http_util.respondError(request, .internal_server_error, "admin-dev directory unavailable"); }; defer dir.close(io); if (readDiskFile(dir, io, request, relative)) |bytes| return http_util.respondBytes(request, .ok, bytes, contentType(relative), &.{}); // SPA fallback, same rule as the embedded path. const index = readDiskFile(dir, io, request, index_path[1..]) orelse return http_util.respondError(request, .not_found, "not found"); return http_util.respondBytes(request, .ok, index, contentType(index_path), &.{}); } fn readDiskFile( dir: std.Io.Dir, io: std.Io, request: *http_util.Request, sub_path: []const u8, ) ?[]const u8 { if (!resolvesUnderRoot(dir, io, sub_path)) return null; return dir.readFileAlloc(io, sub_path, request.arena, .limited(max_disk_asset_bytes)) catch |err| { switch (err) { error.FileNotFound, error.IsDir => {}, else => log.warn("admin-dev read of '{s}' failed: {t}", .{ sub_path, err }), } return null; }; } /// The lexical checks in `diskRelativePath` cannot see a symlink inside the /// tree pointing out of it, so the target's canonical path must sit under the /// root's. Racy against a concurrent rename, which loopback operator tooling /// tolerates; any failure to resolve reads as a 404. fn resolvesUnderRoot(dir: std.Io.Dir, io: std.Io, sub_path: []const u8) bool { var root_buf: [std.Io.Dir.max_path_bytes]u8 = undefined; var target_buf: [std.Io.Dir.max_path_bytes]u8 = undefined; const root_len = dir.realPath(io, &root_buf) catch return false; const target_len = dir.realPathFile(io, sub_path, &target_buf) catch return false; const root = root_buf[0..root_len]; const target = target_buf[0..target_len]; return target.len > root.len + 1 and std.mem.startsWith(u8, target, root) and target[root.len] == '/'; } /// Extension → MIME type for dev-mode disk serving. The embedded entries carry /// the same mapping, stamped by tools/gen_admin_assets.zig; a test below keeps /// the two from drifting. pub fn contentType(path: []const u8) []const u8 { const map = [_]struct { ext: []const u8, mime: []const u8 }{ .{ .ext = ".html", .mime = "text/html; charset=utf-8" }, .{ .ext = ".js", .mime = "text/javascript" }, .{ .ext = ".mjs", .mime = "text/javascript" }, .{ .ext = ".css", .mime = "text/css" }, .{ .ext = ".svg", .mime = "image/svg+xml" }, .{ .ext = ".png", .mime = "image/png" }, .{ .ext = ".ico", .mime = "image/x-icon" }, .{ .ext = ".json", .mime = "application/json" }, .{ .ext = ".map", .mime = "application/json" }, .{ .ext = ".webmanifest", .mime = "application/manifest+json" }, .{ .ext = ".txt", .mime = "text/plain; charset=utf-8" }, .{ .ext = ".woff2", .mime = "font/woff2" }, .{ .ext = ".woff", .mime = "font/woff" }, .{ .ext = ".wasm", .mime = "application/wasm" }, }; for (map) |entry| { if (std.mem.endsWith(u8, path, entry.ext)) return entry.mime; } return "application/octet-stream"; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const test_files = [_]File{ .{ .path = "/index.html", .bytes = "", .content_type = "text/html; charset=utf-8", .etag = "\"aaaa\"" }, .{ .path = "/index.html.gz", .bytes = "gz!", .content_type = "text/html; charset=utf-8", .etag = "\"bbbb\"" }, .{ .path = "/app.css", .bytes = "body{}", .content_type = "text/css", .etag = "\"cccc\"" }, }; test "an exact path selects its file and the root selects the index" { const css = select(&test_files, "/app.css", "").?; try testing.expectEqualStrings("/app.css", css.file.path); try testing.expect(!css.gzip); try testing.expectEqualStrings("/index.html", select(&test_files, "/", "").?.file.path); try testing.expectEqualStrings("/index.html", select(&test_files, "", "").?.file.path); try testing.expect(select(&test_files, "/missing.js", "gzip") == null); } test "a gzip sibling is chosen only when the client accepts gzip" { const plain = select(&test_files, "/index.html", "").?; try testing.expect(!plain.gzip); try testing.expectEqualStrings("\"aaaa\"", plain.file.etag); const gz = select(&test_files, "/index.html", "gzip, br").?; try testing.expect(gz.gzip); try testing.expectEqualStrings("\"bbbb\"", gz.file.etag); try testing.expectEqualStrings("text/html; charset=utf-8", gz.file.content_type); // No sibling: the css stays identity even for a gzip client. try testing.expect(!select(&test_files, "/app.css", "gzip").?.gzip); } test "a .gz path is not addressable directly" { try testing.expect(select(&test_files, "/index.html.gz", "gzip") == null); } test "accept-encoding parsing scans every entry per the grammar" { const cases = [_]struct { header: []const u8, accepts: bool }{ .{ .header = "gzip", .accepts = true }, .{ .header = "GZIP", .accepts = true }, .{ .header = "br, gzip;q=0.5", .accepts = true }, .{ .header = " deflate , gzip ", .accepts = true }, .{ .header = "*", .accepts = true }, .{ .header = "*;q=0.5", .accepts = true }, .{ .header = "gzip;q=0.001", .accepts = true }, .{ .header = "gzip;q=1", .accepts = true }, .{ .header = "gzip;q=1.000", .accepts = true }, .{ .header = "gzip;Q=0.5", .accepts = true }, .{ .header = "", .accepts = false }, .{ .header = "br, deflate", .accepts = false }, .{ .header = "gzip;q=0", .accepts = false }, .{ .header = "gzip;q=0.000", .accepts = false }, // A specific gzip entry decides over the wildcard, in either order. .{ .header = "*;q=0, gzip", .accepts = true }, .{ .header = "gzip, *;q=0", .accepts = true }, .{ .header = "gzip;q=0, *", .accepts = false }, .{ .header = "*, gzip;q=0", .accepts = false }, .{ .header = "*;q=0", .accepts = false }, // Malformed entries are unusable, never acceptable. .{ .header = "gzip;q=invalid", .accepts = false }, .{ .header = "gzip;q=", .accepts = false }, .{ .header = "gzip;q=1.5", .accepts = false }, .{ .header = "gzip;q=0.5000", .accepts = false }, .{ .header = "gzip;q=0..5", .accepts = false }, .{ .header = "gzip;level=9", .accepts = false }, .{ .header = "gzip;q=0.5;q=1", .accepts = false }, // A malformed gzip entry still decides over a usable wildcard. .{ .header = "*, gzip;q=invalid", .accepts = false }, }; for (cases) |case| { testing.expectEqual(case.accepts, acceptsGzip(case.header)) catch |err| { std.debug.print("header: '{s}'\n", .{case.header}); return err; }; } } test "if-none-match matches exact, listed, weak and wildcard validators" { try testing.expect(etagMatches("\"aaaa\"", "\"aaaa\"")); try testing.expect(etagMatches("\"xxxx\", \"aaaa\"", "\"aaaa\"")); try testing.expect(etagMatches("W/\"aaaa\"", "\"aaaa\"")); try testing.expect(etagMatches("*", "\"aaaa\"")); try testing.expect(!etagMatches("\"xxxx\"", "\"aaaa\"")); try testing.expect(!etagMatches("", "\"aaaa\"")); try testing.expect(!etagMatches("aaaa", "\"aaaa\"")); } test "disk paths join segments and refuse every traversal shape" { var buf: [256]u8 = undefined; const nested = diskRelativePath(&buf, &.{ "assets", "app.js" }).?; try testing.expectEqualStrings("assets/app.js", nested); try testing.expectEqualStrings("index.html", diskRelativePath(&buf, &.{}).?); try testing.expect(diskRelativePath(&buf, &.{ "..", "secret" }) == null); try testing.expect(diskRelativePath(&buf, &.{"."}) == null); // `%2F` decodes inside a segment; a joined `/` must not appear. try testing.expect(diskRelativePath(&buf, &.{"../etc"}) == null); try testing.expect(diskRelativePath(&buf, &.{"a\\b"}) == null); var tiny: [4]u8 = undefined; try testing.expect(diskRelativePath(&tiny, &.{"toolong.html"}) == null); } test "dev-mode disk reads refuse a symlink that escapes the root" { const io = testing.io; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); var root = try tmp.dir.createDirPathOpen(io, "root", .{}); defer root.close(io); try root.writeFile(io, .{ .sub_path = "inside.txt", .data = "ok" }); try tmp.dir.writeFile(io, .{ .sub_path = "outside.txt", .data = "secret" }); try root.symLink(io, "../outside.txt", "escape.txt", .{}); try root.symLink(io, "..", "updir", .{ .is_directory = true }); try testing.expect(resolvesUnderRoot(root, io, "inside.txt")); try testing.expect(!resolvesUnderRoot(root, io, "escape.txt")); // A symlinked directory escapes through an intermediate component, which // no-follow on the final open would miss. try testing.expect(!resolvesUnderRoot(root, io, "updir/outside.txt")); try testing.expect(!resolvesUnderRoot(root, io, "missing.txt")); } test "the embedded dist has an index and consistent gzip siblings" { try testing.expect(embedded.len > 0); const index = find(embedded, index_path).?; try testing.expectEqualStrings("text/html; charset=utf-8", index.content_type); try testing.expect(index.bytes.len > 0); for (embedded) |file| { try testing.expect(file.etag.len >= 3); try testing.expectEqual(@as(u8, '"'), file.etag[0]); try testing.expectEqual(@as(u8, '"'), file.etag[file.etag.len - 1]); var occurrences: usize = 0; for (embedded) |other| { if (std.mem.eql(u8, other.path, file.path)) occurrences += 1; } try testing.expectEqual(@as(usize, 1), occurrences); if (std.mem.endsWith(u8, file.path, ".gz")) { const base = find(embedded, file.path[0 .. file.path.len - 3]).?; try testing.expect(file.bytes.len < base.bytes.len); // The gzip member header: build-time compression, not an accident. try testing.expectEqual(@as(u8, 0x1f), file.bytes[0]); try testing.expectEqual(@as(u8, 0x8b), file.bytes[1]); // A sibling is served under its base file's identity, so equal // content is the only thing that makes the substitution honest. var input: std.Io.Reader = .fixed(file.bytes); const window = try testing.allocator.alloc(u8, std.compress.flate.max_window_len); defer testing.allocator.free(window); var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window); const plain = try decompress.reader.allocRemaining( testing.allocator, .limited(max_disk_asset_bytes), ); defer testing.allocator.free(plain); try testing.expectEqualSlices(u8, base.bytes, plain); } } } test "embedded entries agree with the dev-mode content type map" { for (embedded) |file| { const base = if (std.mem.endsWith(u8, file.path, ".gz")) file.path[0 .. file.path.len - 3] else file.path; try testing.expectEqualStrings(contentType(base), file.content_type); } } test "every embedded etag is a quoted 32-digit hash" { for (embedded) |file| { try testing.expectEqual(@as(usize, 34), file.etag.len); try testing.expectEqual(@as(u8, '"'), file.etag[0]); try testing.expectEqual(@as(u8, '"'), file.etag[33]); for (file.etag[1..33]) |c| try testing.expect(std.ascii.isHex(c)); } }