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