e02f9568573909237fe91d83f5b2b0302cd4fd88
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/web/homepage_test.go b/pkg/web/homepage_test.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..793cbb8c5a75c1979325a8acd10a710b9689b085
4--- /dev/null
5+++ b/pkg/web/homepage_test.go
6@@ -0,0 +1,146 @@
7+package web
8+
9+import (
10+ "context"
11+ "net/http"
12+ "net/http/httptest"
13+ "os"
14+ "os/exec"
15+ "path/filepath"
16+ "strings"
17+ "testing"
18+
19+ "github.com/charmbracelet/soft-serve/pkg/access"
20+ "github.com/charmbracelet/soft-serve/pkg/config"
21+ "github.com/charmbracelet/soft-serve/pkg/proto"
22+)
23+
24+func TestHomepageListsOnlyPublicVisibleRepositoriesAndProfile(t *testing.T) {
25+ ctx, be, _ := newLFSTestContext(t)
26+ cfg := config.FromContext(ctx)
27+ cfg.HTTP.WebUI.Enabled = true
28+ allowKeyless := true
29+ cfg.AllowKeyless = &allowKeyless
30+ anonAccess := access.ReadOnlyAccess
31+ cfg.AnonAccess = &anonAccess
32+
33+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ createHomepageRepository(t, be, ctx, "public-project", owner, proto.RepositoryOptions{
38+ ProjectName: "<script>Public project</script>",
39+ Description: `<img src=x onerror="alert(1)">`,
40+ })
41+ createHomepageRepository(t, be, ctx, "private-project", owner, proto.RepositoryOptions{Private: true})
42+ createHomepageRepository(t, be, ctx, "hidden-project", owner, proto.RepositoryOptions{Hidden: true})
43+ createHomepageRepository(t, be, ctx, ".soft-serve", owner, proto.RepositoryOptions{})
44+ commitHomepageFile(t, cfg.DataPath, ".soft-serve", "README.md", "# Profile\n\nPublic *profile* content.\n")
45+
46+ w := httptest.NewRecorder()
47+ NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
48+
49+ if w.Code != http.StatusOK {
50+ t.Fatalf("homepage status = %d, want %d", w.Code, http.StatusOK)
51+ }
52+ body := w.Body.String()
53+ for _, expected := range []string{"public-project", `href="/public-project"`, "<script>Public project</script>", "<img src=x onerror="alert(1)">", "Public <em>profile</em> content."} {
54+ if !strings.Contains(body, expected) {
55+ t.Errorf("homepage does not contain %q:\n%s", expected, body)
56+ }
57+ }
58+ for _, unexpected := range []string{"private-project", "hidden-project", `href="/.soft-serve"`, "<script>Public project</script>", `<img src=x onerror="alert(1)">`} {
59+ if strings.Contains(body, unexpected) {
60+ t.Errorf("homepage contains %q:\n%s", unexpected, body)
61+ }
62+ }
63+ if got := w.Header().Get("Content-Security-Policy"); got != "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" {
64+ t.Errorf("Content-Security-Policy = %q", got)
65+ }
66+ if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
67+ t.Errorf("X-Content-Type-Options = %q", got)
68+ }
69+ if got := w.Header().Get("Referrer-Policy"); got != "same-origin" {
70+ t.Errorf("Referrer-Policy = %q", got)
71+ }
72+}
73+
74+func TestHomepageWithoutProfileIsSuccessful(t *testing.T) {
75+ ctx, be, _ := newLFSTestContext(t)
76+ cfg := config.FromContext(ctx)
77+ cfg.HTTP.WebUI.Enabled = true
78+ allowKeyless := true
79+ cfg.AllowKeyless = &allowKeyless
80+ anonAccess := access.ReadOnlyAccess
81+ cfg.AnonAccess = &anonAccess
82+
83+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
84+ if err != nil {
85+ t.Fatal(err)
86+ }
87+ createHomepageRepository(t, be, ctx, "public-project", owner, proto.RepositoryOptions{})
88+
89+ w := httptest.NewRecorder()
90+ NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
91+ if w.Code != http.StatusOK {
92+ t.Fatalf("homepage without profile status = %d, want %d", w.Code, http.StatusOK)
93+ }
94+ if strings.Contains(w.Body.String(), "profile-heading") {
95+ t.Fatalf("homepage unexpectedly renders profile: %s", w.Body.String())
96+ }
97+}
98+
99+func TestHomepageRemainsUnavailableWhenDisabled(t *testing.T) {
100+ ctx, _, _ := newLFSTestContext(t)
101+ w := httptest.NewRecorder()
102+ NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
103+ if w.Code != http.StatusNotFound {
104+ t.Fatalf("disabled homepage status = %d, want %d", w.Code, http.StatusNotFound)
105+ }
106diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
107index 5af011a8943f1f9bd6e8123880b3135154bf22c4..aaf969d0e9251327b95d3d4e8b04a5145cc942cf 100644
108--- a/pkg/web/pages/assets/site.css
109+++ b/pkg/web/pages/assets/site.css
110@@ -7,8 +7,70 @@ body {
111 line-height: 1.5;
112 }
113
114-header a {
115+.site-header {
116+ border-bottom: 1px solid #d1d5db;
117+ margin-bottom: 2rem;
118+ padding-bottom: 1rem;
119+}
120+
121+.site-name {
122 color: inherit;
123 font-weight: 700;
124 text-decoration: none;
125 }
126+
127+a {
128+ color: #075985;
129+}
130+
131+a:focus-visible {
132+ border-radius: 0.15rem;
133+ outline: 3px solid #f59e0b;
134+ outline-offset: 3px;
135+}
136+
137+.profile,
138+.repository-card {
139+ border: 1px solid #d1d5db;
140+ border-radius: 0.35rem;
141+ margin: 1rem 0;
142+ padding: 1rem;
143+}
144+
145+.repository-list {
146+ display: grid;
147+ gap: 1rem;
148+ list-style: none;
149+ margin: 1rem 0;
150+ padding: 0;
151+}
152+
153+.repository-card h3,
154+.repository-card p {
155+ margin: 0;
156+}
157+
158+.repository-card p + p,
159+.repository-card h3 + p {
160+ margin-top: 0.5rem;
161+}
162+
163+.repository-name,
164+.updated {
165+ color: #4b5563;
166+ font-size: 0.9rem;
167+}
168+
169+.markdown > :first-child {
170+ margin-top: 0;
171+}
172+
173+.markdown > :last-child {
174+ margin-bottom: 0;
175+}
176+
177+@media (min-width: 48rem) {
178+ .repository-list {
179+ grid-template-columns: repeat(2, minmax(0, 1fr));
180+ }
181+}
182diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
183index b1f0f735abdacc9ea7b3183967fc3edaecf0b2c5..6968f39d80c48af98c1374951c43e6bc3f3aefc8 100644
184--- a/pkg/web/pages/controller.go
185+++ b/pkg/web/pages/controller.go
186@@ -9,6 +9,7 @@ import (
187 "net/http"
188
189 "github.com/charmbracelet/soft-serve/pkg/config"
190+ "github.com/charmbracelet/soft-serve/pkg/proto"
191 "github.com/gorilla/mux"
192 )
193
194@@ -25,19 +26,96 @@ func Controller(_ context.Context, r *mux.Router) {
195 r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
196 }
197
198+type homePage struct {
199+ ServerName string
200+ Profile template.HTML
201+ Cards []repositoryCard
202+}
203+
204+type repositoryCard struct {
205+ Name string
206+ Title string
207+ Description string
208+ URL string
209+ UpdatedAt string
210+}
211+
212 func home(w http.ResponseWriter, r *http.Request) {
213- cfg := config.FromContext(r.Context())
214 SetSecurityHeaders(w)
215 w.Header().Set("Content-Type", "text/html; charset=utf-8")
216- if err := pageTemplates.ExecuteTemplate(w, "home", struct {
217- ServerName string
218- }{
219- ServerName: cfg.Name,
220- }); err != nil {
221+
222+ page, err := newHomePage(r.Context())
223+ if err != nil {
224+ http.Error(w, "failed to load page", http.StatusInternalServerError)
225+ return
226+ }
227+ if err := pageTemplates.ExecuteTemplate(w, "home", page); err != nil {
228 http.Error(w, "failed to render page", http.StatusInternalServerError)
229 }
230 }
231
232+func newHomePage(ctx context.Context) (homePage, error) {
233+ cfg := config.FromContext(ctx)
234+ repos, err := HomepageRepositories(ctx)
235+ if err != nil {
236+ return homePage{}, err
237+ }
238+
239+ page := homePage{
240+ ServerName: cfg.Name,
241+ Cards: make([]repositoryCard, 0, len(repos)),
242+ }
243+ for _, repo := range repos {
244+ page.Cards = append(page.Cards, newRepositoryCard(repo))
245+ }
246+
247+ page.Profile = loadProfile(ctx)
248+ return page, nil
249+}
250+
251+func newRepositoryCard(repo proto.Repository) repositoryCard {
252+ title := repo.ProjectName()
253+ if title == "" {
254+ title = repo.Name()
255+ }
256+
257+ updatedAt := ""
258+ if updated := repo.UpdatedAt(); !updated.IsZero() {
259+ updatedAt = updated.UTC().Format("2006-01-02 15:04 UTC")
260+ }
261+ return repositoryCard{
262+ Name: repo.Name(),
263+ Title: title,
264+ Description: repo.Description(),
265+ URL: RepositoryURL(repo.Name()),
266+ UpdatedAt: updatedAt,
267+ }
268+}
269+
270+func loadProfile(ctx context.Context) template.HTML {
271+ repo, err := PublicRepository(ctx, ".soft-serve")
272+ if err != nil {
273+ return ""
274+ }
275+ gitRepo, err := repo.Open()
276+ if err != nil {
277+ return ""
278+ }
279+ ref, err := ResolveRef(gitRepo, "")
280+ if err != nil {
281+ return ""
282+ }
283+ readme, err := LoadReadme(gitRepo, ref)
284+ if err != nil || readme.Binary {
285+ return ""
286diff --git a/pkg/web/pages/templates/home.gohtml b/pkg/web/pages/templates/home.gohtml
287index bcd576eaf2022349b65f4019423e63db41254405..6aae21f17c903ba2cc03d61a11c800f8b47009dc 100644
288--- a/pkg/web/pages/templates/home.gohtml
289+++ b/pkg/web/pages/templates/home.gohtml
290@@ -1,4 +1,4 @@
291-{{ define "home" }}<!doctype html>
292+{{ define "base" }}<!doctype html>
293 <html lang="en">
294 <head>
295 <meta charset="utf-8">
296@@ -7,11 +7,44 @@
297 <link rel="stylesheet" href="/@/assets/site.css">
298 </head>
299 <body>
300- <header>
301- <a href="/">{{ .ServerName }}</a>
302+ <header class="site-header">
303+ <nav aria-label="Primary">
304+ <a class="site-name" href="/">{{ .ServerName }}</a>
305+ </nav>
306 </header>
307 <main>
308- <h1>{{ .ServerName }}</h1>
309+ {{ template "home-content" . }}
310 </main>
311 </body>
312 </html>{{ end }}
313+
314+{{ define "home" }}{{ template "base" . }}{{ end }}
315+
316+{{ define "home-content" }}
317+ <h1>Public projects</h1>
318+ {{ if .Profile }}
319+ <section class="profile" aria-labelledby="profile-heading">
320+ <h2 id="profile-heading">About {{ .ServerName }}</h2>
321+ <div class="markdown">{{ .Profile }}</div>
322+ </section>
323+ {{ end }}
324+ <section aria-labelledby="projects-heading">
325+ <h2 id="projects-heading">Projects</h2>
326+ {{ if .Cards }}
327+ <ul class="repository-list">
328+ {{ range .Cards }}
329+ <li>
330+ <article class="repository-card">
331+ <h3><a href="{{ .URL }}">{{ .Title }}</a></h3>
332+ {{ if ne .Title .Name }}<p class="repository-name">{{ .Name }}</p>{{ end }}
333+ {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
334+ {{ if .UpdatedAt }}<p class="updated">Updated <time>{{ .UpdatedAt }}</time></p>{{ end }}
335+ </article>
336+ </li>
337+ {{ end }}
338+ </ul>
339+ {{ else }}
340+ <p>No public projects are available.</p>
341+ {{ end }}
342+ </section>
343+{{ end }}
344diff --git a/pkg/web/pages/urls.go b/pkg/web/pages/urls.go
345index 7a86e7b49da50b77998a0e1747bd06adac4a3e1d..41778a6cd5b9b557f84e9b79153eaf18b87c35aa 100644
346--- a/pkg/web/pages/urls.go
347+++ b/pkg/web/pages/urls.go
348@@ -2,6 +2,11 @@ package pages
349
350 import "net/url"
351
352+// RepositoryURL builds a repository overview URL without interpolating a name.
353+func RepositoryURL(repository string) string {
354+ return (&url.URL{Path: "/"}).JoinPath(splitRepositoryName(repository)...).String()
355+}
356+
357 // PageURL builds a page operation URL without interpolating untrusted values.
358 func PageURL(repository, operation string, query url.Values) string {
359 segments := append(splitRepositoryName(repository), "@", operation)