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	if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
245		log.Debug("template execution failed", "template", templateName, "err", err)
246		// Already started writing response, so we can't render an error page
247	}
248}
249
250// staticFiles handles static file serving.
251func staticFiles(w http.ResponseWriter, r *http.Request) {
252	// Strip /static/ prefix
253	path := strings.TrimPrefix(r.URL.Path, "/static/")
254
255	data, err := staticFS.ReadFile("static/" + path)
256	if err != nil {
257		renderNotFound(w, r)
258		return
259	}
260
261	// Set cache headers
262	w.Header().Set("Cache-Control", "public, max-age=31536000")
263
264	// Detect content type from extension
265	contentType := mime.TypeByExtension(filepath.Ext(path))
266	if contentType != "" {
267		w.Header().Set("Content-Type", contentType)
268	}
269
270	w.Write(data)
271}
272
273// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes.
274func withWebUIAccess(next http.Handler) http.Handler {
275	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
276		ctx := r.Context()
277		logger := log.FromContext(ctx)
278		logger.Debug("withWebUIAccess called", "path", r.URL.Path)
279
280		// Wrap the writer to suppress 401/403
281		hrw := &hideAuthWriter{ResponseWriter: w}
282		// Run access first so repo/user are injected into context
283		withAccess(next).ServeHTTP(hrw, r)
284
285		if hrw.suppressed {
286			logger.Debug("suppressed 401/403, rendering 404")
287			// Remove auth challenge headers so we don't leak private info
288			w.Header().Del("WWW-Authenticate")
289			w.Header().Del("LFS-Authenticate")
290			// Now render 404 once; no double WriteHeader occurs
291			renderNotFound(w, r)
292		}
293	})
294}
295
296// hideAuthWriter suppresses 401/403 responses to convert them to 404.
297type hideAuthWriter struct {
298	http.ResponseWriter
299	suppressed bool
300}
301
302func (w *hideAuthWriter) WriteHeader(code int) {
303	if code == http.StatusUnauthorized || code == http.StatusForbidden {
304		// Suppress original status/body; we'll render 404 afterwards
305		w.suppressed = true
306		return
307	}
308	w.ResponseWriter.WriteHeader(code)
309}
310
311func (w *hideAuthWriter) Write(p []byte) (int, error) {
312	if w.suppressed {
313		// Drop body of 401/403
314		return len(p), nil
315	}
316	return w.ResponseWriter.Write(p)
317}
318
319// WebUIController registers HTTP routes for the web-based repository browser.
320// It provides HTML views for repository overview, file browsing, commits, and references.
321func WebUIController(ctx context.Context, r *mux.Router) {
322	basePrefix := "/{repo:.*}"
323
324	// Static files (most specific, should be first)
325	r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet)
326
327	// Home page (root path, before other routes)
328	r.HandleFunc("/", home).Methods(http.MethodGet)
329
330	// About page
331	r.HandleFunc("/about", about).Methods(http.MethodGet)
332
333	// More specific routes must be registered before catch-all patterns
334	// Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler
335	// Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
336	r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
337		Methods(http.MethodGet)
338	r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
339		Methods(http.MethodGet)
340
341	// Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
342	r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))).
343		Methods(http.MethodGet)
344
345	// Commits routes - use catch-all pattern to support refs with slashes
346	r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
347		Methods(http.MethodGet)
348	r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
349		Methods(http.MethodGet)
350
351	// Commit route
352	r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))).
353		Methods(http.MethodGet)
354
355	// Commit patch and diff routes
356	r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.patch", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitPatch)))).
357		Methods(http.MethodGet)
358	r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}.diff", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommitDiff)))).
359		Methods(http.MethodGet)
360
361	// Branches route
362	r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))).
363		Methods(http.MethodGet)
364
365	// Tags route
366	r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
367		Methods(http.MethodGet)
368
369	// Bugs routes
370	r.Handle(basePrefix+"/bugs", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBugs)))).
371		Methods(http.MethodGet)
372	r.Handle(basePrefix+"/bug/{hash:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBug)))).
373		Methods(http.MethodGet)
374
375	// Repository overview (catch-all, must be last)
376	r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
377		Methods(http.MethodGet)
378}