diff options
Diffstat (limited to 'web/httpfs.zig')
| -rw-r--r-- | web/httpfs.zig | 583 |
1 files changed, 583 insertions, 0 deletions
diff --git a/web/httpfs.zig b/web/httpfs.zig new file mode 100644 index 0000000..fa783e5 --- /dev/null +++ b/web/httpfs.zig @@ -0,0 +1,583 @@ +//! An HTTP view of the upstream 9P tree under `/fs/<path>`, served through the +//! shared `Mux` at the fid level. It is file operations, not verbs: HTTP +//! methods map onto the tree. +//! +//! GET /fs/<file> bytes (Content-Type sniffed; Range honored) +//! GET /fs/<file>?follow=1 server-sent events, one per blocking read +//! GET /fs/<dir> JSON array of entries (HTML with Accept: text/html) +//! HEAD /fs/<path> stat as headers +//! PUT /fs/<file> write (create if missing; truncate unless ?append=1) +//! PUT /fs/<dir>/ create a directory +//! DELETE /fs/<path> remove +//! +//! A 9P Rerror becomes 404/403/409/500 with the ename in the body. Two +//! caller-owned scratch buffers are used: `big` (>= msize) for read/dir/body +//! data, `small` (>= a few KiB) for control replies and response framing. +const std = @import("std"); +const c9 = @import("cloud9"); +const wire = c9.wire; +const Server = std.http.Server; +const Status = std.http.Status; +const Request = c9.Client.Request; + +const max_components = 64; +const name_bytes = 8192; + +pub fn handle(m: anytype, request: *Server.Request, path: []const u8, big: []u8, small: []u8) void { + const M = @TypeOf(m.*); + var fs: FS(M) = .{ .m = m, .request = request, .big = big, .small = small }; + fs.serve(path) catch |err| switch (err) { + error.Handled => {}, + error.NotFound => fs.fail(.not_found, "No such file or directory\n"), + error.BadPath => fs.fail(.bad_request, "Invalid path\n"), + error.Upstream, error.Disconnected => fs.fail(.bad_gateway, "9P upstream unavailable\n"), + else => fs.fail(.internal_server_error, "Internal error\n"), + }; + fs.cleanup(); +} + +fn FS(comptime M: type) type { + return struct { + const Self = @This(); + + m: *M, + request: *Server.Request, + big: []u8, + small: []u8, + root_fid: ?u32 = null, + leaf_fid: ?u32 = null, + responded: bool = false, + + fn rpcSmall(self: *Self, req: Request) !wire.Decoded { + var r: M.Rpc = .{ .buf = self.small }; + return self.m.rpc(req, &r); + } + + fn fail(self: *Self, status: Status, text: []const u8) void { + if (self.responded) return; + self.responded = true; + self.request.respond(text, .{ .status = status, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "text/plain; charset=utf-8" }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "x-content-type-options", .value = "nosniff" }, + } }) catch {}; + } + + /// A 9P Rerror: map to a status and answer, then stop the request. + fn rerr(self: *Self, ename: []const u8) anyerror { + var buf: [160]u8 = undefined; + const body = std.fmt.bufPrint(&buf, "{s}\n", .{ename[0..@min(ename.len, 128)]}) catch "error\n"; + self.fail(mapError(ename), body); + return error.Handled; + } + + fn cleanup(self: *Self) void { + if (self.leaf_fid) |f| { + _ = self.rpcSmall(.{ .clunk = .{ .fid = f } }) catch {}; + self.leaf_fid = null; + } + if (self.root_fid) |f| { + _ = self.rpcSmall(.{ .clunk = .{ .fid = f } }) catch {}; + self.root_fid = null; + } + } + + // -- session helpers ---------------------------------------------- + + fn attach(self: *Self) !void { + if (self.root_fid != null) return; + const fid = self.m.takeFid() orelse return error.Upstream; + const got = try self.rpcSmall(.{ .attach = .{ .fid = fid, .uname = self.m.user, .aname = self.m.tree } }); + if (got.msg == .rerror) { + self.m.dropFid(fid); + return self.rerr(got.msg.rerror.ename); + } + if (got.msg != .rattach) { + self.m.dropFid(fid); + return error.Upstream; + } + self.root_fid = fid; + } + + /// Walks from the root to `names`, leaving `leaf_fid` on the target and + /// returning its stat. A missing component is `error.NotFound`. + fn walkTo(self: *Self, names: [][]const u8) !wire.Stat { + try self.attach(); + if (self.leaf_fid) |old| { + _ = self.rpcSmall(.{ .clunk = .{ .fid = old } }) catch {}; + self.leaf_fid = null; + } + const leaf = self.m.takeFid() orelse return error.Upstream; + var start: usize = 0; + var src = self.root_fid.?; + var first = true; + while (first or start < names.len) { + const batch = names[start..@min(start + 16, names.len)]; + const got = try self.rpcSmall(.{ .walk = .{ .fid = src, .newfid = leaf, .names = batch } }); + if (got.msg == .rerror) { + self.m.dropFid(leaf); + return self.rerr(got.msg.rerror.ename); + } + if (got.msg != .rwalk or got.msg.rwalk.nwqid != batch.len) { + self.m.dropFid(leaf); // short walk: newfid was not bound + return error.NotFound; + } + self.leaf_fid = leaf; + src = leaf; + start += batch.len; + first = false; + if (batch.len == 0) break; + } + const st = try self.rpcSmall(.{ .stat = .{ .fid = leaf } }); + if (st.msg == .rerror) return self.rerr(st.msg.rerror.ename); + if (st.msg != .rstat) return error.Upstream; + return st.msg.rstat.stat; + } + + // -- dispatch ------------------------------------------------------ + + fn serve(self: *Self, path: []const u8) !void { + const rel = path[3..]; // after "/fs" + const trailing = rel.len != 0 and rel[rel.len - 1] == '/'; + var storage: [name_bytes]u8 = undefined; + var names: [max_components][]const u8 = undefined; + const count = try decodePath(rel, &storage, &names); + const comps = names[0..count]; + + switch (self.request.head.method) { + .GET => try self.get(comps), + .HEAD => try self.head(comps), + .PUT => if (trailing) try self.mkdir(comps) else try self.put(comps), + .DELETE => try self.delete(comps), + else => self.fail(.method_not_allowed, "Method not allowed\n"), + } + } + + fn head(self: *Self, comps: [][]const u8) !void { + const st = try self.walkTo(comps); + const dir = st.qid.type & c9.qtdir != 0; + var len_buf: [24]u8 = undefined; + const clen = std.fmt.bufPrint(&len_buf, "{d}", .{st.length}) catch "0"; + var qid_buf: [48]u8 = undefined; + const qid = std.fmt.bufPrint(&qid_buf, "{d}.{d}.{d}", .{ st.qid.type, st.qid.version, st.qid.path }) catch ""; + var mode_buf: [16]u8 = undefined; + const mode = std.fmt.bufPrint(&mode_buf, "{o}", .{st.mode}) catch ""; + var mtime_buf: [16]u8 = undefined; + const mtime = std.fmt.bufPrint(&mtime_buf, "{d}", .{st.mtime}) catch ""; + self.responded = true; + self.request.respond("", .{ .status = .ok, .keep_alive = false, .transfer_encoding = .none, .extra_headers = &.{ + .{ .name = "content-type", .value = if (dir) "application/json" else "application/octet-stream" }, + .{ .name = "content-length", .value = clen }, + .{ .name = "x-9p-qid", .value = qid }, + .{ .name = "x-9p-mode", .value = mode }, + .{ .name = "x-9p-mtime", .value = mtime }, + .{ .name = "accept-ranges", .value = "bytes" }, + .{ .name = "cache-control", .value = "no-store" }, + } }) catch {}; + } + + fn get(self: *Self, comps: [][]const u8) !void { + const follow = self.wantsFollow(); + const st = try self.walkTo(comps); + if (st.qid.type & c9.qtdir != 0) return self.listDir(st); + if (follow) return self.followFile(); + return self.getFile(st); + } + + // -- files --------------------------------------------------------- + + fn getFile(self: *Self, st: wire.Stat) !void { + const leaf = self.leaf_fid.?; + const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } }); + if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename); + if (opened.msg != .ropen) return error.Upstream; + + const range = self.parseRange(st.length); + const status: Status = if (range.partial) .partial_content else .ok; + + var offset: u64 = range.start; + const first = try self.readAt(leaf, offset, self.chunk()); + if (first.msg == .rerror) return self.rerr(first.msg.rerror.ename); + if (first.msg != .rread) return error.Upstream; + const ctype = sniff(first.msg.rread.data); + + var cr_buf: [64]u8 = undefined; + var headers: [4]std.http.Header = undefined; + var nh: usize = 0; + headers[nh] = .{ .name = "content-type", .value = ctype }; + nh += 1; + headers[nh] = .{ .name = "accept-ranges", .value = "bytes" }; + nh += 1; + headers[nh] = .{ .name = "cache-control", .value = "no-store" }; + nh += 1; + if (range.partial) { + headers[nh] = .{ .name = "content-range", .value = std.fmt.bufPrint(&cr_buf, "bytes {d}-{d}/{d}", .{ range.start, range.end - 1, st.length }) catch return error.Upstream }; + nh += 1; + } + + self.responded = true; + var bw = try self.request.respondStreaming(self.small, .{ + .respond_options = .{ .status = status, .keep_alive = false, .extra_headers = headers[0..nh] }, + }); + // A synthetic/control file reports length 0 but streams content, so + // clamp to the length only for an explicit Range. + const limit: u64 = if (range.partial) range.end - range.start else std.math.maxInt(u64); + var sent: u64 = 0; + var data = first.msg.rread.data; + while (true) { + const take = @min(@as(u64, data.len), limit - sent); + try bw.writer.writeAll(data[0..@intCast(take)]); + sent += take; + if (sent >= limit or data.len == 0) break; + offset += data.len; + const r = try self.readAt(leaf, offset, self.chunk()); + if (r.msg != .rread) break; + data = r.msg.rread.data; + if (data.len == 0) break; + } + try bw.end(); + } + + fn followFile(self: *Self) !void { + const leaf = self.leaf_fid.?; + const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } }); + if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename); + if (opened.msg != .ropen) return error.Upstream; + + self.responded = true; + var bw = try self.request.respondStreaming(self.small, .{ + .respond_options = .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream; charset=utf-8" }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "x-content-type-options", .value = "nosniff" }, + } }, + }); + bw.writer.writeAll(": follow\n\n") catch return; + flushBody(&bw) catch return; + var offset: u64 = 0; + while (true) { + var r: M.Rpc = .{ .buf = self.big }; + // On client disconnect / deadline this rpc is cancelled; the + // mux Tflushes the parked upstream read before returning. + const got = self.m.rpc(.{ .read = .{ .fid = leaf, .offset = offset, .count = self.chunk() } }, &r) catch return; + if (got.msg != .rread) return; + const data = got.msg.rread.data; + if (data.len == 0) { + bw.writer.writeAll("event: eof\ndata:\n\n") catch return; + flushBody(&bw) catch return; + return; + } + offset += data.len; + self.writeSse(&bw, data) catch return; + flushBody(&bw) catch return; + } + } + + /// Drains the body writer's buffer as a chunk, then flushes to the peer. + fn flushBody(bw: anytype) !void { + try bw.writer.flush(); + try bw.flush(); + } + + fn writeSse(self: *Self, bw: anytype, data: []const u8) !void { + _ = self; + var it = std.mem.splitScalar(u8, std.mem.trimEnd(u8, data, "\n"), '\n'); + while (it.next()) |line| { + try bw.writer.writeAll("data: "); + try bw.writer.writeAll(line); + try bw.writer.writeByte('\n'); + } + try bw.writer.writeByte('\n'); + } + + // -- directories --------------------------------------------------- + + fn listDir(self: *Self, st: wire.Stat) !void { + const html = self.wantsHtml(); + const leaf = self.leaf_fid.?; + const opened = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.oread } }); + if (opened.msg == .rerror) return self.rerr(opened.msg.rerror.ename); + if (opened.msg != .ropen) return error.Upstream; + + // JSON/HTML is built into `big`; directory data is read into `small`. + var w: std.Io.Writer = .fixed(self.big); + if (html) { + w.writeAll("<!doctype html><meta charset=utf-8><title>") catch return error.Upstream; + writeHtml(&w, st.name); + w.writeAll("</title><ul>") catch return error.Upstream; + } else { + w.writeByte('[') catch return error.Upstream; + } + var offset: u64 = 0; + var first = true; + while (true) { + var r: M.Rpc = .{ .buf = self.small }; + const got = try self.m.rpc(.{ .read = .{ .fid = leaf, .offset = offset, .count = self.chunk() } }, &r); + if (got.msg != .rread) break; + const data = got.msg.rread.data; + if (data.len == 0) break; + offset += data.len; + var rest: []const u8 = data; + while (rest.len >= 2) { + const size: usize = @as(usize, std.mem.readInt(u16, rest[0..2], .little)) + 2; + if (size > rest.len) break; + const entry = c9.Stat.decode(rest[0..size]) catch break; + rest = rest[size..]; + if (html) { + w.writeAll("<li>") catch return error.Upstream; + writeHtml(&w, entry.name); + if (entry.qid.type & c9.qtdir != 0) w.writeByte('/') catch {}; + w.writeAll("</li>") catch return error.Upstream; + } else { + if (!first) w.writeByte(',') catch return error.Upstream; + first = false; + writeEntryJson(&w, entry) catch return error.Upstream; + } + } + } + if (html) w.writeAll("</ul>") catch return error.Upstream else w.writeByte(']') catch return error.Upstream; + self.responded = true; + self.request.respond(w.buffered(), .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = if (html) "text/html; charset=utf-8" else "application/json" }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "x-content-type-options", .value = "nosniff" }, + } }) catch {}; + } + + // -- writes -------------------------------------------------------- + + fn put(self: *Self, comps: [][]const u8) !void { + if (comps.len == 0) return error.BadPath; + const append = self.wantsAppend(); + const existed = self.tryWalk(comps) catch |err| switch (err) { + error.NotFound => false, + else => return err, + }; + var write_off: u64 = 0; + if (existed) { + const leaf = self.leaf_fid.?; + if (append) { + const st = try self.rpcSmall(.{ .stat = .{ .fid = leaf } }); + if (st.msg == .rstat) write_off = st.msg.rstat.stat.length; + const o = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.owrite } }); + if (o.msg == .rerror) return self.rerr(o.msg.rerror.ename); + if (o.msg != .ropen) return error.Upstream; + } else { + const o = try self.rpcSmall(.{ .open = .{ .fid = leaf, .mode = c9.owrite | c9.otrunc } }); + if (o.msg == .rerror) return self.rerr(o.msg.rerror.ename); + if (o.msg != .ropen) return error.Upstream; + } + } else { + _ = try self.walkTo(comps[0 .. comps.len - 1]); + const parent = self.leaf_fid.?; + const cr = try self.rpcSmall(.{ .create = .{ .fid = parent, .name = comps[comps.len - 1], .perm = 0o644, .mode = c9.owrite } }); + if (cr.msg == .rerror) return self.rerr(cr.msg.rerror.ename); + if (cr.msg != .rcreate) return error.Upstream; + } + const written = try self.streamBody(self.leaf_fid.?, write_off); + var buf: [48]u8 = undefined; + const body = std.fmt.bufPrint(&buf, "{{\"written\":{d}}}\n", .{written}) catch "{}\n"; + self.responded = true; + self.request.respond(body, .{ .status = if (existed) .ok else .created, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "cache-control", .value = "no-store" }, + } }) catch {}; + } + + fn mkdir(self: *Self, comps: [][]const u8) !void { + if (comps.len == 0) return error.BadPath; + _ = try self.walkTo(comps[0 .. comps.len - 1]); + const parent = self.leaf_fid.?; + const cr = try self.rpcSmall(.{ .create = .{ .fid = parent, .name = comps[comps.len - 1], .perm = c9.dmdir | 0o755, .mode = c9.oread } }); + if (cr.msg == .rerror) return self.rerr(cr.msg.rerror.ename); + if (cr.msg != .rcreate) return error.Upstream; + self.fail(.created, "created\n"); + } + + fn delete(self: *Self, comps: [][]const u8) !void { + if (comps.len == 0) return error.BadPath; + _ = try self.walkTo(comps); + const leaf = self.leaf_fid.?; + const rm = try self.rpcSmall(.{ .remove = .{ .fid = leaf } }); + self.m.dropFid(leaf); // Tremove clunks the fid upstream regardless + self.leaf_fid = null; + if (rm.msg == .rerror) return self.rerr(rm.msg.rerror.ename); + self.fail(.no_content, ""); + } + + /// walkTo that reports missing as `false` rather than an HTTP error. + fn tryWalk(self: *Self, comps: [][]const u8) !bool { + _ = self.walkTo(comps) catch |err| switch (err) { + error.NotFound => return false, + else => return err, + }; + return true; + } + + fn streamBody(self: *Self, fid: u32, start: u64) !u64 { + const body_buf = self.big[0..16384]; + const cbuf = self.big[16384..]; + const br = self.request.readerExpectContinue(body_buf) catch return error.Upstream; + const maxw = @min(self.m.negotiatedMsize() -| 23, cbuf.len); + var offset: u64 = start; + var total: u64 = 0; + while (true) { + const n = br.readSliceShort(cbuf[0..maxw]) catch return error.Upstream; + if (n == 0) break; + var done: usize = 0; + while (done < n) { + const w = try self.rpcSmall(.{ .write = .{ .fid = fid, .offset = offset + done, .data = cbuf[done..n] } }); + if (w.msg == .rerror) return self.rerr(w.msg.rerror.ename); + if (w.msg != .rwrite or w.msg.rwrite.count == 0) return error.Upstream; + done += w.msg.rwrite.count; + } + offset += n; + total += n; + } + return total; + } + + // -- small helpers ------------------------------------------------- + + fn readAt(self: *Self, fid: u32, offset: u64, count: u32) !wire.Decoded { + var r: M.Rpc = .{ .buf = self.big }; + return self.m.rpc(.{ .read = .{ .fid = fid, .offset = offset, .count = count } }, &r); + } + + fn chunk(self: *Self) u32 { + const ms = self.m.negotiatedMsize(); + return if (ms > 11) ms - 11 else 0; + } + + const Range = struct { start: u64, end: u64, partial: bool }; + fn parseRange(self: *Self, length: u64) Range { + var it = self.request.iterateHeaders(); + while (it.next()) |h| { + if (!std.ascii.eqlIgnoreCase(h.name, "range")) continue; + if (!std.mem.startsWith(u8, h.value, "bytes=")) break; + const spec = h.value[6..]; + const dash = std.mem.indexOfScalar(u8, spec, '-') orelse break; + const start = std.fmt.parseInt(u64, spec[0..dash], 10) catch break; + var end: u64 = length; + if (dash + 1 < spec.len) { + const e = std.fmt.parseInt(u64, spec[dash + 1 ..], 10) catch break; + end = @min(e + 1, length); + } + if (start >= length or start >= end) break; + return .{ .start = start, .end = end, .partial = true }; + } + return .{ .start = 0, .end = length, .partial = false }; + } + + fn wantsFollow(self: *Self) bool { + if (std.mem.indexOf(u8, self.request.head.target, "follow=1") != null) return true; + return self.acceptHas("text/event-stream"); + } + fn wantsHtml(self: *Self) bool { + return self.acceptHas("text/html"); + } + fn wantsAppend(self: *Self) bool { + return std.mem.indexOf(u8, self.request.head.target, "append=1") != null; + } + fn acceptHas(self: *Self, what: []const u8) bool { + var it = self.request.iterateHeaders(); + while (it.next()) |h| { + if (std.ascii.eqlIgnoreCase(h.name, "accept") and std.mem.indexOf(u8, h.value, what) != null) return true; + } + return false; + } + }; +} + +fn mapError(ename: []const u8) Status { + if (contains(ename, "No such file") or contains(ename, "does not exist") or contains(ename, "not found") or contains(ename, "unknown")) return .not_found; + if (contains(ename, "permission") or contains(ename, "not permitted") or contains(ename, "denied")) return .forbidden; + if (contains(ename, "exists")) return .conflict; + if (contains(ename, "not empty") or contains(ename, "in use") or contains(ename, "Is a directory") or contains(ename, "Not a directory")) return .conflict; + return .internal_server_error; +} + +fn contains(haystack: []const u8, needle: []const u8) bool { + return std.mem.indexOf(u8, haystack, needle) != null; +} + +fn sniff(data: []const u8) []const u8 { + const n = @min(data.len, 1024); + for (data[0..n]) |b| if (b == 0) return "application/octet-stream"; + return "text/plain; charset=utf-8"; +} + +/// Decodes a `/fs`-relative path into non-empty, percent-decoded components. +fn decodePath(rel: []const u8, storage: []u8, names: *[max_components][]const u8) !usize { + var out: usize = 0; + var count: usize = 0; + var it = std.mem.splitScalar(u8, rel, '/'); + while (it.next()) |raw| { + if (raw.len == 0) continue; + var i: usize = 0; + const begin = out; + while (i < raw.len) { + const ch = raw[i]; + if (ch == '%') { + if (i + 2 >= raw.len) return error.BadPath; + const hi = hex(raw[i + 1]) orelse return error.BadPath; + const lo = hex(raw[i + 2]) orelse return error.BadPath; + const byte = (hi << 4) | lo; + if (byte == 0 or byte == '/') return error.BadPath; + if (out >= storage.len) return error.BadPath; + storage[out] = byte; + out += 1; + i += 3; + } else { + if (out >= storage.len) return error.BadPath; + storage[out] = ch; + out += 1; + i += 1; + } + } + const comp = storage[begin..out]; + if (std.mem.eql(u8, comp, ".")) { + out = begin; + continue; + } + if (std.mem.eql(u8, comp, "..")) return error.BadPath; + if (count >= max_components) return error.BadPath; + names[count] = comp; + count += 1; + } + return count; +} + +fn hex(c: u8) ?u8 { + return switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; +} + +fn writeHtml(w: *std.Io.Writer, text: []const u8) void { + for (text) |c| switch (c) { + '<' => w.writeAll("<") catch {}, + '>' => w.writeAll(">") catch {}, + '&' => w.writeAll("&") catch {}, + '"' => w.writeAll(""") catch {}, + else => w.writeByte(c) catch {}, + }; +} + +fn writeEntryJson(w: *std.Io.Writer, e: wire.Stat) !void { + const dir = e.qid.type & c9.qtdir != 0; + try w.writeAll("{\"name\":"); + try std.json.Stringify.value(e.name, .{}, w); + try w.print(",\"dir\":{s},\"length\":{d},\"mode\":{d},\"mtime\":{d},\"qid\":{{\"type\":{d},\"version\":{d},\"path\":{d}}}}}", .{ + if (dir) "true" else "false", + e.length, + e.mode, + e.mtime, + e.qid.type, + e.qid.version, + e.qid.path, + }); +} |
