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}