git.sophuwu.com > manhttpd
finished 1.4.0
sophuwu sophie@skisiel.com
Mon, 24 Feb 2025 15:00:16 +0100
commit

ea552cfecd03b29b7aceed17cade0aefe6db2c7c

parent

849f5ce22f3fe45cb2065e681343940507f8aaa2

M README.mdREADME.md

@@ -12,6 +12,13 @@ - Filter by page function or section: 1=commands, 3=C/C++ Refs, 5,7=config/format, 8=sudo commands, etc.

- Able to correctly interpret and display incorrectly formatted man pages, to a degree. - Auto updates man pages when new packages are installed or removed using standard installation methods. +## Extra Information +### Performance: +A query for all user or sudo commands that list or organise information, shows 119 commands in 53ms. +Searching for all C libraries for parsing, shows 38 libraries in 48ms. + +If I wish to find a command that configures a service, I would use: +network interfaces, # Installation Using Apt
A extra/nfpm.yaml

@@ -0,0 +1,47 @@

+# nfpm example configuration file +# +# check https://nfpm.goreleaser.com/configuration for detailed usage +# +name: "manhttpd" +arch: "amd64" +platform: "linux" +version: "1.4.0" +section: "default" +priority: "extra" +replaces: +provides: +depends: +- mandoc +recommends: +suggests: +conflicts: +maintainer: "sophuwu <sophie@skisiel.com>" +description: """manhttpd is a frontend for the linux man pages. +Offering a functional, minimalistic interface for viewing and finding pages. +The service offers multiple search options: +- Search directly by page name +- Search by keyword/wildcard +- Search by section number +- Full regex name and description search +It is very fast for the features it offers. + +It is useful for serving manpages over a network, or for browsing them +in a web browser. + +manhttpd is written in C and is very lightweight. It can be run as a +systemd service or as a standalone binary. +""" +vendor: "sophuwu.site" +homepage: "https://sophuwu.site/manhttpd" +license: "MIT" +changelog: "" +contents: +- src: ../build/manhttpd + dst: /usr/bin/manhttpd +- src: ./manhttpd.service + dst: /etc/manhttpd.service + type: config +overrides: + deb: + scripts: + postinstall: ./postinstall.sh
A extra/postinstall.sh

@@ -0,0 +1,8 @@

+#!/bin/bash + +if [[ -f /etc/manhttpd.service ]]; then + ln -s /etc/manhttpd.service /etc/systemd/system/manhttpd.service +fi +systemctl daemon-reload +systemctl enable manhttpd +systemctl start manhttpd
M index.htmltemplate/index.html

@@ -1,17 +1,13 @@

<!DOCTYPE html> <html lang="en"> <head> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet"> <meta charset="utf-8"> <title>{{ title }}@{{ hostname }}</title> - <style id="styleCss"> - {{ cssContent }} - </style> - <script> - {{ jsContent }} - </script> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="style.css" type="text/css"> + <script src="script.js"></script> + <link rel="icon" href="icons.ico" type="image/x-icon"> + <style id="styleCss"></style> </head> <body> <header class="menu" id="search">

@@ -19,13 +15,16 @@ <div>

<h3>ManWeb @{{ hostname }}</h3> </div> <form class="rounded" method="post"> - <input type="text" class="txt" name="q" autocomplete="off" value="{{ query }}"> + <input type="text" class="txt" name="q" autocomplete="off" > <input type="submit" class="submit" value="Search"> </form> <div class="rounded"> - <button id="helpButt">Help</button> + <button onclick="GoToRawQuery('manweb-help.html')">Help</button> <button onclick="Menu(H(this).l)">Settings</button> </div> + <script> + document.querySelector('input[name="q"]').value = `{{ query }}`; + </script> </header> <header class="settings menu hidden" id="settings"> <div>
M main.gomain.go

@@ -11,22 +11,28 @@ "regexp"

"strings" ) -//go:embed index.html +//go:embed template/index.html var index string -//go:embed theme.css -var css string +//go:embed template/help.html +var help string + +//go:embed template/theme.css +var css []byte -//go:embed scripts.js -var scripts string +//go:embed template/scripts.js +var scripts []byte -//go:embed favicon.ico +//go:embed template/favicon.ico var favicon []byte var CFG struct { Hostname string Port string Mandoc string + DbCmd string + ManCmd string + Server http.Server Addr string }

