bidet

commit baa98b437d0ac48122258247605f2dc092f075e8

tree

parent:
fad7f9b26c9d8c6063d141f15ef9368f60bf16df

nmyk <nick@nmyk.io>

2026-02-17T20:27:29-05:00

make server configurable; update readme

diff --git a/README.md b/README.md
index 22f9c05fe56b12f25efdfde1893429518d8712ab..9d379d48417fba5990e6c37673ad58c0a0a58593 100644
--- a/README.md
+++ b/README.md
@@ -3,4 +3,25 @@
 Bidet is a simple web frontend for git.
 It is hooked up to the plumbing, but it's all output, no input.
 
-Run bidet in the same directory as your bare repos to serve their contents over HTTP.
\ No newline at end of file
+## Usage
+
+```
+NAME
+  bidet
+
+FLAGS
+  -p, --port INT           port for the server (default: 8080)
+  -n, --name STRING        name for index page header
+  -g, --greeting STRING    greeting for index page
+  -d, --directory STRING   directory containing your bare git repositories (default: .)
+  -s, --css STRING         path or URL to a CSS stylesheet
+```
+
+Bidet can also be configured with environment variables:
+```
+BIDET_PORT
+BIDET_HOSTNAME
+BIDET_GREETING
+BIDET_DIRECTORY
+BIDET_CSS
+```
\ No newline at end of file
diff --git a/cmd/bidet/main.go b/cmd/bidet/main.go
index a3da711b81909251c33d6d225ad55e41a0c92679..47399948cd7eb60bd4d9a5c697611cc24b5e3f38 100644
--- a/cmd/bidet/main.go
+++ b/cmd/bidet/main.go
@@ -1,15 +1,46 @@
 package main
 
 import (
+	"errors"
+	"fmt"
 	"log"
 	"net/http"
+	"os"
 
+	"github.com/peterbourgon/ff/v4"
+	"github.com/peterbourgon/ff/v4/ffhelp"
 	"nmyk.io/bidet/internal/handlers"
 )
 
