╭─────────────╮ │scroll to top│ ╰─────────────╯ ╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1665 try excluded.put(allocator, e.key_ptr.*, {}); 1666 var mit = e.value_ptr.focus.children.iterator(); 1667 while (mit.next()) |m| try excluded.put(allocator, m.key_ptr.*, {}); 1668 } 1669 } 1670 1671 // the HTML element a run of cells is wrapped in, chosen from the cell's 1672 // focusable kind 1673 const CellTag = union(enum) { 1674 span, // clickable focusable cell 1675 a: []const u8, // clickable focusable link 1676 plain, // non-focusable, colored 1677 1678 const a_prefix = "a:"; 1679 // an "in-page" anchor: rendered as a real <a href> so it works with js 1680 // disabled, but wasm handles it without navigating. 1681 fn init(str: []const u8) @This() { 1682 if (std.mem.startsWith(u8, str, ui.raw_link_prefix)) { 1683 return .{ .a = str[ui.raw_link_prefix.len..] }; 1684 } 1685 if (std.mem.startsWith(u8, str, ui.in_page_link_prefix)) { 1686 return .{ .a = str[ui.in_page_link_prefix.len..] }; 1687 } 1688 if (std.mem.startsWith(u8, str, a_prefix)) { 1689 return .{ .a = str[a_prefix.len..] }; 1690 } 1691 return .span; 1692 } 1693 1694 fn writeOpenTag(self: @This(), alloc: std.mem.Allocator, out: *std.ArrayList(u8), id: usize, style_attr: []const u8) !void { 1695 var id_buf: [32]u8 = undefined; 1696 switch (self) { 1697 .span => { 1698 try out.appendSlice(alloc, "<span class=\"clickable\" data-focus-id=\""); 1699 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1700 try out.appendSlice(alloc, "\""); 1701 try out.appendSlice(alloc, style_attr); 1702 try out.appendSlice(alloc, ">"); 1703 }, 1704 .a => |href| { 1705 // tabindex=-1 keeps these out of the browser's tab order 1706 try out.appendSlice(alloc, "<a class=\"clickable\" tabindex=\"-1\" data-focus-id=\""); 1707 try out.appendSlice(alloc, try std.fmt.bufPrint(&id_buf, "{d}", .{id})); 1708 try out.appendSlice(alloc, "\" href=\""); 1709 try appendEscapedHtml(alloc, out, href); 1710 try out.appendSlice(alloc, "\""); 1711 try out.appendSlice(alloc, style_attr); 1712 try out.appendSlice(alloc, ">"); 1713 }, 1714 .plain => { 1715 try out.appendSlice(alloc, "<span"); 1716 try out.appendSlice(alloc, style_attr); 1717 try out.appendSlice(alloc, ">"); 1718 }, 1719 } 1720 } 1721 1722 fn closeTag(self: @This()) []const u8 { 1723 return switch (self) { 1724 .span, .plain => "</span>", 1725 .a => "</a>", 1726 }; 1727 } 1728 }; 1729 1730 // the cells are the volatile part of a panel. js replaces this one subtree 1731 // wholesale while retaining the panel's native controls and scroll shells. 1732 try output.appendSlice(allocator, "<div class=\"grid-cells\">"); 1733 1734 // emit this panel's grid as rows of text, coalescing adjacent cells that 1735 // share a focus id and colors into one tag (a clickable span, a link, or a 1736 // plain colored span). cells under a child scroll keep only their background. 1737 for (0..grid.size.height) |y| { 1738 var cur_id: ?usize = null; 1739 var cur_fg: ?Grid.Color = null; 1740 var cur_bg: ?Grid.Color = null; 1741 var open_tag: ?CellTag = null; 1742 var first = true; 1743 for (0..grid.size.width) |x| { 1744 const cell = (try grid.cell(x, y)).*; 1745 // a cell covered by a child scroll's viewport is drawn by that scroll's 1746 // own div, so blank its glyph and make it non-clickable here — but keep 1747 // its background, so the backdrop still shows through the scroll's 1748 // transparent div instead of a bare hole. 1749 const covered_by_scroll = blk: { 1750 for (direct.items) |id| { 1751 const r = (focus.children.get(id) orelse continue).rect; 1752 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; 1753 } 1754 break :blk false; 1755 }; 1756 // the focusable cell at (x, y) among this panel's own focusables (skipping 1757 // any that belong to a child scroll, whose cells are drawn in their own 1758 // panel); none for a covered cell. 1759 const cell_id = if (covered_by_scroll) null else blk: { 1760 var iter = focus.children.iterator(); 1761 while (iter.next()) |entry| { 1762 const child = entry.value_ptr.*; 1763 if (!child.focus.focusable) continue; 1764 if (excluded.contains(entry.key_ptr.*)) continue; 1765 const r = child.rect; 1766 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 1767 break :blk entry.key_ptr.*; 1768 } 1769 } 1770 break :blk null; 1771 }; 1772 const fg = if (covered_by_scroll) null else cell.style.fg; 1773 const bg = cell.style.bg; 1774 1775 if (first or cell_id != cur_id or !colorEql(fg, cur_fg) or !colorEql(bg, cur_bg)) { 1776 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1777 1778 var new_tag: ?CellTag = null; 1779 if (cell_id) |id| { 1780 if (focus.children.get(id)) |child| { 1781 new_tag = switch (child.focus.kind) { 1782 .custom => |custom| CellTag.init(custom), 1783 else => .span, 1784 }; 1785 } 1786 } 1787 if (new_tag == null and (fg != null or bg != null)) new_tag = .plain; 1788 1789 if (new_tag) |t| { 1790 var style_buf: [64]u8 = undefined; 1791 try t.writeOpenTag(allocator, output, cell_id orelse 0, styleAttr(&style_buf, fg, bg)); 1792 } 1793 1794 open_tag = new_tag; 1795 cur_id = cell_id; 1796 cur_fg = fg; 1797 cur_bg = bg; 1798 first = false; 1799 } 1800 1801 if (covered_by_scroll) { 1802 try appendEscapedHtml(allocator, output, " "); 1803 } else if (cell.continuation) { 1804 // the wide rune to the left was emitted 2ch wide, covering this column 1805 } else if (cell.rune) |rune| { 1806 var encoded: [4]u8 = undefined; 1807 const encoded_len = try std.unicode.utf8Encode(rune, &encoded); 1808 const rune_text = encoded[0..encoded_len]; 1809 // a double-width rune is pinned inside a 2ch span so the 1810 // fallback glyph's advance width can't shift the rest of the row 1811 const wide = x + 1 < grid.size.width and (try grid.cell(x + 1, y)).continuation; 1812 if (wide) { 1813 try output.appendSlice(allocator, "<span class=\"w2\">"); 1814 try appendEscapedHtml(allocator, output, rune_text); 1815 try output.appendSlice(allocator, "</span>"); 1816 } else { 1817 try appendEscapedHtml(allocator, output, rune_text); 1818 } 1819 } else { 1820 try appendEscapedHtml(allocator, output, " "); 1821 } 1822 } 1823 if (open_tag) |t| try output.appendSlice(allocator, t.closeTag()); 1824 try output.append(allocator, '\n'); 1825 } 1826 try output.appendSlice(allocator, "</div>"); 1827 1828 // native controls live in their panel, beside the replaceable cells. their 1829 // stable keys let JS update layout without recreating their DOM nodes, and 1830 // being actual descendants of a scroll gives them native movement/clipping. 1831 try renderPanelControls(allocator, output, focus, session, root_focus, &excluded); 1832 1833 // each child scroll becomes a natively-scrollable div positioned over its 1834 // viewport, holding its full content rendered recursively. 1835 for (direct.items) |id| { 1836 const child = focus.children.get(id) orelse continue; 1837 const info = child.focus.scroll orelse continue; 1838 const r = child.rect; 1839 // overflow only on the axes the widget scrolls; `auto` shows a bar only 1840 // when that axis actually overflows. the other axis is clipped (hidden), 1841 // matching how the terminal Scroll handles its non-scrolling axis. 1842 const overflow = switch (info.direction) { 1843 .vert => "overflow-x:hidden;overflow-y:auto", 1844 .horiz => "overflow-x:auto;overflow-y:hidden", 1845 .both => "overflow:auto", 1846 }; 1847 // the id carries a content version so JS preserves the native scroll 1848 // position across re-renders of the same content but resets it when the 1849 // content is replaced — e.g. selecting a different commit. the widget's 1850 // scroll offset (in cells) rides along so JS can apply it when it has no 1851 // preserved position, letting a wasm-side scrollToRect (the files list 1852 // scrolling to its selected row on page load) reach the browser. 1853 var buf: [256]u8 = undefined; 1854 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 })); 1855 try renderPanel(allocator, output, child.focus, info.content, session, root_focus); 1856 try output.appendSlice(allocator, "</div>"); 1857 } 1858 } 1859 1860 const form_prefix = "form:"; 1861 const form_id_prefix = "haxy-form-"; 1862 1863 fn formAction(focus: *const Focus) ?[]const u8 { 1864 return switch (focus.kind) { 1865 .custom => |custom| if (std.mem.startsWith(u8, custom, form_prefix)) custom[form_prefix.len..] else null, 1866 else => null, 1867 }; 1868 } 1869 1870 // find the form ancestor of a flattened focus-tree child. html's `form` 1871 // attribute then associates the control with that form without requiring the 1872 // form and control to share a DOM parent. 1873 fn formOwner(root_focus: *Focus, focus_id: usize) ?usize { 1874 var id = focus_id; 1875 while (root_focus.children.get(id)) |child| { 1876 const parent_id = child.parent_id; 1877 if (parent_id == root_focus.id) return if (formAction(root_focus) != null) root_focus.id else null; 1878 const parent = root_focus.children.get(parent_id) orelse return null; 1879 if (formAction(parent.focus) != null) return parent_id; 1880 id = parent_id; 1881 } 1882 return null; 1883 } 1884 1885 fn appendFormAttribute(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus, focus_id: usize) !void { 1886 const form_id = formOwner(root_focus, focus_id) orelse return; 1887 var buf: [64]u8 = undefined; 1888 try output.appendSlice(allocator, try std.fmt.bufPrint(&buf, " form=\"" ++ form_id_prefix ++ "{d}\"", .{form_id})); 1889 } 1890 1891 fn renderPanelControls( 1892 allocator: std.mem.Allocator, 1893 output: *std.ArrayList(u8), 1894 focus: *Focus, 1895 session: *ui.Session, 1896 root_focus: *Focus, 1897 excluded: *const std.AutoHashMapUnmanaged(usize, void), 1898 ) !void { 1899 var iter = focus.children.iterator(); 1900 while (iter.next()) |entry| { 1901 const id = entry.key_ptr.*; 1902 if (excluded.contains(id)) continue; 1903 const child = entry.value_ptr.*; 1904 const r = child.rect; 1905 1906 switch (child.focus.kind) { 1907 .text_input, .text_input_password, .text_area => { 1908 const ti = session.text_inputs.get(id) orelse continue; 1909 1910 const inner_left = r.x + 1; 1911 const inner_top = r.y + 1; 1912 const inner_width = if (r.size.width > 2) r.size.width - 2 else 0; 1913 const preserve = !ti.options.read_only; 1914 1915 if (child.focus.kind == .text_area) { 1916 const value = try ti.text(allocator); 1917 defer allocator.free(value); 1918 var id_buf: [128]u8 = undefined; 1919 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<textarea data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 1920 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 1921 try appendFormAttribute(allocator, output, root_focus, id); 1922 if (ti.options.name.len > 0) { 1923 try output.appendSlice(allocator, " name=\""); 1924 try appendEscapedHtml(allocator, output, ti.options.name); 1925 try output.append(allocator, '"'); 1926 } 1927 const inner_height = if (r.size.height > 2) r.size.height - 2 else 0; 1928 var pos_buf: [128]u8 = undefined; 1929 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 })); 1930 try appendEscapedHtml(allocator, output, value); 1931 try output.appendSlice(allocator, "</textarea>"); 1932 continue; 1933 } 1934 1935 var id_buf: [160]u8 = undefined; 1936 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 })); 1937 if (preserve) try output.appendSlice(allocator, " data-preserve-value") else try output.appendSlice(allocator, " readonly"); 1938 try appendFormAttribute(allocator, output, root_focus, id); 1939 if (ti.options.name.len > 0) { 1940 try output.appendSlice(allocator, " name=\""); 1941 try appendEscapedHtml(allocator, output, ti.options.name); 1942 try output.append(allocator, '"'); 1943 } 1944 if (child.focus.kind != .text_input_password) { 1945 const value = try ti.text(allocator); 1946 defer allocator.free(value); 1947 try output.appendSlice(allocator, " value=\""); 1948 try appendEscapedHtml(allocator, output, value); 1949 try output.append(allocator, '"'); 1950 } 1951 var pos_buf: [96]u8 = undefined; 1952 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 })); 1953 }, 1954 .custom => |custom| { 1955 if (std.mem.eql(u8, custom, "submit") or std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1956 if (formOwner(root_focus, id) == null) continue; 1957 var id_buf: [128]u8 = undefined; 1958 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "<button type=\"submit\" data-key=\"control:{d}\" data-focus-id=\"{d}\"", .{ id, id })); 1959 try appendFormAttribute(allocator, output, root_focus, id); 1960 if (std.mem.startsWith(u8, custom, ui.submit_action_prefix)) { 1961 try output.appendSlice(allocator, " formaction=\""); 1962 try appendEscapedHtml(allocator, output, custom[ui.submit_action_prefix.len..]); 1963 try output.append(allocator, '"'); 1964 } 1965 var pos_buf: [128]u8 = undefined; 1966 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 })); 1967 } else if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) { 1968 try output.appendSlice(allocator, "<input type=\"file\" data-preserve-value data-action=\""); 1969 try appendEscapedHtml(allocator, output, custom[ui.file_input_prefix.len..]); 1970 var id_buf: [128]u8 = undefined; 1971 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 })); 1972 } 1973 }, 1974 else => {}, 1975 } 1976 } 1977 } 1978 1979 // emit hidden form owners beside the rendered panels. their controls point 1980 // back here with the standard HTML `form` attribute. 1981 fn renderForms(allocator: std.mem.Allocator, output: *std.ArrayList(u8), root_focus: *Focus) !void { 1982 // the focus child map preserves insertion order, which matches the order 1983 // widgets were added in code 1984 var iter = root_focus.children.iterator(); 1985 while (iter.next()) |entry| { 1986 const child = entry.value_ptr.*; 1987 const action_url = formAction(child.focus) orelse continue; 1988 1989 try output.appendSlice(allocator, "<form hidden data-key=\"form:"); 1990 var id_buf: [96]u8 = undefined; 1991 try output.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}\" id=\"" ++ form_id_prefix ++ "{d}\" action=\"", .{ entry.key_ptr.*, entry.key_ptr.* })); 1992 try appendEscapedHtml(allocator, output, action_url); 1993 try output.appendSlice(allocator, "\" method=\"post\"></form>"); 1994 } 1995 } 1996 1997 fn colorEql(a: ?Grid.Color, b: ?Grid.Color) bool { 1998 if (a) |av| return if (b) |bv| av.eql(bv) else false; 1999 return b == null; 2000 } 2001 2002 // builds an HTML ` style="color:#rrggbb;background-color:#rrggbb"` attribute 2003 // from a cell's fg/bg into `buf`, omitting whichever color is unset. returns an 2004 // empty slice when neither is set. 2005 fn styleAttr(buf: []u8, fg: ?Grid.Color, bg: ?Grid.Color) []const u8 { 2006 if (fg == null and bg == null) return buf[0..0]; 2007 const prefix = " style=\""; 2008 @memcpy(buf[0..prefix.len], prefix); 2009 var i: usize = prefix.len; 2010 if (fg) |c| { 2011 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]; 2012 i += s.len; 2013 } 2014 if (bg) |c| { 2015 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]; 2016 i += s.len; 2017 } 2018 buf[i] = '"'; 2019 i += 1; 2020 return buf[0..i]; 2021 } 2022 2023 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 2024 for (input) |ch| { 2025 switch (ch) { 2026 '&' => try out.appendSlice(allocator, "&amp;"), 2027 '<' => try out.appendSlice(allocator, "&lt;"), 2028 '>' => try out.appendSlice(allocator, "&gt;"), 2029 '"' => try out.appendSlice(allocator, "&quot;"), 2030 '\'' => try out.appendSlice(allocator, "&#39;"), 2031 else => try out.append(allocator, ch), 2032 } 2033 } 2034 } 2035 2036 // --- helpers -------------------------------------------------------------- 2037 2038 fn getCookieValue(request: *std.http.Server.Request, name: []const u8) ?[]const u8 { 2039 var iter = request.iterateHeaders(); 2040 while (iter.next()) |header| { 2041 if (!std.ascii.eqlIgnoreCase(header.name, "cookie")) continue; 2042 var pairs = std.mem.splitScalar(u8, header.value, ';'); 2043 while (pairs.next()) |pair| { 2044 const trimmed = std.mem.trim(u8, pair, " \t"); 2045 const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue; 2046 if (std.mem.eql(u8, trimmed[0..eq], name)) { 2047 return trimmed[eq + 1 ..]; 2048 } 2049 } 2050 } 2051 return null; 2052 } 2053 2054 fn parseFormField(allocator: std.mem.Allocator, body: []const u8, key: []const u8) !?[]u8 { 2055 var iter = std.mem.splitScalar(u8, body, '&'); 2056 while (iter.next()) |pair| { 2057 const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue; 2058 if (std.mem.eql(u8, pair[0..eq], key)) { 2059 return try decodeFormValue(allocator, pair[eq + 1 ..]); 2060 } 2061 } 2062 return null; 2063 } 2064 2065 fn readFormBody(request: *std.http.Server.Request, allocator: std.mem.Allocator) ![]u8 { 2066 var buffer: [256]u8 = undefined; 2067 return request.readerExpectNone(&buffer).allocRemaining(allocator, .limited(65536)); 2068 } 2069 2070 fn decodeFormValue(allocator: std.mem.Allocator, encoded: []const u8) ![]u8 { 2071 var out: std.ArrayList(u8) = .empty; 2072 errdefer out.deinit(allocator); 2073 var i: usize = 0; 2074 while (i < encoded.len) : (i += 1) { 2075 switch (encoded[i]) { 2076 '+' => try out.append(allocator, ' '), 2077 '%' => { 2078 if (i + 2 >= encoded.len) return error.InvalidPercentEncoding; 2079 const hi = std.fmt.charToDigit(encoded[i + 1], 16) catch return error.InvalidPercentEncoding; 2080 const lo = std.fmt.charToDigit(encoded[i + 2], 16) catch return error.InvalidPercentEncoding; 2081 try out.append(allocator, hi * 16 + lo); 2082 i += 2; 2083 }, 2084 else => try out.append(allocator, encoded[i]), 2085 } 2086 } 2087 return try out.toOwnedSlice(allocator); 2088 } 2089 2090 // disk-backed web session store. each login mints a random, opaque token 2091 // (not the user's id) that is stored as a file named by the token hex, whose 2092 // contents are the raw user_id bytes. authenticating a request is a lookup of 2093 // the cookie's token; logout deletes the file, revoking the session. there is 2094 // deliberately no expiry — a session lives until logout. 2095 pub const SessionStore = struct { 2096 io: std.Io, 2097 dir: std.Io.Dir, 2098 auto_login: ?[token_hex_len]u8, 2099 2100 pub const token_hex_len = evt.event_id_size * 2; 2101 2102 pub fn init(io: std.Io, data_dir: std.Io.Dir) !SessionStore { 2103 try data_dir.createDirPath(io, "sessions"); 2104 const dir = try data_dir.openDir(io, "sessions", .{}); 2105 const auto_login = blk: { 2106 const file = dir.openFile(io, auto_login_name, .{ .mode = .read_only }) catch break :blk null; 2107 defer file.close(io); 2108 var token: [token_hex_len]u8 = undefined; 2109 var storage: [token_hex_len]u8 = undefined; 2110 var reader = file.reader(io, &storage); 2111 reader.interface.readSliceAll(&token) catch break :blk null; 2112 break :blk token; 2113 }; 2114 return .{ .io = io, .dir = dir, .auto_login = auto_login }; 2115 } 2116 2117 pub fn deinit(self: SessionStore) void { 2118 self.dir.close(self.io); 2119 } 2120 2121 // mint a new session for user_id, returning the token hex to set as the 2122 // cookie value. 2123 pub fn create(self: SessionStore, user_id: *const [evt.event_id_size]u8) ![token_hex_len]u8 { 2124 var token: [evt.event_id_size]u8 = undefined; 2125 self.io.random(&token); 2126 const token_hex = std.fmt.bytesToHex(token, .lower); 2127 const file = try self.dir.createFile(self.io, &token_hex, .{}); 2128 defer file.close(self.io); 2129 try file.writeStreamingAll(self.io, user_id); 2130 return token_hex; 2131 } 2132 2133 // resolve a cookie's token to its user_id. returns false (logged out) for a 2134 // missing, malformed, or unknown token. 2135 pub fn lookup(self: SessionStore, token_hex: []const u8, out: *[evt.event_id_size]u8) bool { 2136 if (!isToken(token_hex)) return false; 2137 const file = self.dir.openFile(self.io, token_hex, .{ .mode = .read_only }) catch return false; 2138 defer file.close(self.io); 2139 var storage: [evt.event_id_size]u8 = undefined; 2140 var file_reader = file.reader(self.io, &storage); 2141 file_reader.interface.readSliceAll(out) catch return false; 2142 return true; 2143 } 2144 2145 // revoke a session. a no-op for a missing or malformed token. 2146 pub fn remove(self: SessionStore, token_hex: []const u8) void { 2147 if (!isToken(token_hex)) return; 2148 self.dir.deleteFile(self.io, token_hex) catch {}; 2149 } 2150 2151 // leave `token_hex`'s session for visitors arriving without one. only the 2152 // `try` fixture writes one. the name can't collide with a session token. 2153 pub fn offerAutoLogin(self: SessionStore, token_hex: *const [token_hex_len]u8) !void { 2154 const file = try self.dir.createFile(self.io, auto_login_name, .{}); 2155 defer file.close(self.io); 2156 try file.writeStreamingAll(self.io, token_hex); 2157 } 2158 2159 pub fn autoLogin(self: SessionStore, out: *[token_hex_len]u8) bool { 2160 out.* = self.auto_login orelse return false; 2161 return true; 2162 } 2163 2164 const auto_login_name = "auto-login"; 2165 2166 // a token is exactly token_hex_len hex chars. validating before using the 2167 // value as a path keeps attacker-supplied cookies from escaping the 2168 // sessions dir (e.g. via "/" or ".." bytes). 2169 fn isToken(token_hex: []const u8) bool { 2170 if (token_hex.len != token_hex_len) return false; 2171 for (token_hex) |c| if (!std.ascii.isHex(c)) return false; 2172 return true; 2173 } 2174 }; 2175