//! Static file application and range-aware file serving.
//! Paths resolve beneath the configured directory and reject symlinks by default.
const std = @import("std");
const builtin = @import("builtin");
const applications = @import("../applications.zig");
const core = @import("core.zig");
const header_utils = @import("../headers.zig");
const Context = applications.Context;
const HeaderField = applications.HeaderField;
const Method = applications.Method;
const Request = applications.Request;
const Response = applications.Response;
const ResponsePayload = applications.ResponsePayload;
const Route = applications.Route;
const Status = applications.Status;
const StatusHandlerContext = applications.StatusHandlerContext;
const ZAPI = applications.ZAPI;
const testing = std.testing;
const ownedRedirectLocationHeader = header_utils.ownedRedirectLocation;
const StaticPath = core.StaticPath;
const ByteRangeDecision = core.ByteRangeDecision;
const responseFileContentType = core.responseFileContentType;
const responseFileHeaders = core.responseFileHeaders;
const statFileSize = core.statFileSize;
const fileResponseEtag = core.fileResponseEtag;
const freeOwnedHeaders = core.freeOwnedHeaders;
const requestNotModified = core.requestNotModified;
const requestByteRange = core.requestByteRange;
const applyByteRange = core.applyByteRange;
const multipartByteRangesLength = core.multipartByteRangesLength;
const multipartByteRangesBodyAlloc = core.multipartByteRangesBodyAlloc;
const httpDateAlloc = core.httpDateAlloc;
const timestampSeconds = core.timestampSeconds;
const staticFileTargetPath = core.staticFileTargetPath;
const staticContentType = core.staticContentType;
const multipart_range_boundary = core.multipart_range_boundary;
pub const StaticFilesOptions = struct {
dir: std.Io.Dir = .cwd(),
html: bool = false,
follow_symlinks: bool = false,
max_size: std.Io.Limit = .limited(16 * 1024 * 1024),
io: ?std.Io = null,
};
const StaticFilesState = struct {
dir: std.Io.Dir,
html: bool,
follow_symlinks: bool,
max_size: std.Io.Limit,
};
pub const StaticFiles = struct {
app: ZAPI,
state: *StaticFilesState,
pub fn init(allocator: std.mem.Allocator, options: StaticFilesOptions) !StaticFiles {
const state = try allocator.create(StaticFilesState);
errdefer allocator.destroy(state);
state.* = .{
.dir = options.dir,
.html = options.html,
.follow_symlinks = options.follow_symlinks,
.max_size = options.max_size,
};
var app = ZAPI.init(allocator, .{
.openapi_url = null,
.docs_url = null,
.oauth2_redirect_url = null,
.redoc_url = null,
.io = options.io,
});
errdefer app.deinit();
app.setState(state);
try app.addStatusHandler(.method_not_allowed, staticMethodNotAllowed);
try app.route(Route.methods("/", &.{ .GET, .HEAD }, staticFileRoot, .{}));
try app.route(Route.methods("/{path:path}", &.{ .GET, .HEAD }, staticFilePath, .{}));
return .{
.app = app,
.state = state,
};
}
pub fn deinit(self: *StaticFiles) void {
const allocator = self.app.allocator;
self.app.deinit();
allocator.destroy(self.state);
}
};
fn staticFileRoot(ctx: *Context) !ResponsePayload {
return serveStaticFile(ctx, "");
}
fn staticFilePath(ctx: *Context) !ResponsePayload {
return serveStaticFile(ctx, ctx.pathValue("path") orelse "");
}
fn serveStaticFile(ctx: *Context, raw_path: []const u8) !ResponsePayload {
const state = ctx.state(StaticFilesState);
const io = ctx.io orelse return error.MissingIo;
const target = staticFileTargetPath(ctx.allocator, raw_path, state.html) catch return staticNotFound(ctx, state, io);
defer if (target.owned) ctx.allocator.free(target.value);
return serveStaticFileTarget(ctx, state, io, target.value, null) catch |err| switch (err) {
error.IsDir => {
if (try staticHtmlDirectoryHasIndex(ctx, state, io, raw_path)) {
return staticDirectoryRedirect(ctx);
}
return staticNotFound(ctx, state, io);
},
error.AccessDenied, error.PermissionDenied => return staticUnauthorized(),
error.FileNotFound, error.NotDir, error.SymLinkLoop => return staticNotFound(ctx, state, io),
else => return err,
};
}
fn serveStaticFileTarget(ctx: *Context, state: *StaticFilesState, io: std.Io, path: []const u8, status_override: ?Status) !ResponsePayload {
var file = try state.dir.openFile(io, path, .{
.allow_directory = false,
.follow_symlinks = state.follow_symlinks,
.resolve_beneath = true,
});
defer file.close(io);
const stat = try file.stat(io);
if (stat.kind != .file) return error.IsDir;
const full_len = try statFileSize(stat);
const last_modified_seconds = timestampSeconds(stat.mtime);
const last_modified = try httpDateAlloc(ctx.allocator, last_modified_seconds);
defer ctx.allocator.free(last_modified);
const etag = try fileResponseEtag(ctx.allocator, stat);
defer ctx.allocator.free(etag);
const conditional = status_override == null;
const not_modified = conditional and requestNotModified(ctx.request, etag, last_modified_seconds);
var range_decision = if (conditional and !not_modified) try requestByteRange(ctx.allocator, ctx.request, etag, last_modified_seconds, full_len) else ByteRangeDecision.none;
defer range_decision.deinit(ctx.allocator);
switch (range_decision) {
.malformed => |message| return .{
.status = .bad_request,
.content_type = "text/plain; charset=utf-8",
.body = message,
},
else => {},
}
const status: ?Status = if (not_modified) .not_modified else switch (range_decision) {
.none => status_override,
.partial => .partial_content,
.multiple => .partial_content,
.unsatisfiable => .requested_range_not_satisfiable,
.malformed => unreachable,
};
const file_content_type = staticContentType(path);
const response_content_type = responseFileContentType(file_content_type, range_decision);
var body: []const u8 = "";
var owned_body = false;
const body_len = switch (range_decision) {
.none => if (not_modified or ctx.request.method == .HEAD) full_len else full_len,
.partial => |range| range.len(),
.multiple => |ranges| multipartByteRangesLength(ranges, multipart_range_boundary, file_content_type, full_len),
.unsatisfiable => 0,
.malformed => unreachable,
};
if (!not_modified and ctx.request.method != .HEAD and range_decision != .unsatisfiable) {
var file_reader = file.reader(io, &.{});
var file_body = try file_reader.interface.allocRemaining(ctx.allocator, state.max_size);
errdefer ctx.allocator.free(file_body);
switch (range_decision) {
.multiple => |ranges| {
const multipart_body = try multipartByteRangesBodyAlloc(ctx.allocator, file_body, ranges, multipart_range_boundary, file_content_type, full_len);
ctx.allocator.free(file_body);
body = multipart_body;
owned_body = true;
},
else => {
file_body = try applyByteRange(ctx.allocator, file_body, range_decision);
body = file_body;
owned_body = true;
},
}
}
const headers = try responseFileHeaders(ctx.allocator, &.{}, body_len, full_len, etag, last_modified, null, .attachment, status orelse .ok, range_decision);
errdefer freeOwnedHeaders(ctx.allocator, headers);
return .{
.status = status,
.content_type = response_content_type,
.headers = headers,
.owned_headers = true,
.body = body,
.owned_body = owned_body,
};
}
fn staticNotFound(ctx: *Context, state: *StaticFilesState, io: std.Io) !ResponsePayload {
if (state.html) {
if (serveStaticFileTarget(ctx, state, io, "404.html", .not_found)) |payload| {
return payload;
} else |err| switch (err) {
error.FileNotFound, error.IsDir, error.NotDir, error.AccessDenied, error.PermissionDenied => {},
else => return err,
}
}
return .{
.status = .not_found,
.content_type = "text/plain; charset=utf-8",
.body = "Not Found",
};
}
fn staticMethodNotAllowed(ctx: *StatusHandlerContext) !Response {
var response = Response.init(.method_not_allowed);
try response.setHeader(ctx.app.allocator, "content-type", "text/plain; charset=utf-8");
try response.body.appendSlice(ctx.app.allocator, "Method Not Allowed");
return response;
}
fn staticUnauthorized() ResponsePayload {
return .{
.status = .unauthorized,
.content_type = "text/plain; charset=utf-8",
.body = "Unauthorized",
};
}
fn staticHtmlDirectoryHasIndex(ctx: *Context, state: *StaticFilesState, io: std.Io, raw_path: []const u8) !bool {
if (!state.html or raw_path.len == 0 or std.mem.endsWith(u8, raw_path, "/")) return false;
const index_path = try std.fmt.allocPrint(ctx.allocator, "{s}/index.html", .{raw_path});
defer ctx.allocator.free(index_path);
_ = state.dir.statFile(io, index_path, .{}) catch |err| switch (err) {
error.FileNotFound, error.IsDir, error.NotDir, error.AccessDenied, error.PermissionDenied => return false,
else => return err,
};
return true;
}
fn staticDirectoryRedirect(ctx: *Context) !ResponsePayload {
var location = std.Io.Writer.Allocating.init(ctx.allocator);
defer location.deinit();
if (ctx.request.root_path.len > 0) try location.writer.writeAll(ctx.request.root_path);
try location.writer.writeAll(ctx.request.path);
try location.writer.writeAll("/");
if (ctx.request.query.len > 0) {
try location.writer.writeAll("?");
try location.writer.writeAll(ctx.request.query);
}
const headers = try ctx.allocator.alloc(HeaderField, 1);
errdefer ctx.allocator.free(headers);
headers[0] = try ownedRedirectLocationHeader(ctx.allocator, location.written());
return .{
.status = .temporary_redirect,
.content_type = "",
.headers = headers,
.owned_headers = true,
};
}
test "static files serve mounted assets and html indexes" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(testing.io, "assets");
try tmp.dir.createDirPath(testing.io, "empty");
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/site.css", .data = "body { color: black; }" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/data.json", .data = "{}" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/icon.SVG", .data = "" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/photo.jpeg", .data = "jpeg" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/preview.webp", .data = "webp" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/favicon.ico", .data = "ico" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/module.wasm", .data = "wasm" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/report.pdf", .data = "%PDF" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "index.html", .data = "
Home
" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/index.html", .data = "Assets
" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "404.html", .data = "Missing
" });
var static_files = try StaticFiles.init(testing.allocator, .{
.dir = tmp.dir,
.html = true,
});
defer static_files.deinit();
var metadata_static_files = try StaticFiles.init(testing.allocator, .{
.dir = tmp.dir,
.max_size = .limited(0),
});
defer metadata_static_files.deinit();
var parent = ZAPI.init(testing.allocator, .{ .io = testing.io });
defer parent.deinit();
try parent.mount("/static", &static_files.app);
try parent.mount("/metadata-static", &metadata_static_files.app);
var css = try parent.handle(Request.init(.GET, "/static/assets/site.css"));
defer css.deinit(testing.allocator);
try testing.expectEqual(Status.ok, css.status);
try testing.expectEqualStrings("text/css; charset=utf-8", css.header("content-type").?);
try testing.expectEqualStrings("22", css.header("content-length").?);
const css_etag = css.header("etag").?;
try testing.expectEqualStrings("bytes", css.header("accept-ranges").?);
const css_last_modified = css.header("last-modified").?;
try testing.expect(std.mem.endsWith(u8, css_last_modified, " GMT"));
try testing.expectEqualStrings("body { color: black; }", css.body.items);
var css_head = try parent.handle(Request.init(.HEAD, "/static/assets/site.css"));
defer css_head.deinit(testing.allocator);
try testing.expectEqual(Status.ok, css_head.status);
try testing.expectEqualStrings("text/css; charset=utf-8", css_head.header("content-type").?);
try testing.expectEqualStrings("22", css_head.header("content-length").?);
try testing.expectEqualStrings(css_etag, css_head.header("etag").?);
try testing.expectEqualStrings(css_last_modified, css_head.header("last-modified").?);
try testing.expectEqual(@as(usize, 0), css_head.body.items.len);
var metadata_head = try parent.handle(Request.init(.HEAD, "/metadata-static/assets/site.css"));
defer metadata_head.deinit(testing.allocator);
try testing.expectEqual(Status.ok, metadata_head.status);
try testing.expectEqualStrings("text/css; charset=utf-8", metadata_head.header("content-type").?);
try testing.expectEqualStrings("22", metadata_head.header("content-length").?);
try testing.expect(metadata_head.header("etag") != null);
try testing.expect(metadata_head.header("last-modified") != null);
try testing.expectEqual(@as(usize, 0), metadata_head.body.items.len);
var wrong_method = try parent.handle(Request.init(.POST, "/static/assets/site.css"));
defer wrong_method.deinit(testing.allocator);
try testing.expectEqual(Status.method_not_allowed, wrong_method.status);
try testing.expectEqualStrings("HEAD, GET, OPTIONS", wrong_method.header("allow").?);
try tmp.dir.writeFile(testing.io, .{ .sub_path = "assets/private.txt", .data = "secret" });
try tmp.dir.setFilePermissions(testing.io, "assets/private.txt", @fromBackingInt(@intCast(0o000)), .{});
defer tmp.dir.setFilePermissions(testing.io, "assets/private.txt", @fromBackingInt(@intCast(0o666)), .{}) catch {};
var private = try parent.handle(Request.init(.GET, "/static/assets/private.txt"));
defer private.deinit(testing.allocator);
try testing.expectEqual(Status.unauthorized, private.status);
try testing.expectEqualStrings("text/plain; charset=utf-8", private.header("content-type").?);
try testing.expectEqualStrings("Unauthorized", private.body.items);
const asset_content_types = &.{
.{ .path = "/static/assets/data.json", .content_type = "application/json" },
.{ .path = "/static/assets/icon.SVG", .content_type = "image/svg+xml" },
.{ .path = "/static/assets/photo.jpeg", .content_type = "image/jpeg" },
.{ .path = "/static/assets/preview.webp", .content_type = "image/webp" },
.{ .path = "/static/assets/favicon.ico", .content_type = "image/x-icon" },
.{ .path = "/static/assets/module.wasm", .content_type = "application/wasm" },
.{ .path = "/static/assets/report.pdf", .content_type = "application/pdf" },
};
inline for (asset_content_types) |case| {
var asset = try parent.handle(Request.init(.GET, case.path));
defer asset.deinit(testing.allocator);
try testing.expectEqual(Status.ok, asset.status);
try testing.expectEqualStrings(case.content_type, asset.header("content-type").?);
}
var conditional_req = Request.init(.GET, "/static/assets/site.css");
conditional_req.headers = &.{.{ .name = "if-none-match", .value = css_etag }};
var conditional = try parent.handle(conditional_req);
defer conditional.deinit(testing.allocator);
try testing.expectEqual(Status.not_modified, conditional.status);
try testing.expectEqualStrings(css_etag, conditional.header("etag").?);
try testing.expectEqualStrings(css_last_modified, conditional.header("last-modified").?);
try testing.expect(conditional.header("content-type") == null);
try testing.expect(conditional.header("content-length") == null);
try testing.expectEqual(@as(usize, 0), conditional.body.items.len);
var modified_since_req = Request.init(.GET, "/static/assets/site.css");
modified_since_req.headers = &.{.{ .name = "if-modified-since", .value = css_last_modified }};
var modified_since = try parent.handle(modified_since_req);
defer modified_since.deinit(testing.allocator);
try testing.expectEqual(Status.not_modified, modified_since.status);
try testing.expectEqualStrings(css_etag, modified_since.header("etag").?);
try testing.expectEqualStrings(css_last_modified, modified_since.header("last-modified").?);
try testing.expect(modified_since.header("content-type") == null);
try testing.expect(modified_since.header("content-length") == null);
try testing.expectEqual(@as(usize, 0), modified_since.body.items.len);
var range_req = Request.init(.GET, "/static/assets/site.css");
range_req.headers = &.{.{ .name = "range", .value = "bytes=0-3" }};
var range_response = try parent.handle(range_req);
defer range_response.deinit(testing.allocator);
try testing.expectEqual(Status.partial_content, range_response.status);
try testing.expectEqualStrings("bytes 0-3/22", range_response.header("content-range").?);
try testing.expectEqualStrings("4", range_response.header("content-length").?);
try testing.expectEqualStrings("body", range_response.body.items);
const expected_static_multipart =
"--zapi-boundary\r\n" ++
"Content-Type: text/css; charset=utf-8\r\n" ++
"Content-Range: bytes 0-3/22\r\n" ++
"\r\n" ++
"body\r\n" ++
"--zapi-boundary\r\n" ++
"Content-Type: text/css; charset=utf-8\r\n" ++
"Content-Range: bytes 14-18/22\r\n" ++
"\r\n" ++
"black\r\n" ++
"--zapi-boundary--";
var multipart_static_req = Request.init(.GET, "/static/assets/site.css");
multipart_static_req.headers = &.{.{ .name = "range", .value = "bytes=0-3,14-18" }};
var multipart_static = try parent.handle(multipart_static_req);
defer multipart_static.deinit(testing.allocator);
try testing.expectEqual(Status.partial_content, multipart_static.status);
try testing.expectEqualStrings("multipart/byteranges; boundary=zapi-boundary", multipart_static.header("content-type").?);
try testing.expect(multipart_static.header("content-range") == null);
try testing.expectEqualStrings("206", multipart_static.header("content-length").?);
try testing.expectEqualStrings(expected_static_multipart, multipart_static.body.items);
var malformed_range_req = Request.init(.GET, "/static/assets/site.css");
malformed_range_req.headers = &.{.{ .name = "range", .value = "items=0-3" }};
var malformed_range = try parent.handle(malformed_range_req);
defer malformed_range.deinit(testing.allocator);
try testing.expectEqual(Status.bad_request, malformed_range.status);
try testing.expectEqualStrings("text/plain; charset=utf-8", malformed_range.header("content-type").?);
try testing.expectEqualStrings("Only support bytes range", malformed_range.body.items);
var root_index = try parent.handle(Request.init(.GET, "/static"));
defer root_index.deinit(testing.allocator);
try testing.expectEqual(Status.ok, root_index.status);
try testing.expectEqualStrings("text/html; charset=utf-8", root_index.header("content-type").?);
try testing.expectEqualStrings("13", root_index.header("content-length").?);
try testing.expectEqualStrings("Home
", root_index.body.items);
var nested_index = try parent.handle(Request.init(.GET, "/static/assets/"));
defer nested_index.deinit(testing.allocator);
try testing.expectEqual(Status.ok, nested_index.status);
try testing.expectEqualStrings("15", nested_index.header("content-length").?);
try testing.expectEqualStrings("Assets
", nested_index.body.items);
var nested_redirect = try parent.handle(Request.init(.GET, "/static/assets?tab=main"));
defer nested_redirect.deinit(testing.allocator);
try testing.expectEqual(Status.temporary_redirect, nested_redirect.status);
try testing.expectEqualStrings("/static/assets/?tab=main", nested_redirect.header("location").?);
try testing.expectEqualStrings("0", nested_redirect.header("content-length").?);
try testing.expectEqual(@as(usize, 0), nested_redirect.body.items.len);
var missing_index = try parent.handle(Request.init(.GET, "/static/empty"));
defer missing_index.deinit(testing.allocator);
try testing.expectEqual(Status.not_found, missing_index.status);
try testing.expectEqualStrings("text/html; charset=utf-8", missing_index.header("content-type").?);
try testing.expectEqualStrings("16", missing_index.header("content-length").?);
try testing.expectEqualStrings("Missing
", missing_index.body.items);
var custom_missing = try parent.handle(Request.init(.GET, "/static/missing-page"));
defer custom_missing.deinit(testing.allocator);
try testing.expectEqual(Status.not_found, custom_missing.status);
try testing.expectEqualStrings("text/html; charset=utf-8", custom_missing.header("content-type").?);
try testing.expectEqualStrings("Missing
", custom_missing.body.items);
}
test "static files reject symlinks by default" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "secret.txt", .data = "secret" });
try tmp.dir.symLink(testing.io, "secret.txt", "public.txt", .{});
var static_files = try StaticFiles.init(testing.allocator, .{
.dir = tmp.dir,
.io = testing.io,
});
defer static_files.deinit();
var response = try static_files.app.handle(Request.init(.GET, "/public.txt"));
defer response.deinit(testing.allocator);
try testing.expectEqual(Status.not_found, response.status);
}
test "static files reject traversal malformed and missing paths" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "app.js", .data = "console.log('zapi');" });
try tmp.dir.writeFile(testing.io, .{ .sub_path = "space file.txt", .data = "space" });
var static_files = try StaticFiles.init(testing.allocator, .{
.dir = tmp.dir,
.io = testing.io,
});
defer static_files.deinit();
var ok = try static_files.app.handle(Request.init(.GET, "/app.js"));
defer ok.deinit(testing.allocator);
try testing.expectEqual(Status.ok, ok.status);
try testing.expectEqualStrings("text/javascript; charset=utf-8", ok.header("content-type").?);
try testing.expectEqualStrings("20", ok.header("content-length").?);
try testing.expectEqualStrings("console.log('zapi');", ok.body.items);
var encoded_space = try static_files.app.handle(Request.init(.GET, "/space%20file.txt"));
defer encoded_space.deinit(testing.allocator);
try testing.expectEqual(Status.ok, encoded_space.status);
try testing.expectEqualStrings("space", encoded_space.body.items);
const rejected_paths = [_][]const u8{
"/missing.js",
"/../secret.txt",
"/%2e%2e/secret.txt",
"/app%00.js",
"/bad%ZZ.js",
"/assets//site.css",
"/./app.js",
"/assets\\site.css",
};
for (rejected_paths) |path| {
var response = try static_files.app.handle(Request.init(.GET, path));
defer response.deinit(testing.allocator);
try testing.expectEqual(Status.not_found, response.status);
try testing.expectEqualStrings("Not Found", response.body.items);
}
}
test {
_ = core;
}