╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
2005 // the focusable owning each cell, among this panel's own (skipping any 2006 // that belong to a child scroll, whose cells are drawn in their own panel). 2007 // claimed in focus order, so the first focusable over a cell keeps it. 2008 // looking every focusable up for every cell is quadratic in a long list. 2009 const owners = try allocator.alloc(?usize, grid.size.width * grid.size.height); 2010 defer allocator.free(owners); 2011 @memset(owners, null); 2012 { 2013 var it = focus.children.iterator(); 2014 while (it.next()) |entry| { 2015 const child = entry.value_ptr.*; 2016 if (child.focus.mode != .all) continue; 2017 if (excluded.contains(entry.key_ptr.*)) continue; 2018 const r = child.rect; 2019 for (@min(r.y, grid.size.height)..@min(r.y + r.size.height, grid.size.height)) |y| { 2020 for (@min(r.x, grid.size.width)..@min(r.x + r.size.width, grid.size.width)) |x| { 2021 const owner = &owners[y * grid.size.width + x]; 2022 if (owner.* == null) owner.* = entry.key_ptr.*; 2023 } 2024 } 2025 } 2026 } 2027 2028 // the cells are the volatile part of a panel. js replaces this one subtree 2029 // wholesale while retaining the panel's native controls and scroll shells. 2030 try output.appendSlice(allocator, "<div class=\"grid-cells\">"); 2031 2032 // emit this panel's grid as rows of text, coalescing adjacent cells that 2033 // share a focus id and colors into one tag (a clickable span, a link, or a 2034 // plain colored span). cells under a child scroll keep only their background. 2035 for (0..grid.size.height) |y| { 2036 var cur_id: ?usize = null; 2037 var cur_fg: ?Grid.Color = null; 2038 var cur_bg: ?Grid.Color = null; 2039 var open_tag: ?CellTag = null; 2040 var first = true; 2041 for (0..grid.size.width) |x| { 2042 const cell = (try grid.cell(x, y)).*; 2043 // a cell covered by a child scroll's viewport is drawn by that scroll's 2044 // own div, so blank its glyph and make it non-clickable here — but keep 2045 // its background, so the backdrop still shows through the scroll's 2046 // transparent div instead of a bare hole. 2047 const covered_by_scroll = blk: { 2048 for (direct.items) |id| { 2049 const r = (focus.children.get(id) orelse continue).rect; 2050 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) break :blk true; 2051 } 2052 break :blk false; 2053 }; 2054 // the focusable owning the cell at (x, y); none for a covered cell. 2055 const cell_id = if (covered_by_scroll) null else owners[y * grid.size.width + x]; 2056 const fg = if (covered_by_scroll) null else cell.style.fg; 2057 const bg = cell.style.bg; 2058 2059 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 2060 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 2061 2062 var new_tag: ?CellTag = null; 2063 if (cell_id) |id| { 2064 if (focus.children.get(id)) |child| { 2065 new_tag = switch (child.focus.kind) { 2066 .custom => |custom| CellTag.init(custom), 2067 else => .span, 2068 }; 2069 } 2070 } 2071 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 2072 2073 if (new_tag) |t| { 2074 var style_buf: [64]u8 = undefined; 2075 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 2076 } 2077 2078 open_tag = new_tag; 2079 cur_id = cell_id; 2080 cur_fg = fg; 2081 cur_bg = bg; 2082 first = false; 2083 } 2084 2085 if (covered_by_scroll) { 2086 try appendEscapedHtml(allocator, output, " "); 2087 } else if (cell.continuation) { 2088 // the wide rune to the left was emitted 2ch wide, covering this column 2089 } else if (cell.rune) |rune| { 2090 var encoded: [4]u8 = undefined; 2091 const encoded_len = try std.unicode.utf8Encode(rune, &encoded); 2092 const rune_text = encoded[0..encoded_len]; 2093 // a double-width rune is pinned inside a 2ch span so the 2094 // fallback glyph's advance width can't shift the rest of the row 2095 const wide = x + 1 < grid.size.width and (try grid.cell(x + 1, y)).continuation; 2096 if (wide) { 2097 try output.appendSlice(allocator, "<span class=\"w2\">"); 2098 try appendEscapedHtml(allocator, output, rune_text); 2099 try output.appendSlice(allocator, "</span>"); 2100 } else { 2101 try appendEscapedHtml(allocator, output, rune_text); 2102 } 2103 } else { 2104 try appendEscapedHtml(allocator, output, " "); 2105 } 2106 } 2107 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 2108 try output.append(allocator, '\n'); 2109 } 2110 try output.appendSlice(allocator, "</div>"); 2111 2112 // native controls live in their panel, beside the replaceable cells. their 2113 // stable keys let JS update layout without recreating their DOM nodes, and 2114 // being actual descendants of a scroll gives them native movement/clipping. 2115 try renderPanelControls(allocator, output, focus, session, root_focus, &excluded); 2116 2117 // each child scroll becomes a natively-scrollable div positioned over its 2118 // viewport, holding its full content rendered recursively. 2119 for (direct.items) |id| { 2120 const child = focus.children.get(id) orelse continue; 2121 const info = child.focus.scroll orelse continue; 2122 const r = child.rect; 2123 // overflow only on the axes the widget scrolls; `auto` shows a bar only 2124 // when that axis actually overflows. the other axis is clipped (hidden), 2125 // matching how the terminal Scroll handles its non-scrolling axis. 2126 const overflow = switch (info.direction) { 2127 .vert => "overflow-x:hidden;overflow-y:auto", 2128 .horiz => "overflow-x:auto;overflow-y:hidden", 2129 .both => "overflow:auto", 2130 }; 2131 // the id carries a content version so JS preserves the native scroll 2132 // position across re-renders of the same content but resets it when the 2133 // content is replaced — e.g. selecting a different commit. the widget's 2134 // scroll offset (in cells) rides along so JS can apply it when it has no 2135 // preserved position, letting a wasm-side scrollToRect (the files list 2136 // scrolling to its selected row on page load) reach the browser. 2137 var buf: [256]u8 = undefined; 2138 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, "<div class=\"scroll\" data-key=\"scroll:{d}\" data-scroll-id=\"{d}-{d}\" data-scroll-direction=\"{s}\" data-scroll-x=\"{d}\" data-scroll-y=\"{d}\" style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em;{s}\">", .{ id, id, child.focus.version, @tagName(info.direction), info.offset_x, info.offset_y, r.x, r.y, r.size.width, r.size.height, overflow })); 2139 try renderPanel(allocator, output, child.focus, info.content, session, root_focus); 2140 try output.appendSlice(allocator, "</div>"); 2141 } 2142 } 2143 2144 const form_prefix = "form:"; 2145 const form_id_prefix = "haxy-form-"; 2146 2147 fn formAction(focus: *const Focus) ?[]const u8 { 2148 return switch (focus.kind) { 2149 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) custom[form_prefix.len..] else null, 2150 else => null, 2151 }; 2152 } 2153 2154 // find the form ancestor of a flattened focus-tree child. html's `form` 2155 // attribute then associates the control with that form without requiring the 2156 // form and control to share a DOM parent. 2157 fn formOwner(root_focus: *Focus, focus_id: usize) ?usize { 2158 var id = focus_id; 2159 while (root_focus.children.get(id)) |child| { 2160 const parent_id = child.parent_id; 2161 if (parent_id == root_focus.id) return if (formAction(root_focus) != null) root_focus.id else null; 2162 const parent = root_focus.children.get(parent_id) orelse return null; 2163 if (formAction(parent.focus) != null) return parent_id; 2164 id = parent_id; 2165 } 2166 return null; 2167 } 2168 2169 fn appendFormAttribute(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus, focus_id: usize) !void { 2170 const form_id = formOwner(root_focus, focus_id) orelse return; 2171 var buf: [64]u8 = undefined; 2172 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, " form=\"" ++ form_id_prefix ++ "{d}\"", .{form_id})); 2173 } 2174 2175 fn renderPanelControls( 2176 allocator: std.mem.Allocator, 2177 output: *std.ArrayList(u8), 2178 focus: *Focus, 2179 session: *ui.Session, 2180 root_focus: *Focus, 2181 excluded: *const std.AutoHashMapUnmanaged(usize, void), 2182 ) !void { 2183 var iter = focus.children.iterator(); 2184 while (iter.next()) |entry| { 2185 const id = entry.key_ptr.*; 2186 if (excluded.contains(id)) continue; 2187 const child = entry.value_ptr.*; 2188 const r = child.rect; 2189 2190 if (r.size.width == 0 or r.size.height == 0) continue; // not laid out, rather than merely off-screen 2191 2192 switch (child.focus.kind) { 2193 .text_input, .text_input_password, .text_area => { 2194 const ti = session.text_inputs.get(id) orelse continue; 2195 2196 const inner_left = r.x + 1; 2197 const inner_top = r.y + 1; 2198 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 2199 const preserve = !ti.options.read_only; 2200 2201 if (child.focus.kind == .text_area) { 2202 const value = try ti.text(allocator); 2203 defer allocator.free(value); 2204 var id_buf: [128]u8 = undefined; 2205 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<textarea data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 2206 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 2207 try appendFormAttribute(allocator, output, root_focus, id); 2208 if (ti.options.name.len > 0) { 2209 try output.appendSlice(allocator, " name=\""); 2210 try appendEscapedHtml(allocator, output, ti.options.name); 2211 try output.append(allocator, '"'); 2212 } 2213 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 2214 var pos_buf: [128]u8 = undefined; 2215 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">\n", .{ inner_left, inner_top, inner_width, inner_height })); 2216 try appendEscapedHtml(allocator, output, value); 2217 try output.appendSlice(allocator, "</textarea>"); 2218 continue; 2219 } 2220 2221 var id_buf: [160]u8 = undefined; 2222 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<input type=\"{s}\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ if (child.focus.kind == .text_input_password) "password" else "text", id, id })); 2223 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 2224 try appendFormAttribute(allocator, output, root_focus, id); 2225 if (ti.options.name.len > 0) { 2226 try output.appendSlice(allocator, " name=\""); 2227 try appendEscapedHtml(allocator, output, ti.options.name); 2228 try output.append(allocator, '"'); 2229 } 2230 if (child.focus.kind != .text_input_password) { 2231 const value = try ti.text(allocator); 2232 defer allocator.free(value); 2233 try output.appendSlice(allocator, " value=\""); 2234 try appendEscapedHtml(allocator, output, value); 2235 try output.append(allocator, '"'); 2236 } 2237 var pos_buf: [96]u8 = undefined; 2238 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:1em\">", .{ inner_left, inner_top, inner_width })); 2239 }, 2240 .custom => |custom| { 2241 if (std.mem.eql(u8, custom, "submit") or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 2242 if (formOwner(root_focus, id) == null) continue; 2243 var id_buf: [128]u8 = undefined; 2244 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<button type=\"submit\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 2245 try appendFormAttribute(allocator, output, root_focus, id); 2246 if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 2247 try output.appendSlice(allocator, " formaction=\""); 2248 try appendEscapedHtml(allocator, output, custom[ui.submit_action_prefix.len..]); 2249 try output.append(allocator, '"'); 2250 } 2251 var pos_buf: [128]u8 = undefined; 2252 try output.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, " style=\"left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\"></button>", .{ r.x, r.y, r.size.width, r.size.height })); 2253 } else if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) { 2254 try output.appendSlice(allocator, "<input type=\"file\" data-preserve-value data-action=\""); 2255 try appendEscapedHtml(allocator, output, custom[ui.file_input_prefix.len..]); 2256 var id_buf: [128]u8 = undefined; 2257 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "\" data-key=\"control:{d}\" data-focus-id=\"{d}\" style=\"opacity:0;left:{d}ch;top:{d}em;width:{d}ch;height:{d}em\">", .{ id, id, r.x, r.y, r.size.width, r.size.height })); 2258 } 2259 }, 2260 else => {}, 2261 } 2262 } 2263 } 2264 2265 // emit hidden form owners beside the rendered panels. their controls point 2266 // back here with the standard HTML `form` attribute. 2267 fn renderForms(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus) !void { 2268 // the focus child map preserves insertion order, which matches the order 2269 // widgets were added in code 2270 var iter = root_focus.children.iterator(); 2271 while (iter.next()) |entry| { 2272 const child = entry.value_ptr.*; 2273 const action_url = formAction(child.focus) orelse continue; 2274 2275 try output.appendSlice(allocator, "<form hidden data-key=\"form:"); 2276 var id_buf: [96]u8 = undefined; 2277 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}\" id=\"" ++ form_id_prefix ++ "{d}\" action=\"", .{ entry.key_ptr.*, entry.key_ptr.* })); 2278 try appendEscapedHtml(allocator, output, action_url); 2279 try output.appendSlice(allocator, "\" method=\"post\"></form>"); 2280 } 2281 } 2282 2283 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 2284 if (a) |av| return if (b) |bv| av.eql(bv) else false; 2285 return b == null; 2286 } 2287 2288 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 2289 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 2290 // empty slice when neither is set. 2291 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 2292 if (fg == null and bg == null) return buf[0..0]; 2293 const prefix = " style=\""; 2294 @memcpy(buf[0..prefix.len], prefix); 2295 var i: usize = prefix.len; 2296 if (fg) |c| { 2297 const s = std.fmt.bufPrint(buf[i..], "color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 2298 i += s.len; 2299 } 2300 if (bg) |c| { 2301 const s = std.fmt.bufPrint(buf[i..], "background-color:#{x:0>2}{x:0>2}{x:0>2};", .{ c.r, c.g, c.b }) catch return buf[0..0]; 2302 i += s.len; 2303 } 2304 buf[i] = '"'; 2305 i += 1; 2306 return buf[0..i]; 2307 } 2308 2309 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 2310 for (input) |ch| { 2311 switch (ch) { 2312 '&' => try out.appendSlice(allocator, "&amp;"), 2313 '<' => try out.appendSlice(allocator, "&lt;"), 2314 '>' => try out.appendSlice(allocator, "&gt;"), 2315 '"' => try out.appendSlice(allocator, "&quot;"), 2316 '\'' => try out.appendSlice(allocator, "&#39;"), 2317 else => try out.append(allocator, ch), 2318 } 2319 } 2320 } 2321 2322 // --- helpers -------------------------------------------------------------- 2323 2324 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 2325 var iter = request.iterateHeaders(); 2326 while (iter.next()) |header| { 2327 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 2328 var pairs = std.mem.splitScalar(u8, header.value, ';'); 2329 while (pairs.next()) |pair| { 2330 const trimmed = std.mem.trim(u8, pair, " \t"); 2331 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 2332 if (std.mem.eql(u8, trimmed[0..eq], name)) { 2333 return trimmed[eq + 1 ..]; 2334 } 2335 } 2336 } 2337 return null; 2338 } 2339 2340 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 2341 var iter = std.mem.splitScalar(u8, body, '&'); 2342 while (iter.next()) |pair| { 2343 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 2344 if (std.mem.eql(u8, pair[0..eq], key)) { 2345 return try decodeFormValue(allocator, pair[eq + 1 ..]); 2346 } 2347 } 2348 return null; 2349 } 2350 2351 fn readFormBody(request: *std.http.Server.Request, allocator: std.mem.Allocator) ![]u8 { 2352 var buffer: [256]u8 = undefined; 2353 return request.readerExpectNone(&buffer).allocRemaining(allocator, .limited(65536)); 2354 } 2355 2356 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 2357 var out: std.ArrayList(u8) = .empty; 2358 errdefer out.deinit(allocator); 2359 var i: usize = 0; 2360 while (i < encoded.len) : (i += 1) { 2361 switch (encoded[i]) { 2362 '+' => try out.append(allocator, ' '), 2363 '%' => { 2364 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 2365 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 2366 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 2367 try out.append(allocator, hi * 16 + lo); 2368 i += 2; 2369 }, 2370 else => try out.append(allocator, encoded[i]), 2371 } 2372 } 2373 return try out.toOwnedSlice(allocator); 2374 } 2375 2376 // disk-backed web session store. each login mints a random, opaque token 2377 // (not the user's id) that is stored as a file named by the token hex, whose 2378 // contents are the raw user_id bytes. authenticating a request is a lookup of 2379 // the cookie's token; logout deletes the file, revoking the session. there is 2380 // deliberately no expiry — a session lives until logout. form failures use 2381 // files in the flash subdirectory that the redirected GET consumes. the 2382 // directory is cleared whenever the server starts. 2383 pub const SessionStore = struct { 2384 io: std.Io, 2385 dir: std.Io.Dir, 2386 flash_dir: std.Io.Dir, 2387 auto_login: ?[token_hex_len]u8, 2388 2389 pub const token_hex_len = evt.event_id_size * 2; 2390 2391 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 2392 try data_dir.createDirPath(io, "sessions"); 2393 const dir = try data_dir.openDir(io, "sessions", .{}); 2394 errdefer dir.close(io); 2395 try dir.deleteTree(io, "flash"); 2396 try dir.createDirPath(io, "flash"); 2397 const flash_dir = try dir.openDir(io, "flash", .{}); 2398 errdefer flash_dir.close(io); 2399 const auto_login = blk: { 2400 const file = dir.openFile(io, auto_login_name, .{ .mode = .read_only }) catch break :blk null; 2401 defer file.close(io); 2402 var token: [token_hex_len]u8 = undefined; 2403 var storage: [token_hex_len]u8 = undefined; 2404 var reader = file.reader(io, &storage); 2405 reader.interface.readSliceAll(&token) catch break :blk null; 2406 break :blk token; 2407 }; 2408 return .{ .io = io, .dir = dir, .flash_dir = flash_dir, .auto_login = auto_login }; 2409 } 2410 2411 pub fn deinit(self: SessionStore) void { 2412 self.flash_dir.close(self.io); 2413 self.dir.close(self.io); 2414 } 2415 2416 // mint a new session for user_id, returning the token hex to set as the 2417 // cookie value. 2418 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 2419 var token: [evt.event_id_size]u8 = undefined; 2420 self.io.random(&token); 2421 const token_hex = std.fmt.bytesToHex(token, .lower); 2422 const file = try self.dir.createFile(self.io, &token_hex, .{}); 2423 defer file.close(self.io); 2424 try file.writeStreamingAll(self.io, user_id); 2425 return token_hex; 2426 } 2427 2428 fn createFlash(self: SessionStore, allocator: std.mem.Allocator, value: ui.Session.FormFeedback) ![token_hex_len]u8 { 2429 var token: [evt.event_id_size]u8 = undefined; 2430 self.io.random(&token); 2431 const token_hex = std.fmt.bytesToHex(token, .lower); 2432 const file = try self.flash_dir.createFile(self.io, &token_hex, .{}); 2433 errdefer self.flash_dir.deleteFile(self.io, &token_hex) catch {}; 2434 defer file.close(self.io); 2435 2436 var json: std.Io.Writer.Allocating = .init(allocator); 2437 defer json.deinit(); 2438 try std.json.Stringify.value(value, .{}, &json.writer); 2439 try file.writeStreamingAll(self.io, json.written()); 2440 return token_hex; 2441 } 2442 2443 fn takeFlash(self: SessionStore, allocator: std.mem.Allocator, token_hex: []const u8) ?ui.Session.FormFeedback { 2444 if (!isToken(token_hex)) return null; 2445 const file = self.flash_dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return null; 2446 defer self.flash_dir.deleteFile(self.io, token_hex) catch {}; 2447 defer file.close(self.io); 2448 2449 var storage: [4096]u8 = undefined; 2450 var reader = file.reader(self.io, &storage); 2451 const bytes = reader.interface.allocRemaining(allocator, .limited(512 * 1024)) catch return null; 2452 return std.json.parseFromSliceLeaky(ui.Session.FormFeedback, allocator, bytes, .{}) catch null; 2453 } 2454 2455 // resolve a cookie's token to its user_id. returns false (logged out) for a 2456 // missing, malformed, or unknown token. 2457 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 2458 if (!isToken(token_hex)) return false; 2459 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 2460 defer file.close(self.io); 2461 var storage: [evt.event_id_size]u8 = undefined; 2462 var file_reader = file.reader(self.io, &storage); 2463 file_reader.interface.readSliceAll(out) catch return false; 2464 return true; 2465 } 2466 2467 // revoke a session. a no-op for a missing or malformed token. 2468 pub fn remove(self: SessionStore, token_hex: []const u8) void { 2469 if (!isToken(token_hex)) return; 2470 self.dir.deleteFile(self.io, token_hex) catch {}; 2471 } 2472 2473 // leave `token_hex`'s session for visitors arriving without one. only the 2474 // `try` fixture writes one. the name can't collide with a session token. 2475 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 2476 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 2477 defer file.close(self.io); 2478 try file.writeStreamingAll(self.io, token_hex); 2479 } 2480 2481 pub fn autoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 2482 out.* = self.auto_login orelse return false; 2483 return true; 2484 } 2485 2486 const auto_login_name = "auto-login"; 2487 2488 // a token is exactly token_hex_len hex chars. validating before using the 2489 // value as a path keeps attacker-supplied cookies from escaping the 2490 // sessions dir (e.g. via "/" or ".." bytes). 2491 fn isToken(token_hex: []const u8) bool { 2492 if (token_hex.len != token_hex_len) return false; 2493 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 2494 return true; 2495 } 2496 }; 2497