webui.go

  1// Package web implements the HTTP server and web UI for Soft Serve.
  2package web
  3
  4//go:generate go run gen_syntax_css.go
  5
  6import (
  7	"context"
  8	"embed"
  9	"fmt"
 10	"html/template"
 11	"mime"
 12	"net/http"
 13	"path/filepath"
 14	"strings"
 15	"time"
 16
 17	"github.com/charmbracelet/log/v2"
 18	"github.com/gorilla/mux"
 19)
 20
 21//go:embed templates/*.html
 22var templatesFS embed.FS
 23
 24//go:embed static/*
 25var staticFS embed.FS
 26
 27const (
 28	// defaultCommitsPerPage is the number of commits shown per page in commit history view.
 29	defaultCommitsPerPage = 50
 30	// defaultReposPerPage is the number of repositories shown per page on the home page.
 31	defaultReposPerPage = 20
 32	// defaultTagsPerPage is the number of tags shown per page on the tags page.
 33	defaultTagsPerPage = 20
 34	// defaultBranchesPerPage is the number of branches shown per page on the branches page.
 35	defaultBranchesPerPage = 20
 36)
 37
 38// templateFuncs defines template helper functions available in HTML templates.
 39// Functions include: splitPath (split path into components), joinPath (join path components),
 40// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
 41// and humanizeSize (format file size in human-readable format).
 42var templateFuncs = template.FuncMap{
 43	"splitPath": func(path string) []string {
 44		if path == "." {
 45			return []string{}
 46		}
 47		return strings.Split(path, "/")
 48	},
 49	"joinPath": func(index int, data interface{}) string {
 50		var path string
 51
 52		switch v := data.(type) {
 53		case BlobData:
 54			path = v.Path
 55		case TreeData:
 56			path = v.Path
 57		default:
 58			return ""
 59		}
 60
 61		parts := strings.Split(path, "/")
 62		if index >= len(parts) {
 63			return path
 64		}
 65		return strings.Join(parts[:index+1], "/")
 66	},
 67	"parentPath": func(path string) string {
 68		if path == "." || !strings.Contains(path, "/") {
 69			return "."
 70		}
 71		return filepath.Dir(path)
 72	},
 73	"shortHash": func(hash interface{}) string {
 74		var hashStr string
 75		switch v := hash.(type) {
 76		case string:
 77			hashStr = v
 78		case fmt.Stringer:
 79			hashStr = v.String()
 80		default:
 81			hashStr = fmt.Sprintf("%v", hash)
 82		}
 83		if len(hashStr) > 8 {
 84			return hashStr[:8]
 85		}
 86		return hashStr
 87	},
 88	"formatDate": func(t interface{}) string {
 89		if time, ok := t.(fmt.Stringer); ok {
 90			return time.String()
 91		}
 92		return fmt.Sprintf("%v", t)
 93	},
 94	"humanizeSize": func(size int64) string {
 95		const unit = 1024
 96		if size < unit {
 97			return fmt.Sprintf("%d B", size)
 98		}
 99		div, exp := int64(unit), 0
100		for n := size / unit; n >= unit; n /= unit {
101			div *= unit
102			exp++
103		}
104		return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
105	},
106	"inc": func(i int) int {
107		return i + 1
108	},
109	"dec": func(i int) int {
110		return i - 1
111	},
112	"commitSubject": func(message string) string {
113		lines := strings.Split(message, "\n")
114		if len(lines) > 0 {
115			return lines[0]
116		}
117		return message
118	},
119	"commitBody": func(message string) string {
120		lines := strings.Split(message, "\n")
121		if len(lines) <= 1 {
122			return ""
123		}
124		// Skip the subject line and join the rest
125		body := strings.Join(lines[1:], "\n")
126		return strings.TrimSpace(body)
127	},
128	"rfc3339": func(t interface{}) string {
129		switch v := t.(type) {
130		case time.Time:
131			return v.Format(time.RFC3339)
132		default:
133			return ""
134		}
135	},
136}
137
138// renderHTML renders an HTML template with the given data.
139func renderHTML(w http.ResponseWriter, templateName string, data interface{}) {
140	tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templatesFS, "templates/base.html", "templates/"+templateName)
141	if err != nil {
142		log.Debug("failed to parse template", "template", templateName, "err", err)
143		renderInternalServerError(w, nil)
144		return
145	}
146
147	w.Header().Set("Content-Type", "text/html; charset=utf-8")
148	if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
149		log.Debug("template execution failed", "template", templateName, "err", err)
150		// Already started writing response, so we can't render an error page
151	}
152}
153
154// staticFiles handles static file serving.
155func staticFiles(w http.ResponseWriter, r *http.Request) {
156	// Strip /static/ prefix
157	path := strings.TrimPrefix(r.URL.Path, "/static/")
158
159	data, err := staticFS.ReadFile("static/" + path)
160	if err != nil {
161		renderNotFound(w, r)
162		return
163	}
164
165	// Set cache headers
166	w.Header().Set("Cache-Control", "public, max-age=31536000")
167
168	// Detect content type from extension
169	contentType := mime.TypeByExtension(filepath.Ext(path))
170	if contentType != "" {
171		w.Header().Set("Content-Type", contentType)
172	}
173
174	w.Write(data)
175}
176
177// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes.
178func withWebUIAccess(next http.Handler) http.Handler {
179	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180		ctx := r.Context()
181		logger := log.FromContext(ctx)
182		logger.Debug("withWebUIAccess called", "path", r.URL.Path)
183
184		// Wrap the writer to suppress 401/403
185		hrw := &hideAuthWriter{ResponseWriter: w}
186		// Run access first so repo/user are injected into context
187		withAccess(next).ServeHTTP(hrw, r)
188
189		if hrw.suppressed {
190			logger.Debug("suppressed 401/403, rendering 404")
191			// Remove auth challenge headers so we don't leak private info
192			w.Header().Del("WWW-Authenticate")
193			w.Header().Del("LFS-Authenticate")
194			// Now render 404 once; no double WriteHeader occurs
195			renderNotFound(w, r)
196		}
197	})
198}
199
200// hideAuthWriter suppresses 401/403 responses to convert them to 404.
201type hideAuthWriter struct {
202	http.ResponseWriter
203	suppressed bool
204}
205
206func (w *hideAuthWriter) WriteHeader(code int) {
207	if code == http.StatusUnauthorized || code == http.StatusForbidden {
208		// Suppress original status/body; we'll render 404 afterwards
209		w.suppressed = true
210		return
211	}
212	w.ResponseWriter.WriteHeader(code)
213}
214
215func (w *hideAuthWriter) Write(p []byte) (int, error) {
216	if w.suppressed {
217		// Drop body of 401/403
218		return len(p), nil
219	}
220	return w.ResponseWriter.Write(p)
221}
222
223// WebUIController registers HTTP routes for the web-based repository browser.
224// It provides HTML views for repository overview, file browsing, commits, and references.
225func WebUIController(ctx context.Context, r *mux.Router) {
226	basePrefix := "/{repo:.*}"
227
228	// Static files (most specific, should be first)
229	r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet)
230
231	// Home page (root path, before other routes)
232	r.HandleFunc("/", home).Methods(http.MethodGet)
233
234	// About page
235	r.HandleFunc("/about", about).Methods(http.MethodGet)
236
237	// More specific routes must be registered before catch-all patterns
238	// Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler
239	// Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
240	r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
241		Methods(http.MethodGet)
242	r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
243		Methods(http.MethodGet)
244
245	// Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
246	r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))).
247		Methods(http.MethodGet)
248
249	// Commits routes - use catch-all pattern to support refs with slashes
250	r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
251		Methods(http.MethodGet)
252	r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
253		Methods(http.MethodGet)
254
255	// Commit route
256	r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))).
257		Methods(http.MethodGet)
258
259	// Branches route
260	r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))).
261		Methods(http.MethodGet)
262
263	// Tags route
264	r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
265		Methods(http.MethodGet)
266
267	// Repository overview (catch-all, must be last)
268	r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
269		Methods(http.MethodGet)
270}