@@ -46,8 +52,9 @@ } else if s, e = os.Hostname(); e == nil {

CFG.Hostname = s } else if b, e = os.ReadFile("/etc/hostname"); e == nil { CFG.Hostname = strings.TrimSpace(string(b)) - } else { - + } + if CFG.Hostname == "" { + CFG.Hostname = "Unresolved" } index = strings.ReplaceAll(index, "{{ hostname }}", CFG.Hostname)

@@ -61,6 +68,24 @@ Fatal("dependency `mandoc` not found in $PATH, is it installed?\n")

} else { CFG.Mandoc = strings.TrimSpace(string(b)) } + f := func(s string) string { + if b, e = exec.Command("which", s).Output(); e != nil || len(b) == 0 { + return "" + } + return strings.TrimSpace(string(b)) + } + + if s = f("man"); s == "" { + Fatal("dependency `man` not found. `man` and its libraries are required for manhttpd to function.") + } else { + CFG.ManCmd = s + } + + if s = f("apropos"); s == "" { + Fatal("dependency `apropos` not found. `apropos` is required for search functionality.") + } else { + CFG.DbCmd = s + } CFG.Port = os.Getenv("ListenPort") if CFG.Port == "" {

@@ -72,30 +97,45 @@ CFG.Addr = "0.0.0.0"

} } -func init() { - index = strings.ReplaceAll(index, "{{ jsContent }}", scripts) - index = strings.ReplaceAll(index, "{{ cssContent }}", css) -} - func main() { GetCFG() - http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/x-icon") - w.Header().Set("Content-Length", fmt.Sprint(len(favicon))) - w.WriteHeader(http.StatusOK) - w.Write(favicon) - }) server := http.Server{ Addr: CFG.Addr + ":" + CFG.Port, - Handler: http.HandlerFunc(indexHandler), + Handler: http.HandlerFunc(MuxHandler), } _ = server.ListenAndServe() } +func WriteFile(w http.ResponseWriter, file []byte, contentType string) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", fmt.Sprint(len(file))) + w.WriteHeader(http.StatusOK) + w.Write(file) +} + +func MuxHandler(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + l := len(p) + if l >= 9 { + p = p[l-9:] + } + switch p { + case "style.css": + WriteFile(w, css, "text/css") + case "script.js": + WriteFile(w, scripts, "text/javascript") + case "icons.ico": + WriteFile(w, favicon, "image/x-icon") + default: + IndexHandler(w, r) + } + r.Body.Close() +} + func WriteHtml(w http.ResponseWriter, r *http.Request, title, html string, q string) { out := strings.ReplaceAll(index, "{{ host }}", r.Host) out = strings.ReplaceAll(out, "{{ title }}", title) - out = strings.ReplaceAll(out, "{{ query }}", q) + out = strings.ReplaceAll(out, "{{ query }}", strings.ReplaceAll(q, "`", "\\`")) out = strings.ReplaceAll(out, "{{ content }}", html) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK)

@@ -165,16 +205,23 @@ }() {

http.Redirect(w, r, "?"+q, http.StatusFound) return } + var args = RxWords("-lw "+q, -1) + for i := range args { args[i] = strings.TrimSpace(args[i]) args[i] = strings.TrimPrefix(args[i], `"`) args[i] = strings.TrimSuffix(args[i], `"`) + if (args[i] == "-r" || args[i] == "-w") && args[0] != "-l" { + args[0] = "-l" + } } - cmd := exec.Command("whatis", args...) + + cmd := exec.Command(CFG.DbCmd, args...) b, e := cmd.Output() if len(b) < 1 || e != nil { - e404.Write(w, r) + fmt.Println(e) + e404.Write(w, r, q) return } var output string

@@ -210,14 +257,17 @@

