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