261026.29 · 4f85fd9266a0fa0900cfe26382060346ed69bf53
Bugs are numbered, always at hand, and take comments
The bug button leaves --debug for the foot of every page; --no-bug hides it, and a share never has it. A report takes the next number, writes N.kv as keyval beside its N.html snapshot, and says which number it got; a field beside the button then takes a comment for N.kv and the log.
reading declarations
@@ -36,8 +36,9 @@ To reach the server by name, add a line such as `127.31.1.1 www` to `/etc/hosts` - `--secret`: serve a shared tree under the token in `WWW_SECRET`, or a random one, so only those holding the link reach it - `--foreground`: when no server runs on the address yet, serve in the foreground until Ctrl-C - `--no-open`: print the URL without opening a browser+- `--no-bug`: hide the bug button at the foot of each page and refuse reports; `--restart` passes it to the new servers when given - `-p`, `--persist`: write a persistent lock, such as `127.31.1.1-8080.LOCK`, so the tree is served again after a reboot; see [Serving after reboot](#serving-after-reboot)-- `--debug`: show what serves debugging: above each page's header, the build stamp and a bug button, see [debugging](doc/debugging.md); on the index, each server's build and start time and each lock's time. `make run` sets it; `--restart` passes it to the new servers when given, and leaves it off otherwise+- `--debug`: show what serves debugging: above each page's header, the build stamp, see [debugging](doc/debugging.md); on the index, each server's build and start time and each lock's time. `make run` sets it; `--restart` passes it to the new servers when given, and leaves it off otherwise - `--cache=SIZE`: memory a new server may spend on cached git answers, eg `64M`; `0` caches nothing. The default is `32M`, and `--restart` goes back to it - `--disk=SIZE`: disk a new server may fill with online repos, eg `4G`; it drops the repos read longest ago. The default is `2G`, and `--restart` goes back to it - `--restart`: replace every server this binary started with one running it now, serving the same trees, and print each tree's URL; an address with persistent trees and no server gets one, when this binary served it last; with `-a` or `--share`, only the server on that address, whichever binary runs it; with a directory, it also serves that directory there and opens it in the browser unless a page of it was open, and says which@@ -79,7 +80,7 @@ The package also ships a system template, `www@.service`, which serves a user's ## Debugging -`--debug` puts a build stamp and a bug button above each page. The button logs the page as a bug, with a snapshot of what you saw. See [doc/debugging.md](doc/debugging.md).+A faint bug button at the foot of every page files the page as a numbered bug, with a snapshot of what you saw and an optional comment, in `~/.local/state/www/bugs/` and the log. `--debug` adds a build stamp above each page. See [doc/debugging.md](doc/debugging.md). ## Online repos
@@ -8,7 +8,7 @@ {{with .Live}}<script data-live="{{if not $.Fixed}}{{.}}{{end}}">{{liveJS}}</script> {{end}}</head> <body class="{{.Kind}}">-{{with .Built}}<p class="built" data-built="{{.Millis}}">built {{.Clock}}, <span>{{.Ago}}</span> s ago, from {{.Commit}}{{with .Rendered}}, rendered in {{.}}{{end}} <button class="bug" data-post="{{ownPath}}bug" title="log this page as a bug, with the selected text as its note">bug</button></p>+{{with .Built}}<p class="built" data-built="{{.Millis}}">built {{.Clock}}, <span>{{.Ago}}</span> s ago, from {{.Commit}}{{with .Rendered}}, rendered in {{.}}{{end}}</p> {{end}}<header> <nav>{{range $i, $c := .Crumbs}}{{if $i}} / {{end}}{{if $c.Href}}<a href="{{$c.Href}}"{{with $c.Title}} title="{{.}}"{{end}}>{{$c.Label}}</a>{{else if $c.Title}}<span title="{{$c.Title}}">{{$c.Label}}</span>{{else}}<span>{{$c.Label}}</span>{{end}}{{end}}</nav> {{if not .Stopped}}<span>{{with .Status}}<span class="age">{{.}}</span>{{end}}{{if .Raw}}<a href="?raw">raw</a>{{end}}{{if not (or .Shared .NoStop)}}<form method="post" action="{{ownPath}}stop">{{if .Tree}}<input type="hidden" name="tree" value="{{.Tree}}"><button>Delete lock</button>{{else}}<button class="kill">Kill server</button>{{end}}</form>{{end}}</span>{{end}}@@ -17,6 +17,8 @@ {{end}}{{end}}<main> {{.Body}} </main>+{{if .Bug}}<p class="bug"><button class="bug" data-post="{{ownPath}}bug" title="file this page as a bug, with the selected text as its note">bug</button></p>+{{end}} </body> </html> {{end}}
@@ -514,11 +514,40 @@ backlinks(); }); - // The bug button, in the stamp with --debug, logs the page as a bug:- // its URL, the selected text as the note, the window's size and scroll,- // the browser, and a snapshot of the page as it stands, scripts taken- // out, so the snapshot shows what the reader saw without running again.- // It says logged or failed, then bug again.+ // The bug button, at each page's foot, files the page as a numbered+ // bug: its URL, the selected text as the note, the window's size and+ // scroll, the browser, and a snapshot of the page as it stands,+ // scripts taken out, so the snapshot shows what the reader saw without+ // running again. The button then says the number, and a field beside it+ // takes a comment: Enter adds it, Escape or leaving the field drops it.+ const bugSaid = (b, word) => {+ b.textContent = word;+ setTimeout(() => (b.textContent = "bug"), 3000);+ };+ const bugComment = (b, n) => {+ const f = document.createElement("input");+ f.placeholder = "comment on bug " + n;+ f.setAttribute("aria-label", "comment on bug " + n);+ b.before(f);+ f.focus();+ let done = false;+ const end = () => {+ if (done) return;+ done = true;+ f.remove();+ };+ f.addEventListener("keydown", (e) => {+ if (e.key === "Escape") return end();+ if (e.key !== "Enter" || !f.value.trim()) return;+ const body = new URLSearchParams({ bug: n, comment: f.value.trim() });+ end();+ fetch(b.dataset.post, { method: "POST", body }).then(+ (r) => bugSaid(b, r.ok ? "comment on bug " + n + " added" : "comment failed"),+ () => bugSaid(b, "comment failed"),+ );+ });+ f.addEventListener("blur", end);+ }; document.addEventListener("click", (e) => { const b = e.target.closest("button.bug"); if (!b) return;@@ -536,11 +565,15 @@ ua: navigator.userAgent, page: "<!doctype html>\n" + page.outerHTML, });- const said = (word) => {- b.textContent = word;- setTimeout(() => (b.textContent = "bug"), 2000);- };- fetch(b.dataset.post, { method: "POST", body }).then((r) => said(r.ok ? "logged" : "failed"), () => said("failed"));+ fetch(b.dataset.post, { method: "POST", body })+ .then((r) => (r.ok ? r.text() : Promise.reject()))+ .then(+ (n) => {+ bugSaid(b, "bug " + n + " filed");+ bugComment(b, n);+ },+ () => bugSaid(b, "failed"),+ ); }); // The build stamp counts the seconds since the binary was built. It is
@@ -151,7 +151,7 @@ header > span { display: inline-flex; gap: 1em; align-items: baseline; flex: non header form { display: inline; } /* Buttons are flat, as on printed paper: one border in the text's colour, square corners, no bevel. */-header button, table.locks button, p.built button.bug {+header button, table.locks button, p.bug button.bug { font: inherit; color: inherit; background: var(--page);@@ -161,9 +161,12 @@ header button, table.locks button, p.built button.bug { cursor: pointer; white-space: nowrap; }-/* The bug button is flat as every button is, but in the stamp's faint- colour and with little padding, so it stays as quiet as the stamp. */-p.built button.bug { color: inherit; border-color: currentColor; padding: 0 0.4em; margin-left: 0.4em; }+/* The bug button sits at the page's foot, flat as every button is, but+ small, faint and with little padding, so it never draws the eye from+ the page. After a report, a field beside it takes a comment. */+p.bug { font-family: ui-monospace, "Iosevka", monospace; font-size: 0.45em; color: var(--faint); text-align: right; margin: 3em 0 0; }+p.bug button.bug { color: inherit; border-color: currentColor; padding: 0 0.4em; }+p.bug input { font: inherit; color: var(--text); background: var(--page); border: 1px solid var(--faint); border-radius: 0; padding: 0 0.4em; margin-right: 0.6em; width: 30ch; max-width: 60%; } /* What the program says, marked own, is mono, file names in a listing included; what the document says is serif. */ pre, code, header, div.search, .own, ul.listing, p.built, div.found p, p.unfound, table.gone { font-family: ui-monospace, "Iosevka", monospace; }
@@ -1,7 +1,9 @@ package main import (+ "errors" "fmt"+ "io/fs" "net/http" "os" "path/filepath"@@ -14,11 +16,13 @@ import ( // 10,907 lines, is about 2 MB of HTML. const maxBug = 16 << 20 -// handleBug logs a page as a bug, from the bug button with --debug: one-// line in the log, which `journalctl --user -t www -g '^bug:'` finds, and-// the page as the reader saw it in a file under the state directory. The-// line names the URL, the selected text as the note, the window's size-// and scroll, the browser, the build and the snapshot's path.+// handleBug files a page as a numbered bug, from the bug button at each+// page's foot, or adds a comment to one. A report is one line in the log,+// which `journalctl --user -t www -g '^bug '` finds; N.kv under the state+// directory, as keyval: the time, URL, selected text as the note, the+// window's size and scroll, the browser and the build; and N.html, the+// page as the reader saw it. It answers with the number. A comment names+// the number and is added to the log and to N.kv. func (s *server) handleBug(w http.ResponseWriter, r *http.Request) { if !sameOrigin(r) { http.Error(w, "a bug report must come from an "+progName+" page", http.StatusForbidden)@@ -30,38 +34,134 @@ func (s *server) handleBug(w http.ResponseWriter, r *http.Request) { logErr("bug report refused: %s", err) return }- snap, err := saveSnapshot(r.PostFormValue("page"))+ dir, err := bugDir() if err != nil {- logErr("bug snapshot not saved: %s", err)- snap = "none"+ http.Error(w, err.Error(), http.StatusInternalServerError)+ logErr("bug report not filed: %s", err)+ return+ }+ if n := r.PostFormValue("bug"); n != "" {+ comment := clip(r.PostFormValue("comment"))+ if err := commentBug(dir, n, comment); err != nil {+ status := http.StatusInternalServerError+ if errors.Is(err, fs.ErrNotExist) {+ status = http.StatusNotFound+ }+ http.Error(w, err.Error(), status)+ return+ }+ logInfo("bug %s comment: %s", n, strconv.Quote(comment))+ w.WriteHeader(http.StatusNoContent)+ return }- note := r.PostFormValue("note")- if len(note) > 500 {- note = note[:500] + "..."+ n, err := fileBug(dir, [][2]string{+ {"time", time.Now().Format(time.RFC3339)},+ {"url", r.PostFormValue("url")},+ {"note", clip(r.PostFormValue("note"))},+ {"view", r.PostFormValue("view")},+ {"ua", r.PostFormValue("ua")},+ {"build", buildCommit},+ })+ if err != nil {+ http.Error(w, err.Error(), http.StatusInternalServerError)+ logErr("bug report not filed: %s", err)+ return+ }+ snap := filepath.Join(dir, strconv.Itoa(n)+".html")+ if err := saveSnapshot(snap, r.PostFormValue("page")); err != nil {+ logErr("bug %d snapshot not saved: %s", n, err)+ snap = "none" }- logInfo("bug: %s note=%s view=%s ua=%s build=%s snapshot=%s", r.PostFormValue("url"), strconv.Quote(note),+ logInfo("bug %d: %s note=%s view=%s ua=%s build=%s snapshot=%s", n, r.PostFormValue("url"), strconv.Quote(clip(r.PostFormValue("note"))), r.PostFormValue("view"), strconv.Quote(r.PostFormValue("ua")), buildCommit, snap)- w.WriteHeader(http.StatusNoContent)+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")+ fmt.Fprint(w, n) } -// saveSnapshot writes page, a page's HTML as the browser held it, to-// $XDG_STATE_HOME/www/bugs, named by the time, and returns its path. The-// stylesheet goes inline, so the file shows as it did under a later-// build, whose stylesheet has another name.-func saveSnapshot(page string) (string, error) {- if page == "" {- return "", fmt.Errorf("the report holds no page")+// clip cuts a note or comment to 2,000 bytes, so a stray paste of a whole+// page does not fill the log.+func clip(s string) string {+ if len(s) > 2000 {+ return s[:2000] + "..." }+ return s+}++// bugDir is where bug reports go, $XDG_STATE_HOME/www/bugs, made on first+// use.+func bugDir() (string, error) { base, err := stateDir() if err != nil { return "", err } dir := filepath.Join(base, progName, "bugs")- if err := os.MkdirAll(dir, 0o700); err != nil {- return "", err+ return dir, os.MkdirAll(dir, 0o700)+}++// fileBug writes fields as keyval to the next number's N.kv and returns+// the number. The counter in next only grows, so a number deleted once+// its bug is fixed is never given again. Creating N.kv exclusively keeps+// two servers that file at once from taking one number.+func fileBug(dir string, fields [][2]string) (int, error) {+ n := 1+ if b, err := os.ReadFile(filepath.Join(dir, "next")); err == nil {+ if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil && v > 0 {+ n = v+ }+ }+ var b strings.Builder+ for _, f := range fields {+ fmt.Fprintf(&b, "%s %s\n", f[0], kvValue(f[1]))+ }+ for ; ; n++ {+ f, err := os.OpenFile(filepath.Join(dir, strconv.Itoa(n)+".kv"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)+ if errors.Is(err, fs.ErrExist) {+ continue+ }+ if err != nil {+ return 0, err+ }+ _, err = f.WriteString(b.String())+ if cerr := f.Close(); err == nil {+ err = cerr+ }+ if err != nil {+ return 0, err+ }+ return n, os.WriteFile(filepath.Join(dir, "next"), []byte(strconv.Itoa(n+1)+"\n"), 0o600)+ }+}++// commentBug adds comment to bug n's N.kv as the next comment.N key.+func commentBug(dir, n, comment string) error {+ if v, err := strconv.Atoi(n); err != nil || v < 1 {+ return fmt.Errorf("no bug %q", n)+ }+ p := filepath.Join(dir, n+".kv")+ b, err := os.ReadFile(p)+ if err != nil {+ return err+ }+ i := strings.Count("\n"+string(b), "\ncomment.")+ f, err := os.OpenFile(p, os.O_WRONLY|os.O_APPEND, 0o600)+ if err != nil {+ return err+ }+ _, err = fmt.Fprintf(f, "comment.%d %s\n", i, kvValue(comment))+ if cerr := f.Close(); err == nil {+ err = cerr+ }+ return err+}++// saveSnapshot writes page, a page's HTML as the browser held it, to p.+// The stylesheet goes inline, so the file shows as it did under a later+// build, whose stylesheet has another name.+func saveSnapshot(p, page string) error {+ if page == "" {+ return fmt.Errorf("the report holds no page") } css := strings.ReplaceAll(string(styleCSS), `url("fonts/`, `url("`+ownPath+`fonts/`) page = strings.Replace(page, `<link rel="stylesheet" href="`+styleURL+`">`, "<style>"+css+"</style>", 1)- p := filepath.Join(dir, time.Now().Format("20060102-150405.000")+".html")- return p, os.WriteFile(p, []byte(page), 0o600)+ return os.WriteFile(p, []byte(page), 0o600) }
@@ -21,15 +21,14 @@ func postBug(h http.Handler, form url.Values, site string) *httptest.ResponseRec return rec } -// With --debug, each page's stamp holds the bug button, and a report-// writes one line to the log and the page, its stylesheet inline, to the-// state directory. Without --debug, neither the button nor its address-// is there.+// Every page's foot holds the bug button. A report gets the next number,+// one line in the log, N.kv and N.html, its stylesheet inline; a comment+// joins N.kv and the log. A deleted bug's number is not given again. func TestBugReport(t *testing.T) { useJournal(t, "") state := t.TempDir() t.Setenv("XDG_STATE_HOME", state)- defer func() { debugOn = false }()+ dir := filepath.Join(state, progName, "bugs") form := url.Values{ "url": {"http://127.31.1.1:8080/nowhere.md"}, "note": {"the coin\nis cut off"},@@ -37,39 +36,77 @@ func TestBugReport(t *testing.T) { "ua": {"Brave"}, "page": {`<!doctype html><html><head><link rel="stylesheet" href="` + styleURL + `"></head><body>seen</body></html>`}, }- s, _, _ := serveTree(t)- if strings.Contains(get(s.handler(), "/").Body.String(), "button class=\"bug\"") {- t.Errorf("bug button without --debug")- }- if rec := postBug(s.handler(), form, "same-origin"); rec.Code == http.StatusNoContent {- t.Errorf("bug report taken without --debug")- }-- debugOn = true- s, _, _ = serveTree(t) h := s.handler()- if !strings.Contains(get(h, "/").Body.String(), `<button class="bug" data-post="`+ownPath+`bug"`) {- t.Errorf("no bug button with --debug")+ if !strings.Contains(get(h, "/").Body.String(), `<p class="bug"><button class="bug" data-post="`+ownPath+`bug"`) {+ t.Errorf("no bug button at the foot") } if rec := postBug(h, form, "cross-site"); rec.Code != http.StatusForbidden { t.Errorf("cross-site report: status %d, want 403", rec.Code) }- if rec := postBug(h, form, "same-origin"); rec.Code != http.StatusNoContent {- t.Fatalf("report: status %d: %s", rec.Code, rec.Body.String())- }- snaps, _ := filepath.Glob(filepath.Join(state, progName, "bugs", "*.html"))- if len(snaps) != 1 {- t.Fatalf("snapshots %v, want one", snaps)+ rec := postBug(h, form, "same-origin")+ if rec.Code != http.StatusOK || rec.Body.String() != "1" {+ t.Fatalf("report: status %d body %q, want 200 and 1", rec.Code, rec.Body.String()) }- b, _ := os.ReadFile(snaps[0])+ b, _ := os.ReadFile(filepath.Join(dir, "1.html")) if !strings.Contains(string(b), "<style>") || strings.Contains(string(b), styleURL) || !strings.Contains(string(b), "seen") { t.Errorf("snapshot lacks the inline stylesheet or the page: %.200s", b) }+ kv, _ := os.ReadFile(filepath.Join(dir, "1.kv"))+ for _, want := range []string{"url http://127.31.1.1:8080/nowhere.md\n", `note the coin\nis cut off` + "\n", "view 1280x800+0\n", "ua Brave\n", "build " + buildCommit + "\n", "time 20"} {+ if !strings.Contains(string(kv), want) {+ t.Errorf("1.kv lacks %q:\n%s", want, kv)+ }+ }+ if rec := postBug(h, url.Values{"bug": {"1"}, "comment": {"after a restart"}}, "same-origin"); rec.Code != http.StatusNoContent {+ t.Errorf("comment: status %d", rec.Code)+ }+ postBug(h, url.Values{"bug": {"1"}, "comment": {"again"}}, "same-origin")+ if kv, _ := os.ReadFile(filepath.Join(dir, "1.kv")); !strings.HasSuffix(string(kv), "comment.0 after a restart\ncomment.1 again\n") {+ t.Errorf("comments not in 1.kv:\n%s", kv)+ }+ for _, n := range []string{"9", "../next", "0"} {+ if rec := postBug(h, url.Values{"bug": {n}, "comment": {"x"}}, "same-origin"); rec.Code == http.StatusNoContent {+ t.Errorf("comment on bug %q taken", n)+ }+ }+ os.Remove(filepath.Join(dir, "1.kv"))+ if rec := postBug(h, form, "same-origin"); rec.Body.String() != "2" {+ t.Errorf("after deleting bug 1, got number %q, want 2", rec.Body.String())+ } path, _ := logFile() log, _ := os.ReadFile(path)- want := `bug: http://127.31.1.1:8080/nowhere.md note="the coin\nis cut off" view=1280x800+0 ua="Brave" build=` + buildCommit + " snapshot=" + snaps[0]- if !strings.Contains(string(log), want) {- t.Errorf("log lacks %q:\n%s", want, log)+ for _, want := range []string{+ `bug 1: http://127.31.1.1:8080/nowhere.md note="the coin\nis cut off" view=1280x800+0 ua="Brave" build=` + buildCommit + " snapshot=" + filepath.Join(dir, "1.html"),+ `bug 1 comment: "after a restart"`,+ "bug 2: ",+ } {+ if !strings.Contains(string(log), want) {+ t.Errorf("log lacks %q:\n%s", want, log)+ }+ }+}++// --no-bug hides the button and refuses reports; a share never has either.+func TestNoBug(t *testing.T) {+ t.Setenv("XDG_STATE_HOME", t.TempDir())+ form := url.Values{"url": {"x"}, "page": {"x"}}+ defer func() { bugOff = false }()+ bugOff = true+ s, _, _ := serveTree(t)+ if strings.Contains(get(s.handler(), "/").Body.String(), `class="bug"`) {+ t.Errorf("bug button with --no-bug")+ }+ if rec := postBug(s.handler(), form, "same-origin"); rec.Code == http.StatusOK {+ t.Errorf("report taken with --no-bug")+ }+ bugOff = false+ s, tr, _ := serveTree(t)+ s.shared, tr.shared = true, true+ if strings.Contains(get(s.handler(), "/").Body.String(), `class="bug"`) {+ t.Errorf("bug button on a share")+ }+ if rec := postBug(s.handler(), form, "same-origin"); rec.Code == http.StatusOK {+ t.Errorf("report taken on a share") } }
@@ -1,11 +1,24 @@ # Debugging -With `--debug`, each page says when its binary was built and how many seconds ago, the commit it was built from and how long the page took to render, eg `built 12:49, 108 s ago, from 1aa282c+modified, rendered in 3.3 ms`. It sits small and faint above the header, so a glance tells which build served the page. `+modified` marks a build from uncommitted code. www's script counts the seconds up while the page is open. Shares leave the stamp out.+## Bug reports++A small, faint bug button sits at the foot of every page. A click files the page as a numbered bug, and the button says which, eg `bug 7 filed`. Text selected on the page goes in as the note. A field then opens beside the button for a comment: Enter adds it, Escape or clicking away leaves the bug without one.++Each report lands in `~/.local/state/www/bugs/`, or below `$XDG_STATE_HOME` when set:++- `7.kv`: the report as keyval: the time, the page's URL, the note, the window's size and scroll as `view`, the browser as `ua`, the build, and each comment as `comment.0`, `comment.1` and on+- `7.html`: the page as it stood at the click, script changes included, with its stylesheet and no script, so it opens as you saw it+- `next`: the number the next report gets. Numbers only grow, so a bug deleted once fixed never gives its number away -A bug button ends the stamp. It logs the page as a bug: one line in the log, and a snapshot of the page as it stands, script changes included, in `~/.local/state/www/bugs/`. Text selected on the page goes in as the note. Find the reports with `journalctl --user -t www -g '^bug:'`:+Each report and comment is also one line in the log. Find them with `journalctl --user -t www -g '^bug '`: ```-bug: http://127.31.1.1/x/nowhere.md note="the coin is cut off" view=1280x800+0 ua="Mozilla/5.0 ..." build=1aa282c snapshot=/home/t/.local/state/www/bugs/20261026-142821.746.html+bug 7: http://127.31.1.1/x/nowhere.md note="the coin is cut off" view=1280x800+0 ua="Mozilla/5.0 ..." build=1aa282c snapshot=/home/t/.local/state/www/bugs/7.html+bug 7 comment: "after a restart" ``` -`view` is the window's size and how far it was scrolled. The snapshot holds its stylesheet and no script, so it opens as the reader saw it.+`--no-bug` hides the button and refuses reports. A share never shows it, so no visitor writes files on your machine.++## Build stamp++With `--debug`, each page says when its binary was built and how many seconds ago, the commit it was built from and how long the page took to render, eg `built 12:49, 108 s ago, from 1aa282c+modified, rendered in 3.3 ms`. It sits small and faint above the header, so a glance tells which build served the page. `+modified` marks a build from uncommitted code. www's script counts the seconds up while the page is open. Shares leave the stamp out.
@@ -20,7 +20,7 @@ Each server's start with its binary and commit, eg `/usr/bin/www 54cca2a`, and ` Each tree served and withdrawn, with the cause: a deleted lock, a Delete lock or Kill server button, or a signal. Then the server's stop and exit. Stale locks and registry links cleared after a crash, and the SIGTERM that `--kill` and `--restart` send. Every page request, with method, path, status and time taken. Live reload streams, the stylesheet, fonts and the favicon come with every page and are left out. A shared server adds the visitor's address.-Bug reports from the bug button that `--debug` shows, as one line starting `bug:` with the page's URL, the note, the window, the browser, the build and the snapshot's path.+Bug reports from the bug button at each page's foot, as one line starting `bug N:` with the page's URL, the note, the window, the browser, the build and the snapshot's path, and each comment as `bug N comment:`. Faults: render and git failures, refused links, inotify falling back to polling, and what net/http reports. A detached server's stderr goes to the same place, so a panic is kept. A secret share's token never reaches the log. Paths do, and on shares so do visitors' addresses. Your user journal is readable by you and by members of `adm` and `systemd-journal`.
@@ -119,8 +119,8 @@ func TestStrayPath(t *testing.T) { t.Errorf("%s: lacks %q:\n%s", p, w, body) } }- if strings.Contains(body, "<button") || strings.Contains(body, "this page") {- t.Errorf("%s: a stray path shows the index or a button:\n%s", p, body)+ if strings.Contains(body, "Kill server") || strings.Contains(body, "Delete lock") || strings.Contains(body, ", this page") {+ t.Errorf("%s: a stray path shows the index or a stop button:\n%s", p, body) } } if body := get(h, "/notez/").Body.String(); strings.Contains(body, "/other/") {
@@ -69,7 +69,8 @@ type config struct { fg bool noopen bool persist bool // write a persistent lock, so the tree is served after reboot- debug bool // show the build stamp, the index's times and the bug button+ debug bool // show the build stamp and the index's times+ noBug bool // hide the bug button and refuse reports cache string // the memory for cached git answers, eg 32M; "" takes defaultCache disk string // the disk for online repos, eg 4G; "" takes defaultDisk help bool@@ -113,10 +114,13 @@ Options: next server on its address or by --restart --debug show what serves debugging: above each page's header, small and faint, the binary's build time,- the seconds since, its commit, the page's render- time and a bug button that logs the page; on the- index, each server's build and start and each- lock's time; with --restart, on the new servers+ the seconds since, its commit and the page's render+ time; on the index, each server's build and start+ and each lock's time; with --restart, on the new+ servers+ --no-bug hide the bug button at the foot of each page, which+ files the page as a numbered bug report; a share+ never shows it; with --restart, on the new servers --cache=SIZE memory a new server may spend on cached git answers, eg 64M; 0 caches nothing [default: 32M] --disk=SIZE disk a new server may fill with online repos, eg 4G;@@ -151,6 +155,7 @@ func (c *config) switches() map[string]*bool { "no-open": &c.noopen, "persist": &c.persist, "debug": &c.debug,+ "no-bug": &c.noBug, "help": &c.help, "version": &c.version, "restart": &c.restart,@@ -272,7 +277,7 @@ func parseArgs(args []string) (config, error) { dirs = append(dirs, a) } }- if c.commit != "" && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.debug ||+ if c.commit != "" && (c.restart || c.list || c.kill || c.fg || c.noopen || c.persist || c.debug || c.noBug || c.sharing || c.secret || c.addr != "" || c.cache != "" || c.disk != "") { return c, &errno{syscall.EINVAL, "--commit takes a directory and no other option"} }
@@ -23,7 +23,7 @@ import ( // which is how trees come back after a reboot. Without a named address or // --force, it leaves the addresses another binary last served, so the // package's unit and a build from make install never take each other's-// servers. With --debug, the new servers show what serves debugging. With a+// servers. With --debug, the new servers show what serves debugging, and with --no-bug they hide the bug button. With a // directory, it then serves that directory on c.ep, as a start does, and // opens it unless a page of it was open on the old server. func restart(c config) int {@@ -48,7 +48,7 @@ func restart(c config) int { say("the persistent trees on %s wait for %s, so they stay; -f serves them with this binary", ep, other) continue } else if len(kept) > 0 {- err = startTrees(ep, kept, c.debug)+ err = startTrees(ep, kept, c.debug, c.noBug) } else if !named || c.dir != "" { continue }@@ -57,7 +57,7 @@ func restart(c config) int { continue } else if err == nil { var pages []string- if pages, err = restartServer(ep, pid, trees, c.debug); err == nil {+ if pages, err = restartServer(ep, pid, trees, c.debug, c.noBug); err == nil { say("the server on %s is replaced; %s", ep, reloading(len(pages))) } if ep == c.ep {@@ -142,6 +142,9 @@ func startDir(c config, open []string) int { if c.debug { args = append(args, "--debug") }+ if c.noBug {+ args = append(args, "--no-bug")+ } cmd := exec.Command(exe, append(args, "--", dir)...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr if err := cmd.Run(); err != nil {@@ -169,7 +172,7 @@ func pagesOf(n int) string { // the server leaves as it stops. A binary that could not bind the // server's port leaves the server alone, since stopping it would drop its // trees.-func restartServer(ep netip.AddrPort, pid int, trees []servedTree, debug bool) ([]string, error) {+func restartServer(ep netip.AddrPort, pid int, trees []servedTree, debug, noBug bool) ([]string, error) { if ep.Port() < 1024 && !lowPortOK() { return nil, &errno{syscall.EACCES, "this binary cannot bind port " + strconv.Itoa(int(ep.Port())) + ", so the server on " + ep.String() + " keeps its old one. Run the installed package's " + progName + ", which holds cap_net_bind_service"}@@ -189,13 +192,13 @@ func restartServer(ep netip.AddrPort, pid int, trees []servedTree, debug bool) ( pages = strings.FieldsFunc(string(b), func(r rune) bool { return r == '\n' }) os.Remove(record) }- return pages, startTrees(ep, trees, debug)+ return pages, startTrees(ep, trees, debug, noBug) } // startTrees serves each tree on ep through this binary, as a start from // the command line would, persistent ones with --persist. The first start-// runs the server, so --debug reaches it.-func startTrees(ep netip.AddrPort, trees []servedTree, debug bool) error {+// runs the server, so --debug and --no-bug reach it.+func startTrees(ep netip.AddrPort, trees []servedTree, debug, noBug bool) error { if ep.Port() < 1024 && !lowPortOK() { return &errno{syscall.EACCES, "this binary cannot bind port " + strconv.Itoa(int(ep.Port())) + ", so it cannot serve " + ep.String() + ". Run the installed package's " + progName + ", which holds cap_net_bind_service"}@@ -223,6 +226,9 @@ func startTrees(ep netip.AddrPort, trees []servedTree, debug bool) error { if debug { args = append(args, "--debug") }+ if noBug {+ args = append(args, "--no-bug")+ } cmd := exec.Command(exe, append(args, "--", t.dir)...) cmd.Env = env cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
@@ -255,7 +255,7 @@ func ifaceOf(a netip.Addr) string { func serve(c config, rep *reporter, ln net.Listener, reg *registry, dir, home, origin string) int { logInfo("server starts on %s: %s", c.ep, build()) go warmLexers()- debugOn = c.debug+ debugOn, bugOff = c.debug, c.noBug size := int64(defaultCache) if c.cache != "" { size, _ = parseSize(c.cache)
@@ -141,6 +141,9 @@ func (a about) status() string { // they serve debugging alone. var debugOn bool +// bugOff, set by --no-bug, hides the bug button and refuses reports.+var bugOff bool+ var buildCommit = buildRev() var built = func() time.Time {@@ -360,8 +363,8 @@ func (s *server) handler() http.Handler { if !s.shared && s.web == nil { mux.HandleFunc("POST "+ownPath+"stop", s.handleStop) }- // The bug button is there only with --debug, which a share never shows.- if debugOn && !s.shared {+ // The bug button is on every page but a share's, unless --no-bug.+ if !bugOff && !s.shared { mux.HandleFunc("POST "+ownPath+"bug", s.handleBug) } mux.HandleFunc("GET "+ownPath+"live", s.handleLive)@@ -1235,6 +1238,7 @@ type page struct { Status template.HTML // the server's age, on the index, or a repo's commit on the web address Style template.CSS // the stylesheet inline, for a page the server may not outlive Stopped bool+ Bug bool // the bug button at the page's foot Shared bool // a shared server's page, which has no button NoStop bool // a page on the web address, which has no button Live string // the URL of the page's live reload stream, or ""@@ -1302,6 +1306,7 @@ func render(w http.ResponseWriter, r *http.Request, status int, p page) { if p.Live == "" { p.Live = liveURL(r) }+ p.Bug = !bugOff && !p.Shared && !p.Stopped if debugOn && !p.Shared && !built.IsZero() { p.Built = &stamp{Clock: clock(built), Millis: built.UnixMilli(), Ago: int(time.Since(built).Seconds()), Commit: buildCommit, Rendered: rendered(r)}
@@ -198,7 +198,7 @@ func TestStopRefusesOtherSites(t *testing.T) { // A browser may send Origin: null on its own same-origin form post. for _, origin := range []string{"http://127.31.1.1:8080", "null"} { rec := do(h, "POST", ownPath+"stop", "Sec-Fetch-Site", "same-origin", "Origin", origin)- if rec.Code != http.StatusOK {+ if rec.Code != http.StatusGone { t.Fatalf("same-origin stop with Origin %s: status %d", origin, rec.Code) } select {@@ -235,7 +235,8 @@ func TestIndexDeleteReturns(t *testing.T) { t.Errorf("first delete: status %d location %q stopped %d", rec.Code, rec.Header().Get("Location"), len(got)) } rec = post("tree=%2Fother%2F&from=index")- if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Stopped serving ~/other") {+ if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "<th>stopped</th><td><span class=\"seg\">~/</span><wbr><span class=\"seg\">other</span></td>") ||+ !strings.Contains(rec.Body.String(), "<th>server</th><td>stopped; close this tab</td>") || !strings.Contains(rec.Body.String(), `<span>410</span>`) { t.Errorf("last delete: status %d: %s", rec.Code, rec.Body.String()) } // The server closes as this page leaves, so the page carries its style.@@ -359,7 +360,7 @@ func TestStopWithdrawsOneTree(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() s.handler().ServeHTTP(rec, req)- if rec.Code != http.StatusOK || len(got) != 1 || got[0] != other {+ if rec.Code != http.StatusGone || len(got) != 1 || got[0] != other { t.Fatalf("status %d, withdrew %v, want only the other tree", rec.Code, got) } if got[0] == tr {@@ -397,7 +398,7 @@ func TestBuildStamp(t *testing.T) { if got := strings.Contains(body, `<p class="built" data-built="`); got != c.want { t.Errorf("stamp=%v shared=%v: shown=%v", c.show, c.shared, got) }- if c.want && !regexp.MustCompile(`s ago, from [^<]+, rendered in [0-9.]+ ms <button class="bug"`).MatchString(body) {+ if c.want && !regexp.MustCompile(`s ago, from [^<]+, rendered in [0-9.]+ ms</p>`).MatchString(body) { t.Errorf("stamp lacks commit or render time: %s", body) } }