e02f9568573909237fe91d83f5b2b0302cd4fd88

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): add public homepage

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"`, "&lt;script&gt;Public project&lt;/script&gt;", "&lt;img src=x onerror=&#34;alert(1)&#34;&gt;", "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)