Parent directory

controller.go

4169 bytes
  1// Package pages provides the public web UI routes.
  2package pages
  3
  4import (
  5	"context"
  6	"embed"
  7	"html/template"
  8	"io/fs"
  9	"net/http"
 10
 11	"github.com/charmbracelet/soft-serve/pkg/config"
 12	"github.com/charmbracelet/soft-serve/pkg/proto"
 13	"github.com/gorilla/mux"
 14)
 15
 16var (
 17	//go:embed templates/*.gohtml assets/*
 18	files embed.FS
 19
 20	pageTemplates = template.Must(template.ParseFS(files, "templates/*.gohtml"))
 21)
 22
 23// Controller registers public web UI routes.
 24func Controller(_ context.Context, r *mux.Router) {
 25	// Exact and infrastructure paths precede page operations; the generic
 26	// overview route must remain last so nested repository names are unambiguous.
 27	r.HandleFunc("/", home).Methods(http.MethodGet)
 28	r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
 29	r.HandleFunc("/@/assets/site.js", script).Methods(http.MethodGet)
 30	r.HandleFunc("/{repo:.*}/@/tree", treeBrowser).Methods(http.MethodGet)
 31	r.HandleFunc("/{repo:.*}/@/raw", rawFile).Methods(http.MethodGet)
 32	r.HandleFunc("/{repo:.*}/@/commits", commits).Methods(http.MethodGet)
 33	r.HandleFunc("/{repo:.*}/@/commit", commit).Methods(http.MethodGet)
 34	r.HandleFunc("/{repo:.*}/@/refs", refs).Methods(http.MethodGet)
 35	r.HandleFunc("/{repo:.*}", repositoryOverview).Methods(http.MethodGet)
 36}
 37
 38type homePage struct {
 39	ServerName string
 40	Profile    template.HTML
 41	Cards      []repositoryCard
 42}
 43
 44type repositoryCard struct {
 45	Name        string
 46	Title       string
 47	Description string
 48	URL         string
 49	UpdatedAt   string
 50	BuildStatus repositoryBuildStatus
 51}
 52
 53func home(w http.ResponseWriter, r *http.Request) {
 54	SetSecurityHeaders(w)
 55	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 56
 57	page, err := newHomePage(r.Context())
 58	if err != nil {
 59		http.Error(w, "failed to load page", http.StatusInternalServerError)
 60		return
 61	}
 62	if err := pageTemplates.ExecuteTemplate(w, "home", page); err != nil {
 63		http.Error(w, "failed to render page", http.StatusInternalServerError)
 64	}
 65}
 66
 67func newHomePage(ctx context.Context) (homePage, error) {
 68	cfg := config.FromContext(ctx)
 69	repos, err := HomepageRepositories(ctx)
 70	if err != nil {
 71		return homePage{}, err
 72	}
 73
 74	page := homePage{
 75		ServerName: cfg.Name,
 76		Cards:      make([]repositoryCard, 0, len(repos)),
 77	}
 78	names := make([]string, 0, len(repos))
 79	for _, repo := range repos {
 80		names = append(names, repo.Name())
 81	}
 82	statuses := LoadBuildStatuses(ctx, cfg.HTTP.WebUI.BuildStatusURL, names)
 83	for _, repo := range repos {
 84		page.Cards = append(page.Cards, newRepositoryCard(repo, statuses[repo.Name()]))
 85	}
 86
 87	page.Profile = loadProfile(ctx)
 88	return page, nil
 89}
 90
 91func newRepositoryCard(repo proto.Repository, status repositoryBuildStatus) repositoryCard {
 92	title := repo.ProjectName()
 93	if title == "" {
 94		title = repo.Name()
 95	}
 96
 97	updatedAt := ""
 98	if updated := repo.UpdatedAt(); !updated.IsZero() {
 99		updatedAt = updated.UTC().Format("2006-01-02 15:04 UTC")
100	}
101	return repositoryCard{
102		Name:        repo.Name(),
103		Title:       title,
104		Description: repo.Description(),
105		URL:         RepositoryURL(repo.Name()),
106		UpdatedAt:   updatedAt,
107		BuildStatus: status,
108	}
109}
110
111func loadProfile(ctx context.Context) template.HTML {
112	repo, err := PublicRepository(ctx, ".soft-serve")
113	if err != nil {
114		return ""
115	}
116	gitRepo, err := repo.Open()
117	if err != nil {
118		return ""
119	}
120	ref, err := ResolveRef(gitRepo, "")
121	if err != nil {
122		return ""
123	}
124	readme, err := LoadReadme(gitRepo, ref)
125	if err != nil || readme.Binary {
126		return ""
127	}
128	profile, err := RenderMarkdown(readme.Bytes)
129	if err != nil {
130		return ""
131	}
132	return profile
133}
134
135func stylesheet(w http.ResponseWriter, r *http.Request) {
136	serveAsset(w, "assets/site.css", "text/css; charset=utf-8")
137}
138
139func script(w http.ResponseWriter, r *http.Request) {
140	serveAsset(w, "assets/site.js", "text/javascript; charset=utf-8")
141}
142
143func serveAsset(w http.ResponseWriter, name, contentType string) {
144	SetContentSafetyHeaders(w)
145	asset, err := fs.ReadFile(files, name)
146	if err != nil {
147		http.Error(w, "embedded asset is unavailable", http.StatusInternalServerError)
148		return
149	}
150
151	w.Header().Set("Content-Type", contentType)
152	w.Header().Set("Cache-Control", "public, max-age=31536000")
153	_, _ = w.Write(asset)
154}