refactored in preperation for updates
sophuwu sophie@sophuwu.com
Sat, 12 Jul 2025 18:53:39 +0200
10 files changed,
378 insertions(+),
267 deletions(-)
A
CFG/CFG.go
@@ -0,0 +1,78 @@
+package CFG + +import ( + "net/http" + "os" + "os/exec" + "sophuwu.site/manhttpd/neterr" + "strings" +) + +var ( + Hostname string + Port string + Mandoc string + DbCmd string + ManCmd string + Server http.Server + Addr string +) + +func init() { + var e error + var b []byte + var s string + if s = os.Getenv("HOSTNAME"); s != "" { + Hostname = s + } else if s, e = os.Hostname(); e == nil { + Hostname = s + } else if b, e = os.ReadFile("/etc/hostname"); e == nil { + Hostname = strings.TrimSpace(string(b)) + } + if Hostname == "" { + Hostname = "Unresolved" + } + + if b, e = exec.Command("which", func() string { + if s = os.Getenv("MANDOCPATH"); s != "" { + return s + } + return "mandoc" + }()).Output(); e != nil || len(b) == 0 { + neterr.Fatal("dependency `mandoc` not found in $PATH, is it installed?\n") + } else { + 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 == "" { + neterr.Fatal("dependency `man` not found. `man` and its libraries are required for manhttpd to function.") + } else { + ManCmd = s + } + + if s = f("apropos"); s == "" { + neterr.Fatal("dependency `apropos` not found. `apropos` is required for search functionality.") + } else { + DbCmd = s + } + + Port = os.Getenv("ListenPort") + if Port == "" { + Port = "8082" + } + Addr = os.Getenv("ListenAddr") + if Addr == "" { + Addr = "0.0.0.0" + } + + Server = http.Server{ + Addr: Addr + ":" + Port, + Handler: nil, + } +}
A
embeds/embeds.go
@@ -0,0 +1,114 @@
+package embeds + +import ( + "embed" + _ "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "path/filepath" + "sophuwu.site/manhttpd/CFG" + "sophuwu.site/manhttpd/neterr" +) + +//go:embed template/index.html +var index string + +//go:embed template/help.html +var help string + +//go:embed static/* +var static embed.FS + +type StaticFS struct { + ContentType string + Length string + Content []byte +} + +func (s *StaticFS) WriteTo(w http.ResponseWriter) { + w.Header().Set("Content-Type", s.ContentType) + w.Header().Set("Content-Length", s.Length) + w.WriteHeader(http.StatusOK) + w.Write(s.Content) +} + +var constentExt = map[string]string{ + "css": "text/css", + "js": "text/javascript", + "ico": "image/x-icon", +} +var files map[string]StaticFS + +func openStatic() { + d, _ := static.ReadDir("static") + var sfs StaticFS + files = make(map[string]StaticFS, len(d)) + ext := "" + var ok bool + var f fs.DirEntry + for _, f = range d { + sfs = StaticFS{"", "", nil} + if f.IsDir() { + continue + } + ext = filepath.Ext(f.Name())[1:] + if sfs.ContentType, ok = constentExt[ext]; !ok { + continue + } + sfs.Content, _ = static.ReadFile("static/" + f.Name()) + sfs.Length = fmt.Sprint(len(sfs.Content)) + files[ext] = sfs + } + if len(files) != 3 { + neterr.Fatal("Failed to load static files, expected 3 types (css, js, ico), got", len(files)) + } +} + +var t *template.Template + +type Page struct { + Title string + Hostname string + Content template.HTML + Query string +} + +func init() { + openStatic() + var e error + t, e = template.New("index.html").Parse(index) + if e != nil { + neterr.Fatal("Failed to parse index template:", e) + } +} + +func StaticFile(name string) (*StaticFS, bool) { + f, ok := files[name] + return &f, ok +} + +func WriteError(w http.ResponseWriter, r *http.Request, err neterr.NetErr) { + p := Page{ + Title: err.Error().Title(), + Hostname: CFG.Hostname, + Content: template.HTML(err.Error().Content()), + Query: r.URL.RawQuery, + } + t.ExecuteTemplate(w, "index.html", p) +} + +func WriteHtml(w http.ResponseWriter, r *http.Request, title, html string, q string) { + p := Page{ + Title: title, + Hostname: CFG.Hostname, + Content: template.HTML(html), + Query: q, + } + t.ExecuteTemplate(w, "index.html", p) +} +func Help(w http.ResponseWriter, r *http.Request) { + + WriteHtml(w, r, "Help", help, r.URL.RawQuery) +}
M
main.go
→
main.go
@@ -1,205 +1,44 @@
package main import ( - _ "embed" "fmt" "net/http" - "os" "os/exec" - "path/filepath" "regexp" + "sophuwu.site/manhttpd/CFG" + "sophuwu.site/manhttpd/embeds" + "sophuwu.site/manhttpd/manpage" + "sophuwu.site/manhttpd/neterr" "strings" ) -//go:embed template/index.html -var index string - -//go:embed template/help.html -var help string - -//go:embed template/theme.css -var css []byte - -//go:embed template/scripts.js -var scripts []byte - -//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 -} - -func Fatal(v ...interface{}) { - fmt.Fprintln(os.Stderr, "manhttpd exited due to an error it could not recover from.") - fmt.Fprintf(os.Stderr, "Error: %s\n", fmt.Sprint(v...)) - os.Exit(1) -} - -func GetCFG() { - var e error - var b []byte - var s string - if s = os.Getenv("HOSTNAME"); s != "" { - CFG.Hostname = s - } 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)) - } - if CFG.Hostname == "" { - CFG.Hostname = "Unresolved" - } - index = strings.ReplaceAll(index, "{{ hostname }}", CFG.Hostname) - - if b, e = exec.Command("which", func() string { - if s = os.Getenv("MANDOCPATH"); s != "" { - return s - } - return "mandoc" - }()).Output(); e != nil || len(b) == 0 { - 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 == "" { - CFG.Port = "8082" - } - CFG.Addr = os.Getenv("ListenAddr") - if CFG.Addr == "" { - CFG.Addr = "0.0.0.0" - } -} - func main() { - GetCFG() - server := http.Server{ - Addr: CFG.Addr + ":" + CFG.Port, - 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 }}", strings.ReplaceAll(q, "`", "\\`")) - out = strings.ReplaceAll(out, "{{ content }}", html) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, out) -} - -var LinkRemover = regexp.MustCompile(`(<a [^>]*>)|(</a>)`).ReplaceAllString -var HTMLManName = regexp.MustCompile(`(?:<b>)?([a-zA-Z0-9_.:\-]+)(?:</b>)?\(([0-9][0-9a-z]*)\)`) - -type ManPage struct { - Name string - Section string - Desc string - Path string -} - -func (m *ManPage) Where() error { - var arg = []string{"-w", m.Name} - if m.Section != "" { - arg = []string{"-w", "-s" + m.Section, m.Name} - } - b, err := exec.Command("man", arg...).Output() - m.Path = strings.TrimSpace(string(b)) - return err -} -func (m *ManPage) Html() (string, NetErr) { - if m.Where() != nil { - return "", e404 - } - b, err := exec.Command(CFG.Mandoc, "-Thtml", "-O", "fragment", m.Path).Output() + CFG.Server.Handler = ManHandler{} + err := CFG.Server.ListenAndServe() if err != nil { - return "", e500 - } - html := LinkRemover(string(b), "") - html = HTMLManName.ReplaceAllStringFunc(html, func(s string) string { - m := HTMLManName.FindStringSubmatch(s) - return fmt.Sprintf(`<a href="?%s.%s">%s(%s)</a>`, m[1], m[2], m[1], m[2]) - }) - return html, nil -} - -var ManDotName = regexp.MustCompile(`^([a-zA-Z0-9_\-]+)(?:\.([0-9a-z]+))?$`) - -func NewManPage(s string) (m ManPage) { - name := ManDotName.FindStringSubmatch(s) - if len(name) >= 2 { - m.Name = name[1] + fmt.Println("Error starting server:", err) } - if len(name) >= 3 { - m.Section = name[2] - } - return } var RxWords = regexp.MustCompile(`("[^"]+")|([^ ]+)`).FindAllString var RxWhatIs = regexp.MustCompile(`([a-zA-Z0-9_\-]+) [(]([0-9a-z]+)[)][\- ]+(.*)`).FindAllStringSubmatch -func searchHandler(w http.ResponseWriter, r *http.Request) { +func SearchHandler(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if neterr.Err400.Is(err) { + embeds.WriteError(w, r, neterr.Err400) + return + } q := r.Form.Get("q") if q == "" { http.Redirect(w, r, r.URL.Path, http.StatusFound) } + if strings.HasPrefix(q, "manweb:") { + http.Redirect(w, r, "?"+q, http.StatusFound) + return + } if func() bool { - m := NewManPage(q) + m := manpage.New(q) return m.Where() == nil }() { http.Redirect(w, r, "?"+q, http.StatusFound)@@ -220,8 +59,7 @@
cmd := exec.Command(CFG.DbCmd, args...) b, e := cmd.Output() if len(b) < 1 || e != nil { - fmt.Println(e) - e404.Write(w, r, q) + embeds.WriteError(w, r, neterr.Err404) return } var output string@@ -230,105 +68,55 @@ if len(line) == 4 {
output += fmt.Sprintf(`<p><a href="?%s.%s">%s (%s)</a> - %s</p>%c`, line[1], line[2], line[1], line[2], line[3], 10) } } - WriteHtml(w, r, "Search", output, q) + embeds.WriteHtml(w, r, "Search", output, q) } -var ( - e400 = HTCode(400, "Bad Request", - "Your request cannot be understood by the server.", - "Check that you are using a release version of manhttpd.", - "Please check spelling and try again.", - "Otherwise browser extensions or proxies may be hijacking your requests.", - ) - e404 = HTCode(404, "Not Found", - "The requested does match any known page names. Please check your spelling and try again.", - `If you cannot find the page using your system's man command, then you may need to update your manDB or apt-get <b><package>-doc</b>.`, - "If you can open a page using the cli but not in manhttpd, your service is misconfigured. For best results set user and group to your login user:", - `You can edit <<b>/etc/systemd/system/manhttpd.service</b>> and set "<b>User</b>=<<b>your-user</b>>" and "<b>Group</b>=<<b>your-group</b>>".`, - `Usually root user will work just fine, however root does not index user pages. If manuals are installed without superuser, they are saved to <<b>$HOME/.local/share/man/</b>>.`, - `If you want user pages you have to run manhttpd as your login user. If you really want to run the service as root with user directories, at your own risk: adding users' homes into the global path <<b>/etc/manpath.config</b>> is usually safe but may cause catastrophic failure on some systems.`, - ) - e500 = HTCode(500, "Internal Server Error", - "The server encountered an error and could not complete your request.", - "Make sure you are using a release version of manhttpd.", - ) -) - -func HTCode(code int, name string, desc ...string) HTErr { - return HTErr{code, name, desc} -} -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, s ...string) bool { - if err == nil { - return false - } - h.Write(w, r, s...) - return true +type ManHandle interface { + ServeHTTP(http.ResponseWriter, *http.Request) } -type HTErr struct { - Code int - Name string - Desc []string -} -type NetErr interface { - Error() HTErr - Write(w http.ResponseWriter, r *http.Request, s ...string) +type ManHandler struct { } -func (e HTErr) Error() HTErr { - return e -} -func (e HTErr) Title() string { - return fmt.Sprintf("%d %s", e.Code, e.Name) -} -func (e HTErr) Content() string { - s := fmt.Sprintf("<h1>%3d</h1><h2>%s</h2><br>\n", e.Code, e.Name) - for d := range e.Desc { - s += fmt.Sprintf("<p>%s</p><br>\n", e.Desc[d]) +func (m ManHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // func IndexHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("static") { + StaticHandler(w, r) + return } - s += `<script>SetRawQuery()</script> -` - return s -} - -func IndexHandler(w http.ResponseWriter, r *http.Request) { - path := filepath.Base(r.URL.Path) - path = strings.TrimSuffix(path, "/") - if r.Method == "POST" { - err := r.ParseForm() - if !e400.Is(err, w, r) { - searchHandler(w, r) - } + SearchHandler(w, r) return } - name := r.URL.RawQuery - - if name == "manweb-help.html" { - WriteHtml(w, r, "Help", help, name) + if name == "manweb:help" { + embeds.Help(w, r) return } - var nerr NetErr + var nerr neterr.NetErr title := "Index" var html string if name != "" { - man := NewManPage(name) + man := manpage.New(name) html, nerr = man.Html() if nerr != nil { - nerr.Write(w, r, name) + embeds.WriteError(w, r, nerr) return } title = man.Name } - WriteHtml(w, r, title, html, name) + embeds.WriteHtml(w, r, title, html, name) return } + +func StaticHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("static") + if f, ok := embeds.StaticFile(q); ok { + w.Header().Set("Content-Type", f.ContentType) + w.Header().Set("Content-Length", f.Length) + f.WriteTo(w) + return + } +}
A
manpage/manpage.go
@@ -0,0 +1,58 @@
+package manpage + +import ( + "fmt" + "os/exec" + "regexp" + "sophuwu.site/manhttpd/CFG" + "sophuwu.site/manhttpd/neterr" + "strings" +) + +var LinkRemover = regexp.MustCompile(`(<a [^>]*>)|(</a>)`).ReplaceAllString +var HTMLManName = regexp.MustCompile(`(?:<b>)?([a-zA-Z0-9_.:\-]+)(?:</b>)?\(([0-9][0-9a-z]*)\)`) + +type ManPage struct { + Name string + Section string + Desc string + Path string +} + +func (m *ManPage) Where() error { + var arg = []string{"-w", m.Name} + if m.Section != "" { + arg = []string{"-w", "-s" + m.Section, m.Name} + } + b, err := exec.Command("man", arg...).Output() + m.Path = strings.TrimSpace(string(b)) + return err +} +func (m *ManPage) Html() (string, neterr.NetErr) { + if m.Where() != nil { + return "", neterr.Err404 + } + b, err := exec.Command(CFG.Mandoc, "-Thtml", "-O", "fragment", m.Path).Output() + if err != nil { + return "", neterr.Err500 + } + html := LinkRemover(string(b), "") + html = HTMLManName.ReplaceAllStringFunc(html, func(s string) string { + m := HTMLManName.FindStringSubmatch(s) + return fmt.Sprintf(`<a href="?%s.%s">%s(%s)</a>`, m[1], m[2], m[1], m[2]) + }) + return html, nil +} + +var ManDotName = regexp.MustCompile(`^([a-zA-Z0-9_\-]+)(?:\.([0-9a-z]+))?$`) + +func New(s string) (m ManPage) { + name := ManDotName.FindStringSubmatch(s) + if len(name) >= 2 { + m.Name = name[1] + } + if len(name) >= 3 { + m.Section = name[2] + } + return +}
A
neterr/neterr.go
@@ -0,0 +1,71 @@
+package neterr + +import ( + "fmt" + "os" +) + +func Fatal(v ...interface{}) { + fmt.Fprintln(os.Stderr, "manhttpd exited due to an error it could not recover from.") + fmt.Fprintf(os.Stderr, "Error: %s\n", fmt.Sprint(v...)) + os.Exit(1) +} + +var ( + Err400 = HTCode(400, "Bad Request", + "Your request cannot be understood by the server.", + "Check that you are using a release version of manhttpd.", + "Please check spelling and try again.", + "Otherwise browser extensions or proxies may be hijacking your requests.", + ) + Err404 = HTCode(404, "Not Found", + "The requested does match any known page names. Please check your spelling and try again.", + `If you cannot find the page using your system's man command, then you may need to update your manDB or apt-get <b><package>-doc</b>.`, + "If you can open a page using the cli but not in manhttpd, your service is misconfigured. For best results set user and group to your login user:", + `You can edit <<b>/etc/systemd/system/manhttpd.service</b>> and set "<b>User</b>=<<b>your-user</b>>" and "<b>Group</b>=<<b>your-group</b>>".`, + `Usually root user will work just fine, however root does not index user pages. If manuals are installed without superuser, they are saved to <<b>$HOME/.local/share/man/</b>>.`, + `If you want user pages you have to run manhttpd as your login user. If you really want to run the service as root with user directories, at your own risk: adding users' homes into the global path <<b>/etc/manpath.config</b>> is usually safe but may cause catastrophic failure on some systems.`, + ) + Err500 = HTCode(500, "Internal Server Error", + "The server encountered an error and could not complete your request.", + "Make sure you are using a release version of manhttpd.", + ) +) + +func HTCode(code int, name string, desc ...string) NetErr { + return HTErr{code, name, desc} +} + +func (h HTErr) Is(err error) bool { + if err == nil { + return false + } + return true +} + +type HTErr struct { + Code int + Name string + Desc []string +} +type NetErr interface { + Error() HTErr + Is(error) bool +} + +func (e HTErr) Error() HTErr { + return e +} +func (e HTErr) Title() string { + return fmt.Sprintf("%d %s", e.Code, e.Name) +} +func (e HTErr) Content() string { + s := fmt.Sprintf("<h1>%3d</h1><h2>%s</h2><br>\n", e.Code, e.Name) + for d := range e.Desc { + s += fmt.Sprintf("<p>%s</p><br>\n", e.Desc[d]) + } + + s += `<script>SetRawQuery()</script> +` + return s +}
M
template/help.html
→
embeds/template/help.html
@@ -44,4 +44,4 @@ <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> +</div>
M
template/index.html
→
embeds/template/index.html
@@ -1,29 +1,30 @@
+{{ define "index.html" }} <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> - <title>{{ title }}@{{ hostname }}</title> + <title>{{ .Title }}@{{ .Hostname }}</title> <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"> + <link rel="stylesheet" href="?static=css" type="text/css"> + <script src="?static=js"></script> + <link rel="icon" href="?static=ico" type="image/x-icon"> <style id="styleCss"></style> </head> <body> <header class="menu" id="search"> <div> - <h3>ManWeb @{{ hostname }}</h3> + <h3 style="cursor: pointer;" onclick="GoToRawQuery('');">ManWeb @{{ .Hostname }}</h3> </div> <form class="rounded" method="post"> <input type="text" class="txt" name="q" autocomplete="off" > <input type="submit" class="submit" value="Search"> </form> <div class="rounded"> - <button onclick="GoToRawQuery('manweb-help.html')">Help</button> + <button onclick="GoToRawQuery('manweb:help')">Help</button> <button onclick="Menu(H(this).l)">Settings</button> </div> <script> - document.querySelector('input[name="q"]').value = `{{ query }}`; + document.querySelector('input[name="q"]').value = `{{ .Query }}`; </script> </header> <header class="settings menu hidden" id="settings">@@ -62,7 +63,8 @@ </div>
</div> </header> <main id="main-content"> - {{ content }} + {{ .Content }} </main> </body> </html> +{{ end }}
M
template/scripts.js
→
embeds/static/scripts.js
@@ -172,7 +172,7 @@ let h = document.getElementById("index")
h.outerHTML = a } function makeIndex (){ -document.querySelector("#NAME").parentElement.innerHTML+=`<p><a onclick="index()" id="index">Create Index</a></p>` +document.querySelector("#NAME").parentElement.outerHTML+=`<p><a onclick="index()" id="index">Create Index</a></p>` let e=document.querySelector("#index") }