-const addr = ":8080"
+func main() {
+	fs := ff.NewFlagSet("bidet")
+	var (
+		port     = fs.Int('p', "port", 8080, "port for the server")
+		hostname = fs.String('n', "name", "", "name for index page header")
+		greeting = fs.String('g', "greeting", "", "greeting for index page")
+		dir      = fs.String('d', "directory", ".", "directory containing your bare git repositories")
+		cssLoc   = fs.String('s', "css", "", "path or URL to a CSS stylesheet")
+	)
+
+	if err := ff.Parse(fs, os.Args[1:],
+		ff.WithEnvVarPrefix("BIDET"),
+	); err != nil {
+		fmt.Println(ffhelp.Flags(fs))
+		if errors.Is(err, ff.ErrHelp) {
+			os.Exit(0)
+		}
+		os.Exit(1)
+	}
 
-func main() {
-	log.Printf("Serving on %s\n", addr)
-	log.Fatal(http.ListenAndServe(addr, handlers.Routes()))
+	cfg := handlers.Config{
+		Port:        *port,
+		Name:        *hostname,
+		Greeting:    *greeting,
+		Dir:         *dir,
+		CSSLocation: *cssLoc,
+	}
+	srv := handlers.NewServer(&cfg)
+	addr := fmt.Sprintf(":%d", srv.Port)
+	fmt.Printf("listening on port %d\n", *port)
+	log.Fatal(http.ListenAndServe(addr, srv.Routes()))
 }
diff --git a/go.mod b/go.mod
index 45053c62ff6d84549a834f84ba40827a56d0be76..f38f6e4fe43c66a02b1096a7a9831d6a3d8a9b79 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,10 @@ module nmyk.io/bidet
 
 go 1.25.0
 
-require github.com/go-git/go-git/v5 v5.16.5
+require (
+	github.com/go-git/go-git/v5 v5.16.5
+	github.com/peterbourgon/ff/v4 v4.0.0-beta.1
+)
 
 require (
 	dario.cat/mergo v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 095dd6191aa7a2676c274595c40a7024bebbb0cd..4f9ab652ae88ccf6eed718fe2ccb646ca41037d1 100644
--- a/go.sum
+++ b/go.sum
@@ -47,6 +47,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
+github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/peterbourgon/ff/v4 v4.0.0-beta.1 h1:hV8qRu3V7YfiSMsBSfPfdcznAvPQd3jI5zDddSrDoUc=
+github.com/peterbourgon/ff/v4 v4.0.0-beta.1/go.mod h1:onQJUKipvCyFmZ1rIYwFAh1BhPOvftb1uhvSI7krNLc=
 github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
 github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -97,6 +101,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/handlers/formatting.go b/internal/handlers/formatting.go
new file mode 100644
index 0000000000000000000000000000000000000000..37cc7e2f529e5310c33143f7ebcbb0f9e108274f
--- /dev/null
+++ b/internal/handlers/formatting.go
@@ -0,0 +1,41 @@
+package handlers
+
+import (
+	"path"
+	"slices"
+)
+
+func fmtAuthor(name string, email string) string {
+	return name + " <" + email + ">"
+}
+
+type Crumb struct {
+	Name   string
+	URL    string
+	IsLast bool
+}
+
+func breadcrumbs(repoName string, refLabel string, p string) []Crumb {
+	var crumbs []Crumb
+	last := true
+	cur := p
+	for {
+		base := path.Base(cur)
+		if base == "." || base == "/" {
+			break
+		}
+		crumbs = append(crumbs, Crumb{
+			Name:   base,
+			URL:    path.Join(repoName, "tree", refLabel, cur),
+			IsLast: last,
+		})
+		last = false
+		next := path.Dir(cur)
+		if next == cur {
+			break
+		}
+		cur = next
+	}
+	slices.Reverse(crumbs)
+	return crumbs
+}
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 30d441a267daf1c079f95ecef36cc6b954f48a71..1ec7c84bb91c28be9f6884733d2e2cd119dac9ee 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -17,16 +17,40 @@ 	"github.com/go-git/go-git/v5/plumbing/object"
 	"nmyk.io/bidet/internal/core"
 )
 
-func Routes() *http.ServeMux {
+type Config struct {
+	Port        int
+	Name        string
+	Greeting    string
+	Dir         string
+	CSSLocation string
+}
+
+func NewServer(c *Config) Server {
+	css := getCSS(c.CSSLocation)
+	if css == nil {
+		css = defaultCSS
+	}
+	if c.Name == "" {
+		c.Name = "repositories"
+	}
+	return Server{Config: c, CSS: css}
+}
+
+type Server struct {
+	*Config
+	CSS []byte
+}
+
+func (s Server) Routes() *http.ServeMux {
 	mux := http.NewServeMux()
-	mux.HandleFunc("GET /", ListRepos)
-	mux.HandleFunc("GET /{name}", Repo)
-	mux.HandleFunc("GET /{name}/refs", Refs)
-	mux.HandleFunc("GET /{name}/refs/{type}", Refs)
-	mux.HandleFunc("GET /{name}/tree/{path...}", Tree)
-	mux.HandleFunc("GET /{name}/blob/{path...}", Blob)
-	mux.HandleFunc("GET /{name}/commits/{path...}", Commits)
-	mux.HandleFunc("GET /{name}/commit/{hash}", Commit)
+	mux.HandleFunc("GET /", s.ListRepos)
+	mux.HandleFunc("GET /{name}", s.Repo)
+	mux.HandleFunc("GET /{name}/refs", s.Refs)
+	mux.HandleFunc("GET /{name}/refs/{type}", s.Refs)
+	mux.HandleFunc("GET /{name}/tree/{path...}", s.Tree)
+	mux.HandleFunc("GET /{name}/blob/{path...}", s.Blob)
+	mux.HandleFunc("GET /{name}/commits/{path...}", s.Commits)
+	mux.HandleFunc("GET /{name}/commit/{hash}", s.Commit)
 	return mux
 }
 
@@ -34,13 +58,13 @@ //go:embed templates/*.tmpl
 var Templates embed.FS
 
 //go:embed static/style.css
-var styleCSS []byte
+var defaultCSS []byte
 
-func Serve(w http.ResponseWriter, tmplName string, data any) {
+func (s Server) Serve(w http.ResponseWriter, tmplName string, data any) {
 	tmpl := template.Must(template.New("").
 		Funcs(template.FuncMap{
 			"css": func() template.CSS {
-				return template.CSS(styleCSS)
+				return template.CSS(s.CSS)
 			},
 		}).
 		ParseFS(
@@ -49,24 +73,33 @@ 			"templates/base.tmpl",
 			"templates/"+tmplName+".tmpl",
 		),
 	)
-
 	var buf bytes.Buffer
 	if err := tmpl.ExecuteTemplate(&buf, tmplName, data); err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-
 	w.WriteHeader(http.StatusOK)
 	buf.WriteTo(w)
 }
 
-func ListRepos(w http.ResponseWriter, _ *http.Request) {
-	repos, err := core.List(".")
+type IndexData struct {
+	Title  string
+	Header string
+	Repos  []string
+}
+
+func (s Server) ListRepos(w http.ResponseWriter, _ *http.Request) {
+	repos, err := core.List(s.Dir)
 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-	Serve(w, "repos", repos)
+	data := IndexData{
+		Title:  s.Name,
+		Header: s.Greeting,
+		Repos:  repos,
+	}
+	s.Serve(w, "repos", data)
 }
 
 type BranchMeta struct {
@@ -84,7 +117,7 @@ 	Branches []BranchMeta
 	Tags     []TagMeta
 }
 
-func Refs(w http.ResponseWriter, r *http.Request) {
+func (s Server) Refs(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -125,7 +158,7 @@ 		Type:     refType,
 		Branches: branches,
 		Tags:     tags,
 	}
-	Serve(w, "refs", data)
+	s.Serve(w, "refs", data)
 }
 
 type CommitMeta struct {
@@ -141,11 +174,7 @@ 	Ref     string
 	Commits []CommitMeta
 }
 
-func fmtAuthor(name string, email string) string {
-	return name + " <" + email + ">"
-}
-
-func Commits(w http.ResponseWriter, r *http.Request) {
+func (s Server) Commits(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -177,7 +206,7 @@ 		Repo:    repoName,
 		Ref:     commit.RefName,
 		Commits: commits,
 	}
-	Serve(w, "commits", data)
+	s.Serve(w, "commits", data)
 }
 
 type CommitData struct {
@@ -187,7 +216,7 @@ 	Parents []string
 	Patch   string
 }
 
-func Commit(w http.ResponseWriter, r *http.Request) {
+func (s Server) Commit(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -232,7 +261,7 @@ 		Repo:    repoName,
 		Parents: parents,
 		Patch:   patch,
 	}
-	Serve(w, "commit", data)
+	s.Serve(w, "commit", data)
 }
 
 type BlobData struct {
@@ -243,7 +272,7 @@ 	Crumbs   []Crumb
 	Content  string
 }
 
-func Blob(w http.ResponseWriter, r *http.Request) {
+func (s Server) Blob(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -278,7 +307,7 @@ 		Path:     filePath,
 		Crumbs:   breadcrumbs(repoName, refLabel, filePath),
 		Content:  content,
 	}
-	Serve(w, "blob", data)
+	s.Serve(w, "blob", data)
 }
 
 type TreeData struct {
@@ -300,7 +329,7 @@ 	Path  string
 	IsDir bool
 }
 
-func Tree(w http.ResponseWriter, r *http.Request) {
+func (s Server) Tree(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -315,7 +344,7 @@ 		http.Error(w, "Cannot resolve HEAD state", 500)
 	}
 	headRef := head.Name().Short()
 	if commit.RefName == headRef && treePath == "" {
-		Repo(w, r)
+		s.Repo(w, r)
 		return
 	}
 	rootTree, err := commit.Tree()
@@ -382,41 +411,10 @@ 		Entries:    entries,
 		ReadmeName: readmeName,
 		Readme:     readme,
 	}
-	Serve(w, "tree", data)
-}
-
-type Crumb struct {
-	Name   string
-	URL    string
-	IsLast bool
-}
-
-func breadcrumbs(repoName string, refLabel string, p string) []Crumb {
-	var crumbs []Crumb
-	last := true
-	cur := p
-	for {
-		base := path.Base(cur)
-		if base == "." || base == "/" {
-			break
-		}
-		crumbs = append(crumbs, Crumb{
-			Name:   base,
-			URL:    path.Join(repoName, "tree", refLabel, cur),
-			IsLast: last,
-		})
-		last = false
-		next := path.Dir(cur)
-		if next == cur {
-			break
-		}
-		cur = next
-	}
-	slices.Reverse(crumbs)
-	return crumbs
+	s.Serve(w, "tree", data)
 }
 
-func Repo(w http.ResponseWriter, r *http.Request) {
+func (s Server) Repo(w http.ResponseWriter, r *http.Request) {
 	repoName := r.PathValue("name")
 	repo, err := core.Open(repoName)
 	if err != nil {
@@ -464,5 +462,5 @@ 		Entries:    entries,
 		ReadmeName: readmeName,
 		Readme:     readme,
 	}
-	Serve(w, "repo", data)
+	s.Serve(w, "repo", data)
 }
diff --git a/internal/handlers/style.go b/internal/handlers/style.go
new file mode 100644
index 0000000000000000000000000000000000000000..ad361c02d8ea709e658f976ef9ce738995870185
--- /dev/null
+++ b/internal/handlers/style.go
@@ -0,0 +1,33 @@
+package handlers
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+)
+
+func getCSS(loc string) []byte {
+	if loc == "" {
+		return nil
+	}
+	if u, err := url.Parse(loc); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
+		if resp, err := http.Get(loc); err == nil {
+			defer resp.Body.Close()
+			if resp.StatusCode == http.StatusOK {
+				if data, err := io.ReadAll(resp.Body); err == nil {
+					return data
+				}
+			}
+		}
+		fmt.Printf("could not find stylesheet at %s; using default\n", loc)
+		return nil
+	}
+
+	if data, err := os.ReadFile(loc); err == nil {
+		return data
+	}
+	fmt.Printf("error reading stylesheet at %s; using default\n")
+	return nil
+}
diff --git a/internal/handlers/templates/repo.tmpl b/internal/handlers/templates/repo.tmpl
index c741b6262e9cfa1d6a57731c297413a2bec68df6..ff4eb3c663555ae23dbbef4f3e8e9cb9d1ca972b 100644
--- a/internal/handlers/templates/repo.tmpl
+++ b/internal/handlers/templates/repo.tmpl
@@ -7,7 +7,7 @@ <ul>
 {{range .Entries}}
 <li>
     {{- if .IsDir -}}
-        📁 <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
+        📂 <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
     {{- else -}}
         📄 <a href="/{{$.Repo}}/blob/{{$.Ref}}/{{.Path}}">{{.Name}}</a>
     {{- end -}}
diff --git a/internal/handlers/templates/repos.tmpl b/internal/handlers/templates/repos.tmpl
index 107486974cb6551ed511399f6f03780b10f7065d..28fb5e11c198177f67558eea8af2563c37090e0b 100644
--- a/internal/handlers/templates/repos.tmpl
+++ b/internal/handlers/templates/repos.tmpl
@@ -1,8 +1,9 @@
-{{define "title"}}repositories{{end}}
+{{define "title"}}{{.Title}}{{end}}
 {{define "content"}}
-<h1>repositories</h1>
+<h1>{{.Title}}</h1>
+<h4>{{.Header}}</h4>
 <ul>
-{{range .}}
+{{range .Repos}}
 <li><a href="/{{.}}">{{.}}</a></li>
 {{end}}
 </ul>
diff --git a/internal/handlers/templates/tree.tmpl b/internal/handlers/templates/tree.tmpl
index 8ae54adaba821181839da5f0a682f2caa5f9fc0c..cc7c430f07820c36decf10e3537641b907b5607a 100644
--- a/internal/handlers/templates/tree.tmpl
+++ b/internal/handlers/templates/tree.tmpl
@@ -20,7 +20,7 @@ <ul>
 {{range .Entries}}
 <li>
     {{- if .IsDir -}}
-        📁 <a href="/{{$.Repo}}/tree/{{$.RefLabel}}/{{.Path}}">{{.Name}}</a>
+        📂 <a href="/{{$.Repo}}/tree/{{$.RefLabel}}/{{.Path}}">{{.Name}}</a>
     {{- else -}}
         📄 <a href="/{{$.Repo}}/blob/{{$.RefLabel}}/{{.Path}}">{{.Name}}</a>
     {{- end -}}