@ 261101.7

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

README.md

@@ -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 

assets/layout.html

@@ -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}}&nbsp;/ {{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}}

assets/live.js

@@ -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

assets/style.css

@@ -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; }

bug.go

@@ -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) }

bug_test.go

@@ -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") 	} }

doc/debugging.md

@@ -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.

doc/log.md

@@ -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`.

gone_test.go

@@ -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/") {

main.go

@@ -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"} 	}

restart.go

@@ -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

serve.go

@@ -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)

server.go

@@ -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)}

server_test.go

@@ -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) 		} 	}