diff --git a/build.zig b/build.zig index 934c5d1..5eb4f30 100644 --- a/build.zig +++ b/build.zig @@ -77,14 +77,6 @@ pub fn build(b: *std.Build) !void { exe.root_module.addImport("fern", fern.module("fern")); mlua_exe.root_module.addImport("fern", fern.module("fern")); - const httpz = b.dependency("httpz", .{ - .target = target, - .optimize = optimize, - }); - - mod.addImport("httpz", httpz.module("httpz")); - exe.root_module.addImport("httpz", httpz.module("httpz")); - // exe ==================================================================== b.installArtifact(exe); diff --git a/build.zig.zon b/build.zig.zon index bacc8b8..eec4753 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -27,10 +27,6 @@ // supported use case. .minimum_zig_version = "0.16.0", .dependencies = .{ - .httpz = .{ - .url = "git+https://github.com/karlseguin/http.zig?ref=master#b0c3479b58f999f014862c35922eea1a56e5677a", - .hash = "httpz-0.0.0-PNVzrDC4BwANG-9UeHJ5udYPc19jZpm6LbkAtlAElOBk", - }, .fern = .{ .url = "git+https://git.koi.rip/koi/fern.git#0ffc3e4421010daad5e18272f2f82249def58694", .hash = "fern-0.0.0-iF4X_ZB5AACpf_yE820FAV3m3OFVsQfUro0Z3fk4qqzS", diff --git a/justfile b/justfile index 9e74885..3180f77 100644 --- a/justfile +++ b/justfile @@ -17,6 +17,12 @@ test: test-build: @just zig-build run -- build +[env("MAIVI_ROOT", test_dir)] +[env("MAIVI_REV", "test")] +[env("MAIVI_LOG", "debug")] +test-run: + @just zig-build run -- + # zig ========================================================================= [private] diff --git a/nix/deps.nix b/nix/deps.nix index 374ea94..d72aa4a 100644 --- a/nix/deps.nix +++ b/nix/deps.nix @@ -4,35 +4,6 @@ fetchzip, }: linkFarm "zig-packages" [ - # httpz ====================================================================== - - { - name = "httpz-0.0.0-PNVzrDC4BwANG-9UeHJ5udYPc19jZpm6LbkAtlAElOBk"; - path = fetchgit { - url = "https://github.com/karlseguin/http.zig.git"; - rev = "b0c3479b58f999f014862c35922eea1a56e5677a"; - hash = "sha256-NOmO1wtVgw7+tJk049zEIZyo2jv683//jAz6PALx2fI="; - }; - } - - { - name = "metrics-0.0.0-W7G4eIegAQD4XxA9Co7Atbw59u_2zvxYf406AZuoAHPM"; - path = fetchgit { - url = "https://github.com/karlseguin/metrics.zig.git"; - rev = "6de29b83a750a06c438d268543e0e3c3c1b309da"; - hash = "sha256-F0tnpGmcPtNIff+i9z9ZVWttLvAKQNXQzVAKnsSFV+o="; - }; - } - - { - name = "websocket-0.1.0-ZPISdUU6BAAPe0iZ_JHMVAXaBlz327xZRBrRY06-Vw5h"; - path = fetchgit { - url = "https://github.com/karlseguin/websocket.zig.git"; - rev = "3be6210f53297fb4b458d88562047ff4d69629a3"; - hash = "sha256-KcOI77ckcUsnFUrPs6qzETR2aQk8iMAj8QKYzdvCZIo="; - }; - } - # fern ======================================================================= { diff --git a/src/gen.zig b/src/gen.zig index 7d8d8e5..37274ff 100644 --- a/src/gen.zig +++ b/src/gen.zig @@ -29,31 +29,26 @@ pub fn generate() !void { }; const buildroot = try Io.Dir.openDirAbsolute(maivi.io, maivi.config.buildroot, .{ .access_sub_paths = true }); - var it = maivi.router.paths.iterator(); + var it = maivi.router.luapaths.iterator(); while (true) { const entry = it.next() orelse break; const path = entry.key_ptr.*; + const data = entry.value_ptr.*; fern.debug().ctx("gen.generate").msg("generating path").str("path", path).log(); - genpath(io, buildroot, path) catch |err| { + genpath(io, buildroot, path, data) catch |err| { fern.err().ctx("gen.generate") .msg("could not generate path") .str("path", path) .err(err) .log(); continue; - } orelse { - fern.err().ctx("gen.generate") - .msg("could not get router data") - .str("path", path) - .log(); - continue; }; } } -fn genpath(io: Io, dir: Io.Dir, path: []const u8) !?void { +fn genpath(io: Io, dir: Io.Dir, path: []const u8, data: maivi.Router.LuaPath) !void { const outpath = blk: { const outpath_base = if (path.len == 1) "." else path[1..]; var it = std.mem.splitBackwardsScalar(u8, path, '/'); @@ -72,7 +67,7 @@ fn genpath(io: Io, dir: Io.Dir, path: []const u8) !?void { var buf: [0x1000]u8 = undefined; var writer = file.writer(io, &buf); - try maivi.router.get(maivi.lua.lua, &writer.interface, path) orelse return null; + try maivi.Router.get(maivi.lua.lua, &writer.interface, data); } // helpers ==================================================================== diff --git a/src/handler.zig b/src/handler.zig index 73c368a..77b9f1e 100644 --- a/src/handler.zig +++ b/src/handler.zig @@ -1,108 +1,104 @@ const std = @import("std"); const maivi = @import("root.zig"); +const http = std.http; +const Request = http.Server.Request; +const Status = http.Status; const fern = @import("fern"); -const httpz = @import("httpz"); - const helpers = @import("helpers.zig"); -const Status = helpers.Status; const lua = maivi.lua; const Self = @This(); -const SanitizationError = error{NoTrailingSlashes} || std.mem.Allocator.Error; -fn sanitize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - var buf = try allocator.alloc(u8, path.len); +pub fn task(io: std.Io, stream: std.Io.net.Stream) error{Canceled}!void { + defer stream.close(io); + + var recv_buffer: [0x1000]u8 = undefined; + var send_buffer: [0x1000]u8 = undefined; + + var connection_reader = stream.reader(io, &recv_buffer); + var connection_writer = stream.writer(io, &send_buffer); - @memcpy(buf, path); - // replace double slash (//) with single slash (/) - buf = std.mem.collapseRepeats(u8, buf, '/'); + var server: std.http.Server = .init(&connection_reader.interface, &connection_writer.interface); - // redirect: no-trailing-slashes - if (path.len > 1 and std.mem.endsWith(u8, path, "/")) return error.NoTrailingSlashes; + var request = server.receiveHead() catch return; - return buf; + const ret = blk: { + const path = helpers.sanitize(maivi.allocator, request.head.target) catch |err| switch (err) { + error.TrailingSlash => return helpers.redirect(&request, request.head.target[0 .. request.head.target.len - 1], .temporary_redirect) catch |e| break :blk e, + else => break :blk err, + }; + defer maivi.allocator.free(path); + dispatch(.{ + .request = &request, + .handle = stream.socket.handle, + .path = path, + }) catch |err| break :blk err; + }; + ret catch |e| { + fern.err().err(e).log(); + return error.Canceled; + }; } -pub const Action = helpers.Action(*Self); -pub fn dispatch(self: *Self, action: Action, req: *httpz.Request, res: *httpz.Response) !void { - var arena = std.heap.ArenaAllocator.init(maivi.allocator); - defer arena.deinit(); - const allocator = arena.allocator(); +pub const Connection = struct { + request: *Request, + handle: std.Io.net.Socket.Handle, + path: []const u8, +}; - var request = try helpers.Request.init(allocator, req, null); - var response = helpers.Response.init(res); +pub fn dispatch(conn: Connection) !void { + const request = conn.request; - fern.debug().ctx("handler.dispatch") - .int("id", request.id) + var l = fern.debug().ctx("handler.dispatch") .msg("request") - .fmt("method", "{}", .{req.method}) - .str("path", request.path).log(); - var l = fern.debug().ctx("handler.dispatch").int("id", request.id); - var it = req.headers.iterator(); - while (it.next()) |header| l = l.str(header.key, header.value); + .fmt("method", "{}", .{request.head.method}) + .str("path", request.head.target); + var it = request.iterateHeaders(); + while (it.next()) |header| l = l.str(header.name, header.value); l.log(); const timer = std.Io.Timestamp.now(maivi.io, .cpu_thread); defer { const elapsed = std.Io.Timestamp.durationTo(timer, std.Io.Timestamp.now(maivi.io, .cpu_thread)).toMilliseconds(); - l = (switch (res.status) { - 400...499 => fern.warn(), - else => fern.debug(), - }).ctx("handler.dispatch") + fern.debug().ctx("handler.dispatch") .msg("response") - .int("id", request.id) - .int("status", res.status) - .int("elapsed", elapsed); - - if (res.content_type) |content_type| { - l = l.fmt("content-type", "{}", .{content_type}); - } - - it = res.headers.iterator(); - while (it.next()) |header| l = l.str(header.key, header.value); - - l.log(); + .str("path", request.head.target) + .int("elapsed", elapsed).log(); } - const new_path = sanitize(allocator, request.path) catch |err| switch (err) { - error.NoTrailingSlashes => return response.redirect(request.path[0 .. request.path.len - 1], .PermanentRedirect), - else => return err, - }; - try request.set_path(new_path); - - action(self, &request, &response) catch |err| { - fern.err() - .err(err) - .ctx("handler.dispatch") - .msg("error encountered while handling request") - .int("id", request.id) - .fmt("method", "{}", .{req.method}) - .str("path", request.path) - .log(); - res.status = 500; + maivi.handle(conn) catch |e| switch (e) { + error.FileNotFound => try returnNotFound(conn), + else => try returnInternalErr(request, e), }; } -pub fn returnNotFound(h: *Self, req: *helpers.Request, res: *helpers.Response) !void { +const NOTFOUND_MSG = "path not found :("; +const INTERNALERR_MSG = "internal server error x_x"; + +fn returnNotFound(conn: Connection) !void { + const request = conn.request; + const dir = try std.Io.Dir.openDirAbsolute(maivi.io, maivi.config.buildroot, .{}); - res.set_file(dir, "/404") catch |err| switch (err) { + helpers.sendfile(conn, dir, "/404") catch |err| switch (err) { error.IsDirectory => { - try res.set_file(dir, "/404/index.html"); + try helpers.sendfile(conn, dir, "/404/index.html"); }, error.FileNotFound => { - return try h.notFound(req.request, res.response); - }, - else => { - const status = helpers.statusFromErr(err) orelse return err; - res.status(status); + try request.respond(NOTFOUND_MSG, .{ + .status = .not_found, + }); }, + else => return returnInternalErr(request, err), }; } -pub fn notFound(_: *Self, _: *httpz.Request, res: *httpz.Response) !void { - res.status = @intFromEnum(Status.NotFound); - res.content_type = .TEXT; +fn returnInternalErr(request: *Request, err: anyerror) !void { + fern.err().err(err).log(); + const status = helpers.statusFromErr(err) orelse return err; + try request.respond(INTERNALERR_MSG, .{ + .status = status, + }); } diff --git a/src/helpers.zig b/src/helpers.zig index 7e4d8b8..2df6415 100644 --- a/src/helpers.zig +++ b/src/helpers.zig @@ -1,194 +1,335 @@ const std = @import("std"); -const maivi = @import("root.zig"); +const http = std.http; +const Request = http.Server.Request; +const Status = http.Status; const fern = @import("fern"); -const httpz = @import("httpz"); - const Handler = @import("handler.zig"); +const maivi = @import("root.zig"); -pub const Request = struct { - request: *httpz.Request, - path: []u8, - id: u64, - allocator: std.mem.Allocator, - - const Self = @This(); - - pub fn init(allocator: std.mem.Allocator, req: *httpz.Request, path: ?[]u8) !Self { - return .{ - .request = req, - .path = path orelse blk: { - const buf = try allocator.alloc(u8, req.url.path.len); - @memcpy(buf, req.url.path); - break :blk buf; - }, - .id = @intCast(std.Io.Timestamp.now(maivi.io, .real).toMicroseconds()), - .allocator = allocator, - }; - } +pub fn requestHeader(request: Request, name: []const u8) ?http.Header { + var it = request.iterateHeaders(); + while (it.next()) |header| + if (std.mem.eql(u8, header.name, name)) return header; + return null; +} - pub fn set_path(self: *Self, path: []const u8) !void { - self.path = try self.allocator.alloc(u8, path.len); - @memcpy(self.path, path); - } +const SERVER_HEADER: http.Header = .{ + .name = "server", + .value = "maivi", }; -pub const Status = enum(u16) { - OK = 200, - Forbidden = 403, - NotFound = 404, - MethodNotAllowed = 405, - // This response code means that the URI of requested resource has been - // changed temporarily. Further changes in the URI might be made in the - // future, so the same URI should be used by the client in future requests. - Found = 302, - // The server sends this response to direct the client to get the requested resource at another URI with the same method that was used in the prior request. This has the same semantics as the 302 Found response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the redirected request. - TemporaryRedirect = 307, - // This means that the resource is now permanently located at another URI, - // specified by the Location response header. This has the same semantics - // as the 301 Moved Permanently HTTP response code, with the exception - // that the user agent must not change the HTTP method used: if a POST was - // used in the first request, a POST must be used in the second request. - PermanentRedirect = 308, -}; +pub fn respond(request: *Request, buf: []u8, opts: struct { + content_length: ?u64 = null, + content_type: ContentType = .octetstream, + status: http.Status = .ok, + reason: ?[]const u8 = null, + headers: []const http.Header = &.{}, +}) !http.BodyWriter { + const headers = try std.mem.concat(maivi.allocator, http.Header, &.{ + &.{SERVER_HEADER}, + &.{opts.content_type.header()}, + opts.headers, + }); + defer maivi.allocator.free(headers); -pub const Response = struct { - response: *httpz.Response, + return try request.respondStreaming(buf, .{ + .content_length = opts.content_length, + .respond_options = .{ + .status = opts.status, + .reason = opts.reason, + .extra_headers = headers, + }, + }); +} - const Self = @This(); +// u usually want to use sendfile(2) to directly stream other FDs +// luckily zig std is awesome and already implements everything +// b.writer.sendFileAll(file_reader: *Reader, .unlimited); +// later on u probably wanna make it chunked too, see +// std.http.BodyWriter.chunkedSendFile +// for reference :3 - pub fn init(res: *httpz.Response) Self { - return .{ - .response = res, - }; - } +pub fn sendfile(conn: Handler.Connection, dir: std.Io.Dir, path: []const u8) !void { + const request = conn.request; - // status ===================================================================== - pub fn status(self: *Self, s: Status) void { - self.response.status = @intFromEnum(s); - } + const sub_path = std.mem.cutPrefix(u8, path, "/") orelse path; + const file = try dir.openFile(maivi.io, sub_path, .{}); - pub fn ok(self: *Self) void { - self.status(.OK); - } + const stat = try file.stat(maivi.io); + if (stat.kind == .directory) return error.IsDirectory; + if (stat.kind != .file) return error.NonFile; + const size = stat.size; + defer file.close(maivi.io); - // body ======================================================================= + const content_type = content_type_for_file(path) orelse .txt; - pub fn set_file(self: *Self, dir: std.Io.Dir, path: []const u8) !void { - const buf = try read_file(self.response.arena, dir, path); - errdefer self.response.arena.free(buf); + var wbuf: [0x0]u8 = undefined; + var b = try maivi.helpers.respond(request, &wbuf, .{ + .content_length = size, + .content_type = content_type, + }); + try b.flush(); - self.response.content_type = content_type_for_file(path); - var writer = self.response.writer(); - _ = try writer.write(buf); - try writer.flush(); + var remaining = size; + var offset: i64 = 0; + while (remaining > 0) { + const count: usize = @intCast(@min(remaining, 0x7ffff000)); // ~2GiB + const ret: isize = @intCast(std.os.linux.sendfile(conn.handle, file.handle, &offset, count)); + defer remaining -= @intCast(ret); + switch (ret) { + 0 => return error.UnexpectedEndOfFile, + -1 => return error.SendFileFailed, + else => {}, + } } - pub fn redirect(self: *Self, uri: []const u8, code: ?Status) !void { - const s = code orelse Status.Found; - self.response.status = @intFromEnum(s); - const uri_buf = try self.response.arena.dupe(u8, uri); - self.response.headers.add("location", uri_buf); - } -}; + b.state = .end; +} -pub fn Action(comptime ActionContext: type) type { - if (ActionContext == void) { - return *const fn (*Request, *Response) anyerror!void; - } - return *const fn (ActionContext, *Request, *Response) anyerror!void; +pub fn redirect(request: *Request, uri: []const u8, code: ?Status) !void { + const headers = try std.mem.concat(maivi.allocator, http.Header, &.{ + &.{SERVER_HEADER}, + &.{.{ .name = "location", .value = uri }}, + }); + defer maivi.allocator.free(headers); + + try request.respond(uri, .{ + .status = code orelse Status.found, + .extra_headers = headers, + }); } pub fn statusFromErr(err: anyerror) ?Status { return switch (err) { - error.FileNotFound => .NotFound, - error.AccessDenied => .Forbidden, + error.FileNotFound => Status.not_found, + error.AccessDenied => Status.forbidden, else => null, }; } -pub fn content_type_for_file(path: []const u8) ?httpz.ContentType { - return forExtension(std.fs.path.extension(path)); +pub fn content_type_for_file(path: []const u8) ?ContentType { + return .forExtension(std.fs.path.extension(path)); } -/// source: https://github.com/karlseguin/http.zig/blob/5d1b4e2e3e5a30aa489bdb33321dae83897f2426/src/httpz.zig#L86 -fn forExtension(ext: []const u8) ?httpz.ContentType { - if (ext.len == 0) return null; - const temp = if (ext[0] == '.') ext[1..] else ext; - if (temp.len == 0) return null; - if (temp.len > 5) return .UNKNOWN; +pub const ContentType = enum { + @"3g2", + @"3gp", + @"7z", + aac, + abw, + apng, + arc, + avi, + avif, + azw, + bin, + bmp, + bz, + bz2, + cda, + csh, + css, + csv, + doc, + docx, + eot, + epub, + gif, + gz, + htm, + html, + ico, + ics, + jar, + jpeg, + jpg, + js, + json, + jsonld, + m4a, + md, + midi, + mid, + mjs, + mp3, + mp4, + mpeg, + mpkg, + octetstream, + odp, + ods, + odt, + oga, + ogv, + ogx, + opus, + otf, + pdf, + php, + png, + ppt, + pptx, + rar, + rtf, + sh, + svg, + tar, + tiff, + tif, + tkt, + ts, + ttf, + txt, + vsd, + wasm, + wav, + weba, + webm, + webmanifest, + webp, + woff, + woff2, + xhtml, + xls, + xlsx, + xml, + xul, + zip, - var normalized: [5]u8 = undefined; - for (temp, 0..) |c, i| { - normalized[i] = std.ascii.toLower(c); + pub fn header(self: ContentType) http.Header { + return .{ + .name = "content-type", + .value = self.toHeader(), + }; } - switch (temp.len) { - 2 => { - switch (@as(u16, @bitCast(normalized[0..2].*))) { - asUint("js") => return .JS, - asUint("gz") => return .GZ, - else => return .UNKNOWN, - } - }, - 3 => { - switch (@as(u24, @bitCast(normalized[0..3].*))) { - asUint("css") => return .CSS, - asUint("csv") => return .CSV, - asUint("eot") => return .EOT, - asUint("gif") => return .GIF, - asUint("htm") => return .HTML, - asUint("ico") => return .ICO, - asUint("jpg") => return .JPG, - asUint("otf") => return .OTF, - asUint("pdf") => return .PDF, - asUint("png") => return .PNG, - asUint("svg") => return .SVG, - asUint("tar") => return .TAR, - asUint("ttf") => return .TTF, - asUint("txt") => return .TEXT, - asUint("xml") => return .XML, - else => return .UNKNOWN, - } - }, - 4 => { - switch (@as(u32, @bitCast(normalized[0..4].*))) { - asUint("jpeg") => return .JPG, - asUint("json") => return .JSON, - asUint("html") => return .HTML, - asUint("text") => return .TEXT, - asUint("wasm") => return .WASM, - asUint("woff") => return .WOFF, - asUint("webp") => return .WEBP, - else => return .UNKNOWN, - } - }, - 5 => { - switch (@as(u40, @bitCast(normalized[0..5].*))) { - asUint("woff2") => return .WOFF2, - else => return .UNKNOWN, - } - }, - else => return .UNKNOWN, + fn toHeader(self: ContentType) []const u8 { + return switch (self) { + .@"3g2" => "audio/3gpp2", + .@"3gp" => "audio/3gpp", + .@"7z" => "application/x-7z-compressed", + .aac => "audio/aac", + .abw => "application/x-abiword", + .apng => "image/apng", + .arc => "application/x-freearc", + .avi => "video/x-msvideo", + .avif => "image/avif", + .azw => "application/vnd.amazon.ebook", + .bin => "application/octet-stream", + .bmp => "image/bmp", + .bz => "application/x-bzip", + .bz2 => "application/x-bzip2", + .cda => "application/x-cdf", + .csh => "application/x-csh", + .css => "text/css", + .csv => "text/csv", + .doc => "application/msword", + .docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + .eot => "application/vnd.ms-fontobject", + .epub => "application/epub+zip", + .gif => "image/gif", + .gz => "application/x-gzip", + .htm, .html => "text/html", + .ico => "image/vnd.microsoft.icon", + .ics => "text/calendar", + .jar => "application/java-archive", + .jpeg, .jpg => "image/jpeg", + .js => "text/javascript", + .json => "application/json", + .jsonld => "application/ld+json", + .m4a => "audio/mp4", + .md => "text/markdown", + .mid, .midi => "audio/x-midi", + .mjs => "text/javascript", + .mp3 => "audio/mpeg", + .mp4 => "video/mp4", + .mpeg => "video/mpeg", + .mpkg => "application/vnd.apple.installer+xml", + .octetstream => "application/octet-stream", + .odp => "application/vnd.oasis.opendocument.presentation", + .ods => "application/vnd.oasis.opendocument.spreadsheet", + .odt => "application/vnd.oasis.opendocument.text", + .oga => "audio/ogg", + .ogv => "video/ogg", + .ogx => "application/ogg", + .opus => "audio/ogg", + .otf => "font/otf", + .pdf => "application/pdf", + .php => "application/x-httpd-php", + .png => "image/png", + .ppt => "application/vnd.ms-powerpoint", + .pptx => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + .rar => "application/vnd.rar", + .rtf => "application/rtf", + .sh => "application/x-sh", + .svg => "image/svg+xml", + .tar => "application/x-tar", + .tif, .tiff => "image/tiff", + .tkt => "text/trinket", + .ts => "video/mp2t", + .ttf => "font/ttf", + .txt => "text/plain", + .vsd => "application/vnd.visio", + .wasm => "text/wasm", + .wav => "audio/wav", + .weba => "audio/webm", + .webm => "video/webm", + .webmanifest => "application/manifest+json", + .webp => "image/webp", + .woff => "font/woff", + .woff2 => "font/woff2", + .xhtml => "application/xhtml+xml", + .xls => "application/vnd.ms-excel", + .xlsx => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + .xml => "application/xml", + .xul => "application/vnd.mozilla.xul+xml", + .zip => "application/x-zip-compressed", + }; } - return .UNKNOWN; -} -/// source: https://github.com/karlseguin/http.zig/blob/5d1b4e2e3e5a30aa489bdb33321dae83897f2426/src/url.zig#L134 -/// converts ascii to unsigned int of appropriate size -pub fn asUint(comptime string: anytype) std.meta.Int( - .unsigned, - @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0 -) { - const byteLength = @sizeOf(@TypeOf(string.*)) - 1; - const expectedType = *const [byteLength:0]u8; - if (@TypeOf(string) != expectedType) { - @compileError("expected : " ++ @typeName(expectedType) ++ ", got: " ++ @typeName(@TypeOf(string))); + pub fn forExtension(ext: []const u8) ?ContentType { + if (ext.len == 0) return null; + const temp = if (ext[0] == '.') ext[1..] else ext; + if (temp.len == 0) return null; + if (temp.len > 5) return null; + + var normalized: [5]u8 = undefined; + for (temp, 0..) |c, i| { + normalized[i] = std.ascii.toLower(c); + } + + return ContentType.map.get(normalized[0..temp.len]); } - return @bitCast(@as(*const [byteLength]u8, string).*); -} + const map = std.StaticStringMap(@This()).initComptime(.{ + .{ "css", .css }, + .{ "csv", .csv }, + .{ "eot", .eot }, + .{ "gif", .gif }, + .{ "gz", .gz }, + .{ "htm", .html }, + .{ "html", .html }, + .{ "ico", .ico }, + .{ "jpeg", .jpg }, + .{ "jpg", .jpg }, + .{ "js", .js }, + .{ "json", .json }, + .{ "otf", .otf }, + .{ "pdf", .pdf }, + .{ "png", .png }, + .{ "svg", .svg }, + .{ "tar", .tar }, + .{ "ttf", .ttf }, + .{ "txt", .txt }, + .{ "wasm", .wasm }, + .{ "webp", .webp }, + .{ "woff", .woff }, + .{ "woff2", .woff2 }, + .{ "xml", .xml }, + }); +}; pub fn read_file(allocator: std.mem.Allocator, dir: std.Io.Dir, path: []const u8) ![]const u8 { fern.debug().ctx("helpers.read_file").str("path", path).log(); @@ -209,3 +350,14 @@ pub fn read_file(allocator: std.mem.Allocator, dir: std.Io.Dir, path: []const u8 return contents; } + +const SanitizationError = error{NoTrailingSlashes} || std.mem.Allocator.Error; +pub fn sanitize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + // redirect: no-trailing-slashes + if (path.len > 1 and std.mem.endsWith(u8, path, "/")) return error.TrailingSlash; + + var buf = try allocator.dupe(u8, path); + + // replace double slash (//) with single slash (/) + return buf[0..std.mem.collapseRepeatsLen(u8, buf, '/')]; +} diff --git a/src/main.zig b/src/main.zig index c798d7a..1f840b5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -30,22 +30,15 @@ pub fn main(init: std.process.Init) !void { try maivi.init(init.io, init.gpa, &config); defer maivi.deinit(); - if (maivi.build) { + if (maivi.luaruntime) try maivi.lua.setup(); - return maivi.gen.generate(); - } - - var handler: Handler = .{}; - try maivi.server(&handler, .{ - .address = .all(maivi.config.port), - }); - - if (maivi.luaruntime) try maivi.lua.setup(); + if (maivi.build) + return maivi.gen.generate(); - try maivi.router.add("/*", maivi.handle, .{}); try maivi.add_redirects(); + try maivi.server(); try maivi.listen(); } diff --git a/src/root.zig b/src/root.zig index 9c395c4..3c7c374 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,14 +1,16 @@ const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; +const http = std.http; +const Request = http.Server.Request; +const Server = std.Io.net.Server; const fern = @import("fern"); -const httpz = @import("httpz"); pub const Config = @import("config.zig"); pub const data = @import("data.zig"); pub const gen = @import("gen.zig"); -pub const Handler = @import("handler.zig"); +pub const handler = @import("handler.zig"); pub const helpers = @import("helpers.zig"); pub const lua = @import("lua/root.zig"); pub const Router = @import("router.zig"); @@ -21,16 +23,14 @@ pub var rev: ?[]const u8 = null; // null indicates a dev build pub var config: *const Config = undefined; -pub const Server = httpz.Server(*Handler); -pub const ServerRouter = httpz.Router(*Handler, Handler.Action); +var addr: std.Io.net.IpAddress = undefined; pub var nserver: ?Server = null; -pub var nrouter: ?*ServerRouter = null; +var closed = false; pub var router: Router = undefined; // used by redirects var arena: std.heap.ArenaAllocator = undefined; - var redirects: std.StringHashMap([]const u8) = undefined; // allocator ================================================================== @@ -89,24 +89,12 @@ pub fn deinit() void { } if (nserver == null) return; - nserver.?.deinit(); + nserver.?.deinit(io); } -pub fn server(handler_ptr: *Handler, cfg: httpz.Config) !void { - if (nserver != null) return error.ServerAlreadyInitialized; - - nserver = try Server.init( - io, - allocator, - cfg, - handler_ptr, - ); - if (nserver == null) return error.ServerIsNull; - nrouter = try nserver.?.router(.{}); - if (nrouter) |r| - router.router = r - else - return error.RouterIsNull; +pub fn server() !void { + addr = try .resolve(io, "127.0.0.1", config.port); + nserver = try addr.listen(io, .{ .reuse_address = true }); } fn shutdown(signo: std.os.linux.SIG) callconv(.c) void { @@ -114,8 +102,8 @@ fn shutdown(signo: std.os.linux.SIG) callconv(.c) void { .fmt("sig", "{}", .{signo}).log(); if (nserver == null) return; - var srv = nserver.?; - if (srv._listener != null) srv.stop(); + closed = true; + std.process.exit(130); } pub fn listen() !void { @@ -138,99 +126,115 @@ pub fn listen() !void { .flags = 0, }, null); - try nserver.?.listen(); + var group: std.Io.Group = .init; + defer group.cancel(io); + + while (true) { + if (closed) break; + const stream = try nserver.?.accept(io); + try group.concurrent(io, handler.task, .{ io, stream }); + } } // handlers =================================================================== +// try: +// with(router) $uri +// else: +// with(build) $uri $uri/index.html +// with(static) $uri +pub fn handle(conn: handler.Connection) !void { + const request = conn.request; + + const user_agent = helpers.requestHeader(request.*, "user-agent"); + if (user_agent) |ua| if (std.mem.startsWith(u8, ua.value, "curl")) { + return try handletxt(conn); + }; + + const path = router.paths.get(request.head.target) orelse return handle_fs(conn); + + blk: { + for (path.config.allowed_methods) |method| + if (request.head.method == method) break :blk; + + const status: http.Status = .method_not_allowed; + return request.respond("", .{ + .status = status, + }); + } + + try path.action(request); + + // TODO: pre-register all static & build paths + // if (dev) + // try handle_fs(request); +} + pub const MESSAGE_PATH = "/index.txt"; -pub fn handle(h: *Handler, req: *helpers.Request, res: *helpers.Response) !void { - if (req.request.method != .GET) { - res.status(.MethodNotAllowed); - return; +fn handle_fs(conn: handler.Connection) !void { + const request = conn.request; + + const build_dir = try std.Io.Dir.openDirAbsolute(io, config.buildroot, .{}); + defer build_dir.close(io); + + try_build: { + return helpers.sendfile(conn, build_dir, request.head.target) catch |err| switch (err) { + error.FileNotFound => break :try_build, + else => return err, + }; } - const user_agent = req.request.header("user-agent"); - if (user_agent) |ua| if (std.mem.startsWith(u8, ua, "curl")) { - return try handletxt(h, req, res); - }; + try_build_index_html: { + if (std.mem.eql(u8, request.head.target, "/")) { + return helpers.sendfile(conn, build_dir, "/index.html") catch |err| switch (err) { + error.FileNotFound => break :try_build_index_html, + else => return err, + }; + } - var it = std.mem.splitBackwardsScalar(u8, req.path, '/'); - if (!std.mem.containsAtLeast(u8, it.first(), 1, ".")) { - return handlebuild(h, req, res, true) catch |err| switch (err) { - error.FileNotFound => { - return h.returnNotFound(req, res); - }, - else => { - const status = helpers.statusFromErr(err) orelse return err; - res.status(status); - }, + const path = try std.mem.join(allocator, "/", &.{ request.head.target, "index.html" }); + defer allocator.free(path); + return helpers.sendfile(conn, build_dir, path) catch |err| switch (err) { + error.FileNotFound => break :try_build_index_html, + else => return err, }; } - try_build: { - handlebuild(h, req, res, false) catch |err| switch (err) { - error.FileNotFound => break :try_build, - else => { - const status = helpers.statusFromErr(err) orelse return err; - res.status(status); - return; - }, + const static_dir = try std.Io.Dir.openDirAbsolute(io, config.staticroot, .{}); + defer static_dir.close(io); + + try_static: { + return helpers.sendfile(conn, static_dir, request.head.target) catch |err| switch (err) { + error.FileNotFound => break :try_static, + else => return err, }; } - fern.debug().ctx("maivi.handle") - .msg("using fs") - .int("id", req.id) - .str("path", req.path).log(); - - const dir = try std.Io.Dir.openDirAbsolute(io, config.staticroot, .{}); - res.set_file(dir, req.path) catch |err| switch (err) { - error.FileNotFound => { - return h.notFound(req.request, res.response); - }, - else => { - const status = helpers.statusFromErr(err) orelse return err; - res.status(status); - return; - }, - }; + return error.FileNotFound; } -fn handlebuild(_: *Handler, req: *helpers.Request, res: *helpers.Response, transform_path: bool) !void { - const new_path = if (transform_path) try transform_dir_path(req) else req.path; - const dir = try std.Io.Dir.openDirAbsolute(io, config.buildroot, .{}); - return try res.set_file(dir, new_path); -} +pub fn handletxt(conn: handler.Connection) !void { + const request = conn.request; -pub fn handletxt(h: *Handler, req: *helpers.Request, res: *helpers.Response) !void { - if (req.path.len == 1) { + if (request.head.target.len == 1) { fern.debug().ctx("maivi.handletxt") - .int("id", req.id) .msg("redirecting to message path") .str("path", MESSAGE_PATH) .log(); - return res.redirect(MESSAGE_PATH, null); + return helpers.redirect(request, MESSAGE_PATH, null); } - const mdpath = try std.mem.concat(allocator, u8, &.{ "data", req.path, ".md" }); + const mdpath = try std.mem.concat(allocator, u8, &.{ request.head.target[0 .. request.head.target.len - 1], ".md" }); defer allocator.free(mdpath); fern.debug().ctx("maivi.handletxt") - .int("id", req.id) .str("path", mdpath) .log(); - const dir = try std.Io.Dir.openDirAbsolute(io, config.root, .{}); - return res.set_file(dir, mdpath) catch |err| switch (err) { - error.FileNotFound => { - return h.notFound(req.request, res.response); - }, - else => { - const status = helpers.statusFromErr(err) orelse return err; - res.status(status); - }, - }; + const dataroot = + try std.mem.join(allocator, "/", &.{ config.root, "data" }); + const dir = try std.Io.Dir.openDirAbsolute(io, dataroot, .{}); + return try helpers.sendfile(conn, dir, mdpath); } // redirects ================================================================== @@ -262,15 +266,8 @@ pub fn add_redirects() !void { } } -fn handleredirect(h: *Handler, req: *helpers.Request, res: *helpers.Response) !void { - const redirect_url = redirects.get(req.path) orelse - return h.returnNotFound(req, res); - try res.redirect(redirect_url, null); -} - -// helpers ==================================================================== - -fn transform_dir_path(req: *helpers.Request) ![]const u8 { - if (std.mem.eql(u8, req.path, "/")) return "/index.html"; - return try std.mem.join(req.request.arena, "/", &.{ req.path, "/index.html" }); +fn handleredirect(request: *Request) !void { + const redirect_url = redirects.get(request.head.target) orelse + return error.FileNotFound; + try helpers.redirect(request, redirect_url, null); } diff --git a/src/router.zig b/src/router.zig index f9ff265..276b88e 100644 --- a/src/router.zig +++ b/src/router.zig @@ -1,44 +1,56 @@ const std = @import("std"); -const maivi = @import("root.zig"); +const http = std.http; +const Request = http.Server.Request; const fern = @import("fern"); - +const httpz = @import("httpz"); const zlua = @import("zlua"); const Lua = zlua.Lua; -const httpz = @import("httpz"); - const fragment = @import("fragment.zig"); +const helpers = @import("helpers.zig"); +const maivi = @import("root.zig"); const Self = @This(); -const Path = struct { +pub const Path = struct { + action: Action, + config: Config, + + pub fn init(action: Action, cfg: ?Config) Path { + return .{ .action = action, .config = cfg orelse .{} }; + } + + pub const Action = *const fn (request: *Request) anyerror!void; + pub const Config = struct { + allowed_methods: []const http.Method = &.{.GET}, + }; + // pub const Error = error{} || Request.ExpectContinueError; +}; + +pub const LuaPath = struct { f: i32, props: i32, }; -router: ?*maivi.ServerRouter, paths: std.hash_map.StringHashMap(Path), +luapaths: std.hash_map.StringHashMap(LuaPath), pub fn init() Self { return .{ - .router = null, .paths = .init(maivi.allocator), + .luapaths = .init(maivi.allocator), }; } pub fn deinit(self: *Self) void { self.paths.deinit(); + self.luapaths.deinit(); } -pub const RouteConfig = @TypeOf(maivi.ServerRouter.routeConfig(maivi.nrouter.?.*, .{})); -pub fn add(self: *Self, path: []const u8, f: maivi.Handler.Action, cfg: RouteConfig) !void { +pub fn add(self: *Self, path: []const u8, action: Path.Action, cfg: ?Path.Config) !void { fern.debug().ctx("router").msg("adding router path").str("path", path).log(); - if (self.router) |router| - return try router.tryGet(path, f, cfg); - - if (maivi.build) return; - return error.RouterIsNull; + return try self.paths.put(path, .init(action, cfg)); } // lua ======================================================================== @@ -78,10 +90,10 @@ fn lua_add(l: *Lua) !i32 { break :blk l.ref(zlua.registry_index); }; - try maivi.router.add(path, action, .{}); + try maivi.router.add(path, handle_lua, .{}); - if (maivi.router.paths.contains(path)) return error.Clobber; - try maivi.router.paths.put(path, .{ + if (maivi.router.luapaths.contains(path)) return error.Clobber; + try maivi.router.luapaths.put(path, .{ .f = f, .props = props, }); @@ -92,30 +104,20 @@ fn lua_add(l: *Lua) !i32 { // TODO: // - set content // - caching? -pub fn action(h: *maivi.Handler, req: *maivi.helpers.Request, res: *maivi.helpers.Response) !void { - if (req.request.method != .GET) { - res.status(.MethodNotAllowed); - return; - } +pub fn handle_lua(request: *Request) !void { + const content_type = helpers.content_type_for_file(request.head.target) orelse .html; - const user_agent = req.request.header("user-agent"); - if (user_agent) |ua| if (std.mem.startsWith(u8, ua, "curl")) { - return try maivi.handletxt(h, req, res); - }; - - handle(h, req, res) catch |err| { - fern.err().err(err).log(); - res.response.status = 500; - }; -} + var wbuf: [0x4000]u8 = undefined; + var b = try maivi.helpers.respond(request, &wbuf, .{ + // .content_length = size, + .content_type = content_type, + }); -pub fn handle(h: *maivi.Handler, req: *maivi.helpers.Request, res: *maivi.helpers.Response) !void { - try lhandle(maivi.lua.lua, req, res) orelse return h.notFound(req.request, res.response); + const data = maivi.router.luapaths.get(request.head.target) orelse return error.FileNotFound; + try get(maivi.lua.lua, &b.writer, data); } -pub fn get(self: *Self, l: *Lua, writer: *std.Io.Writer, path: []const u8) !?void { - const data = self.paths.get(path) orelse return null; - +pub fn get(l: *Lua, writer: *std.Io.Writer, data: LuaPath) !void { // call handler function _ = l.getIndexRaw(zlua.registry_index, data.f); if (!l.isFunction(-1)) return error.LuaValueNotAFunction; @@ -158,9 +160,3 @@ pub fn get(self: *Self, l: *Lua, writer: *std.Io.Writer, path: []const u8) !?voi try fragment.render(l, writer); } - -fn lhandle(l: *Lua, req: *maivi.helpers.Request, res: *maivi.helpers.Response) !?void { - const writer = res.response.writer(); - try maivi.router.get(l, writer, req.path) orelse return null; - res.response.content_type = maivi.helpers.content_type_for_file(req.path) orelse .HTML; -}