current commit
261101.1 · server.go splits by purpose
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

// maxBug caps a bug report, snapshot included. A code page of jquery.js,
// 10,907 lines, is about 2 MB of HTML.
const maxBug = 16 << 20

// 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, the build and the server's
// state from bugState; 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)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBug)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad bug report: "+err.Error(), http.StatusRequestEntityTooLarge)
		logErr("bug report refused: %s", err)
		return
	}
	dir, err := bugDir()
	if err != nil {
		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
	}
	n, err := fileBug(dir, append([][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},
	}, s.bugState()...))
	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 %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.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprint(w, n)
}

// bugState is the server's part of a report: its process, its binary and
// whether a rebuild has replaced it, the address, when it started, the
// command line, and each tree it serves as tree.0, tree.1 and on. The
// button reports bugs in the program, so a report names the server that
// filed it, eg a stale one on another port.
func (s *server) bugState() [][2]string {
	stale := "no"
	if s.about.stale() {
		stale = "yes"
	}
	started := ""
	if !s.about.started.IsZero() {
		started = s.about.started.Format(time.RFC3339)
	}
	addr := ""
	if s.about.ep.IsValid() {
		addr = s.about.ep.String()
	}
	fields := [][2]string{
		{"pid", strconv.Itoa(os.Getpid())},
		{"exe", s.about.exe},
		{"stale", stale},
		{"addr", addr},
		{"started", started},
		{"command", commandLine(os.Args[1:])},
	}
	for i, t := range s.list() {
		fields = append(fields, [2]string{"tree." + strconv.Itoa(i), t.dir})
	}
	return fields
}

// 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
}

// sourceDir is the checkout the binary was built from, set by make build.
var sourceDir string

// bugDir is where bug reports go, made on first use: csi/bugs in the
// checkout the binary was built from, when that checkout has csi, so the
// bugs sit with the project they are about; else $XDG_STATE_HOME/www/bugs.
func bugDir() (string, error) {
	if sourceDir != "" {
		if info, err := os.Stat(filepath.Join(sourceDir, "csi")); err == nil && info.IsDir() {
			dir := filepath.Join(sourceDir, "csi", "bugs")
			return dir, os.MkdirAll(dir, 0o700)
		}
	}
	base, err := stateDir()
	if err != nil {
		return "", err
	}
	dir := filepath.Join(base, progName, "bugs")
	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)
	return os.WriteFile(p, []byte(page), 0o600)
}

// bugOff, set by --no-bug, hides the bug button and refuses reports.
var bugOff bool