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