func HTCode(code int, name string, desc ...string) HTErr { return HTErr{code, name, desc} } -func (h HTErr) Write(w http.ResponseWriter, r *http.Request) { - WriteHtml(w, r, h.Title(), h.Content(), r.URL.RawQuery) +func (h HTErr) Write(w http.ResponseWriter, r *http.Request, s ...string) { + if len(s) == 0 { + s = append(s, r.URL.RawQuery) + } + WriteHtml(w, r, h.Title(), h.Content(), s[0]) } -func (h HTErr) Is(err error, w http.ResponseWriter, r *http.Request) bool { +func (h HTErr) Is(err error, w http.ResponseWriter, r *http.Request, s ...string) bool { if err == nil { return false } - h.Write(w, r) + h.Write(w, r, s...) return true }

@@ -228,7 +278,7 @@ Desc []string

} type NetErr interface { Error() HTErr - Write(w http.ResponseWriter, r *http.Request) + Write(w http.ResponseWriter, r *http.Request, s ...string) } func (e HTErr) Error() HTErr {

@@ -248,31 +298,37 @@ `

return s } -func indexHandler(w http.ResponseWriter, r *http.Request) { +func IndexHandler(w http.ResponseWriter, r *http.Request) { path := filepath.Base(r.URL.Path) path = strings.TrimSuffix(path, "/") - err := r.ParseForm() - if e400.Is(err, w, r) { + if r.Method == "POST" { + err := r.ParseForm() + if !e400.Is(err, w, r) { + searchHandler(w, r) + } return } - if r.Method == "POST" { - searchHandler(w, r) + name := r.URL.RawQuery + + if name == "manweb-help.html" { + WriteHtml(w, r, "Help", help, name) return } - name := r.URL.RawQuery + var nerr NetErr + title := "Index" + var html string if name != "" { man := NewManPage(name) - html, nerr := man.Html() + html, nerr = man.Html() if nerr != nil { - nerr.Write(w, r) + nerr.Write(w, r, name) return } - WriteHtml(w, r, man.Name, html, name) - return + title = man.Name } - WriteHtml(w, r, "Index", "", name) + WriteHtml(w, r, title, html, name) return }
M manhttpd.serviceextra/manhttpd.service

@@ -1,10 +1,10 @@

[Unit] -Description=Server that serves a html man page interface over http +Description=front end service for parsing man into html over http After=network.target [Service] # Path to the command binary -ExecStart=/usr/local/bin/manweb +ExecStart=/usr/bin/manhttpd # Recommended to use /tmp as the working directory since data is not saved WorkingDirectory=/tmp Type=simple
M scripts.jstemplate/scripts.js

@@ -1,5 +1,4 @@

-const style = document.getElementById("styleCss"); -const styleCss = style.innerHTML; +var style = null; const ValidKeys = ["theme", "contrast", "scale"]; const Valid_theme = ["dark", "warm", "light"];

@@ -14,7 +13,7 @@ let value = localStorage.getItem(key);

if (value) tmp += "--" + key + ": " + value + ";\n"; }); tmp += "}\n\n"; - style.innerHTML = tmp + styleCss; + style.innerHTML = tmp; ValidKeys.forEach(key => function (key) { if (butts[key]["arr"].length < 3) return; let value = localStorage.getItem(key);

@@ -122,40 +121,45 @@ };

var butts = Object(); -document.addEventListener("DOMContentLoaded", function () { +function SetButts() { document.querySelectorAll("#settings > div > h3").forEach(elemH => function (elemH,n) { - if (!ValidKeys.includes(n.l)) return; - butts[n.l]= new Object({}); - butts[n.l]["h"] = elemH; - butts[n.l]["div"] = elemH.parentElement; - butts[n.l]["arr"] = []; - butts[n.l]["map"] = new Object({}); - elemH.parentElement.querySelectorAll("button").forEach(butt => function (butt, fun, v) { - let i = butts[n.l]["arr"].push(butt); - butts[n.l]["map"][v] = butts[n.l]["arr"][i-1]; - butt.addEventListener("click", function () {fun(v)}); - }(butt,eval("Set" + n.n), H(butt).l)); + if (!ValidKeys.includes(n.l)) return; + butts[n.l]= new Object({}); + butts[n.l]["h"] = elemH; + butts[n.l]["div"] = elemH.parentElement; + butts[n.l]["arr"] = []; + butts[n.l]["map"] = new Object({}); + elemH.parentElement.querySelectorAll("button").forEach(butt => function (butt, fun, v) { + let i = butts[n.l]["arr"].push(butt); + butts[n.l]["map"][v] = butts[n.l]["arr"][i-1]; + butt.addEventListener("click", function () {fun(v)}); + }(butt,eval("Set" + n.n), H(butt).l)); }(elemH,H(elemH))); - SetStyle(); - SetScale("0"); -}); +} function ChangeRawQuery(s="") { - let u = document.URL; - let i = u.indexOf("?"); - if (s.length > 0 && !s.startsWith("?")) { - s = "?" + s; - } - if (i == -1) { - return u + s; - } - return u.substring(0, i) + s; + + let u = document.URL; + let i = u.indexOf("?"); + if (s.length > 0 && !s.startsWith("?")) { + s = "?" + s; + } + if (i == -1) { + return u + s; + } + return u.substring(0, i) + s; } function SetRawQuery(s="") { - let u = ChangeRawQuery(s); - window.history.pushState({"html":document.toString(),"pageTitle":document.title},"", u); + let u = ChangeRawQuery(s); + window.history.pushState({"html":document.toString(),"pageTitle":document.title},"", u); } function GoToRawQuery(s) { - let u = ChangeRawQuery(s); - window.location.href = u; -}+ let u = ChangeRawQuery(s); + window.location.href = u; +} +document.addEventListener("DOMContentLoaded", function () { + style = document.getElementById("styleCss"); + SetButts(); + SetStyle(); + SetScale("0"); +});
A template/help.html

@@ -0,0 +1,47 @@

+<div class="help"> + + <div> + <h2>Help</h2> + </div> + <div> + <p>To open a page, simply type the name of the page. e.g:</p> + <pre>ls</pre> + <p>Will open the ls page.</p> + </div> + <div> + <p>Wildcard search by using the * character. e.g:</p> + <pre>ls*</pre> + <p>Will list all pages starting with ls.</p> + </div> + <div> + <p>Using multiple wildcards by anding them together. e.g:</p> + <pre>*conf* -a *git*</pre> + <p>Will list all pages with the words conf and git in their name or description.</p> + </div> + <div> + <p>regex search by using the -r flag. e.g:</p> + <pre>-r "^ls"</pre> + <p>Will list all pages starting with ls.</p> + </div> + <div> + <p>Filter by page section by using the -s flag. e.g:</p> + <pre>ls* -s8</pre> + <p>Will list all pages starting with ls in section 8.</p> + </div> + <div> + <p> + Sections are as follows: + </p> + <table> + <tr><td>1</td><td>Executable programs or shell commands</td></tr> + <tr><td>2</td><td>System calls (functions provided by the kernel)</td></tr> + <tr><td>3</td><td>Library calls (functions within program libraries)</td></tr> + <tr><td>4</td><td>Special files (usually found in /dev)</td></tr> + <tr><td>5</td><td>File formats and conventions, e.g. /etc/passwd</td></tr> + <tr><td>6</td><td>Games</td></tr> + <tr><td>7</td><td>Miscellaneous (including macro packages and conventions), e.g. man(7), groff(7), man-pages(7)</td></tr> + <tr><td>8</td><td>System administration commands (usually only for root)</td></tr> + <tr><td>9</td><td>Kernel routines [Non standard]</td></tr> + </table> + </div> +</div>
M theme.csstemplate/theme.css

@@ -1,3 +1,5 @@

+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); + :root{ --dark: #262833;

@@ -18,9 +20,6 @@ --rf-hover: #40d8ff;

--dimmer: color-mix(in srgb, var(--fg-color), var(--bg-color) 70%); --dim: color-mix(in srgb, var(--fg-color), var(--bg-color) 80%); - - font-variant-ligatures: none!important; - --font-family: var(--font, 'JetBrains Mono'); --x-font-size: 18px; @media (min-width: 2000px) {

@@ -47,9 +46,11 @@ }

font-size: calc( var(--scale,1) * var(--x-font-size) ); } + *{ color: var(--fg-color); - font-family: var(--font-family), monospace; + font-variant-ligatures: none!important; + font-family: "JetBrains Mono", monospace; line-height: 1em; } body, html {

@@ -216,4 +217,26 @@ .butt-set {

background-color: var(--it-color); color: var(--bg-color); } +.help { + display: contents; +} +.help > div { + padding: 1lh 0; + margin: 1ch 0; +} +.help pre { + border-radius: 1ch; + padding: 0.1em 1ch; + margin: 0 1ch; + border: 1px var(--fg-color) solid; + width: 15ch; +} +.help td { + padding: 0.2lh 1ch; + line-height: 1.5em; +} +.help h3 { + font-size: 1.15rem; + margin: 0; +}