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