e76c55401622130e49653fdd4b54be1def0c6f71

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

Message

feat(web): add opt-in public UI shell

Diff

  1diff --git a/README.md b/README.md
  2index 3afa497ef45ed02d2f511fca772cbf003eb3cc42..9968b6f50097f5cb009e1f7f485af53a0c09242c 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -222,6 +222,10 @@ http:
  6   # Make sure to use https:// if you are using TLS.
  7   public_url: "http://localhost:23232"
  8 
  9+  # Enable the public web UI.
 10+  web_ui:
 11+    enabled: false
 12+
 13   # The cross-origin request security options
 14   cors:
 15     # The allowed cross-origin headers
 16@@ -302,6 +306,7 @@ name all in uppercase. Here are some examples:
 17 - `SOFT_SERVE_SSH_KEY_PATH`: SSH host key-pair path
 18 - `SOFT_SERVE_HTTP_LISTEN_ADDR`: HTTP listen address
 19 - `SOFT_SERVE_HTTP_PUBLIC_URL`: HTTP public URL used for cloning
 20+- `SOFT_SERVE_HTTP_WEB_UI_ENABLED`: Enable the public web UI
 21 - `SOFT_SERVE_GIT_MAX_CONNECTIONS`: The number of simultaneous connections to git daemon
 22 - `SOFT_SERVE_ANON_ACCESS`: Overrides the `anon-access` setting (see [Authentication](#authentication))
 23 - `SOFT_SERVE_ALLOW_KEYLESS`: Overrides the `allow-keyless` setting (see [Authentication](#authentication))
 24diff --git a/pkg/config/config.go b/pkg/config/config.go
 25index fc025c10f8464d256c6b0195939cb1194fb402c1..04b271a75c3f885dc72df163c422204de5f02112 100644
 26--- a/pkg/config/config.go
 27+++ b/pkg/config/config.go
 28@@ -71,6 +71,11 @@ type CORSConfig struct {
 29 	AllowedMethods []string `env:"ALLOWED_METHODS" yaml:"allowed_methods"`
 30 }
 31 
 32+// WebUIConfig is the configuration for the public web UI.
 33+type WebUIConfig struct {
 34+	Enabled bool `env:"ENABLED" yaml:"enabled"`
 35+}
 36+
 37 // HTTPConfig is the HTTP configuration for the server.
 38 type HTTPConfig struct {
 39 	// Enabled toggles the HTTP server on/off
 40@@ -90,6 +95,9 @@ type HTTPConfig struct {
 41 
 42 	// CORS is the cross-origin configuration for the HTTP server.
 43 	CORS CORSConfig `envPrefix:"CORS_" yaml:"cors"`
 44+
 45+	// WebUI is the configuration for the public web UI.
 46+	WebUI WebUIConfig `envPrefix:"WEB_UI_" yaml:"web_ui"`
 47 }
 48 
 49 // StatsConfig is the configuration for the stats server.
 50@@ -236,6 +244,7 @@ func (c *Config) Environ() []string {
 51 		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_HEADERS=%s", strings.Join(c.HTTP.CORS.AllowedHeaders, ",")),
 52 		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_ORIGINS=%s", strings.Join(c.HTTP.CORS.AllowedOrigins, ",")),
 53 		fmt.Sprintf("SOFT_SERVE_HTTP_CORS_ALLOWED_METHODS=%s", strings.Join(c.HTTP.CORS.AllowedMethods, ",")),
 54+		fmt.Sprintf("SOFT_SERVE_HTTP_WEB_UI_ENABLED=%t", c.HTTP.WebUI.Enabled),
 55 		fmt.Sprintf("SOFT_SERVE_STATS_ENABLED=%t", c.Stats.Enabled),
 56 		fmt.Sprintf("SOFT_SERVE_STATS_LISTEN_ADDR=%s", c.Stats.ListenAddr),
 57 		fmt.Sprintf("SOFT_SERVE_LOG_FORMAT=%s", c.Log.Format),
 58@@ -407,6 +416,7 @@ func DefaultConfig() *Config {
 59 			Enabled:    true,
 60 			ListenAddr: ":23232",
 61 			PublicURL:  "http://localhost:23232",
 62+			WebUI:      WebUIConfig{Enabled: false},
 63 			CORS: CORSConfig{
 64 				AllowedHeaders: []string{"Accept", "Accept-Language", "Content-Language", "Content-Type", "Origin", "X-Requested-With", "User-Agent", "Authorization", "Access-Control-Request-Method", "Access-Control-Allow-Origin"},
 65 				AllowedMethods: []string{"GET", "HEAD", "POST", "PUT", "OPTIONS"},
 66diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
 67index 00dcee3036e13a0d937c3c53d123ecf25e10f8e1..a7b051eab95305398f42185d393e6a44f56c2ddf 100644
 68--- a/pkg/config/config_test.go
 69+++ b/pkg/config/config_test.go
 70@@ -86,6 +86,40 @@ func TestDefaultRepoDisabledByDefault(t *testing.T) {
 71 	is.Equal(DefaultConfig().DefaultRepo, "")
 72 }
 73 
 74+func TestWebUIDisabledByDefault(t *testing.T) {
 75+	is := is.New(t)
 76+	is.Equal(DefaultConfig().HTTP.WebUI.Enabled, false)
 77+}
 78+
 79+func TestParseWebUIEnabled(t *testing.T) {
 80+	is := is.New(t)
 81+
 82+	cfg := DefaultConfig()
 83+	path := t.TempDir() + "/config.yaml"
 84+	is.NoErr(os.WriteFile(path, []byte("http:\n  web_ui:\n    enabled: true\n"), 0o600))
 85+	is.NoErr(parseFile(cfg, path))
 86+	is.Equal(cfg.HTTP.WebUI.Enabled, true)
 87+
 88+	is.NoErr(os.Setenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED", "true"))
 89+	t.Cleanup(func() {
 90+		is.NoErr(os.Unsetenv("SOFT_SERVE_HTTP_WEB_UI_ENABLED"))
 91+	})
 92+	cfg = DefaultConfig()
 93+	is.NoErr(cfg.ParseEnv())
 94+	is.Equal(cfg.HTTP.WebUI.Enabled, true)
 95+}
 96+
 97+func TestWebUIEnviron(t *testing.T) {
 98+	cfg := DefaultConfig()
 99+	cfg.HTTP.WebUI.Enabled = true
100+	for _, env := range cfg.Environ() {
101+		if env == "SOFT_SERVE_HTTP_WEB_UI_ENABLED=true" {
102+			return
103+		}
104+	}
105+	t.Error("Config.Environ() did not include SOFT_SERVE_HTTP_WEB_UI_ENABLED=true")
106+}
107+
108 func TestParseDefaultRepo(t *testing.T) {
109 	is := is.New(t)
110 	is.NoErr(os.Setenv("SOFT_SERVE_DEFAULT_REPO", "gitops"))
111diff --git a/pkg/config/file.go b/pkg/config/file.go
112index 8a928e7adf8e5a113dd3244944865c54621e52c6..ad5950308bbfec56dc0fbaf94c79fbae728119b2 100644
113--- a/pkg/config/file.go
114+++ b/pkg/config/file.go
115@@ -89,6 +89,10 @@ http:
116   # Make sure to use https:// if you are using TLS.
117   public_url: "{{ .HTTP.PublicURL }}"
118 
119+  # Enable the public web UI.
120+  web_ui:
121+    enabled: {{ .HTTP.WebUI.Enabled }}
122+
123   # The cross-origin request security options
124   cors:
125     # The allowed cross-origin headers
126diff --git a/pkg/config/file_test.go b/pkg/config/file_test.go
127index 60b60db91d9b79867d642195687223b45daec452..f067869c85ad8df18bcc0e26918999fe9572f944 100644
128--- a/pkg/config/file_test.go
129+++ b/pkg/config/file_test.go
130@@ -1,6 +1,9 @@
131 package config
132 
133-import "testing"
134+import (
135+	"strings"
136+	"testing"
137+)
138 
139 func TestNewConfigFile(t *testing.T) {
140 	for _, cfg := range []*Config{
141@@ -13,3 +16,9 @@ func TestNewConfigFile(t *testing.T) {
142 		}
143 	}
144 }
145+
146+func TestNewConfigFileIncludesWebUI(t *testing.T) {
147+	if s := newConfigFile(DefaultConfig()); !strings.Contains(s, "web_ui:\n    enabled: false") {
148+		t.Errorf("generated config does not include disabled web_ui: %q", s)
149+	}
150+}
151diff --git a/pkg/web/git.go b/pkg/web/git.go
152index 16c967ffab9ff0fe83d93e1346f3c8abf8a11072..7ba1e2f8fab8e7f3abfba13a70bb9e2c8ca3fce7 100644
153--- a/pkg/web/git.go
154+++ b/pkg/web/git.go
155@@ -111,9 +111,6 @@ func GitController(_ context.Context, r *mux.Router) {
156 		// request vars will not be set.
157 		r.Handle(basePrefix+route.path, withParams(withAccess(route)))
158 	}
159-
160-	// Handle go-get
161-	r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)
162 }
163 
164 var gitRoutes = []GitRoute{
165diff --git a/pkg/web/goget.go b/pkg/web/goget.go
166index fa486d5b3d8919fd934093e71f2c1a59ff555b3a..ac5bf35a109dd4cca544ffa6de25b5ae20a0b8b5 100644
167--- a/pkg/web/goget.go
168+++ b/pkg/web/goget.go
169@@ -1,6 +1,7 @@
170 package web
171 
172 import (
173+	"context"
174 	"net/http"
175 	"net/url"
176 	"path"
177@@ -35,6 +36,14 @@ Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}"
178 </html>
179 `))
180 
181+// GoGetController registers Go import metadata routes.
182+func GoGetController(_ context.Context, r *mux.Router) {
183+	basePrefix := "/{repo:.*}"
184+	r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).
185+		Methods(http.MethodGet).
186+		Queries("go-get", "1")
187+}
188+
189 // GoGetHandler handles go get requests.
190 func GoGetHandler(w http.ResponseWriter, r *http.Request) {
191 	ctx := r.Context()
192diff --git a/pkg/web/pages/assets/site.css b/pkg/web/pages/assets/site.css
193new file mode 100644
194index 0000000000000000000000000000000000000000..5af011a8943f1f9bd6e8123880b3135154bf22c4
195--- /dev/null
196+++ b/pkg/web/pages/assets/site.css
197@@ -0,0 +1,14 @@
198+body {
199+  margin: 0 auto;
200+  max-width: 72rem;
201+  padding: 1rem;
202+  color: #1f2937;
203+  font-family: system-ui, sans-serif;
204+  line-height: 1.5;
205+}
206+
207+header a {
208+  color: inherit;
209+  font-weight: 700;
210+  text-decoration: none;
211+}
212diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
213new file mode 100644
214index 0000000000000000000000000000000000000000..08de5959f957947012717f8d7e8173de0eed24b0
215--- /dev/null
216+++ b/pkg/web/pages/controller.go
217@@ -0,0 +1,50 @@
218+// Package pages provides the public web UI routes.
219+package pages
220+
221+import (
222+	"context"
223+	"embed"
224+	"html/template"
225+	"io/fs"
226+	"net/http"
227+
228+	"github.com/charmbracelet/soft-serve/pkg/config"
229+	"github.com/gorilla/mux"
230+)
231+
232+var (
233+	//go:embed templates/*.gohtml assets/*
234+	files embed.FS
235+
236+	pageTemplates = template.Must(template.ParseFS(files, "templates/*.gohtml"))
237+)
238+
239+// Controller registers public web UI routes.
240+func Controller(_ context.Context, r *mux.Router) {
241+	r.HandleFunc("/", home).Methods(http.MethodGet)
242+	r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
243+}
244+
245+func home(w http.ResponseWriter, r *http.Request) {
246+	cfg := config.FromContext(r.Context())
247+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
248+	if err := pageTemplates.ExecuteTemplate(w, "home", struct {
249+		ServerName string
250+	}{
251+		ServerName: cfg.Name,
252+	}); err != nil {
253+		http.Error(w, "failed to render page", http.StatusInternalServerError)
254+	}
255+}
256+
257+func stylesheet(w http.ResponseWriter, r *http.Request) {
258+	css, err := fs.ReadFile(files, "assets/site.css")
259+	if err != nil {
260+		http.Error(w, "embedded stylesheet is unavailable", http.StatusInternalServerError)
261+		return
262+	}
263+
264+	w.Header().Set("Content-Type", "text/css; charset=utf-8")
265+	w.Header().Set("Cache-Control", "public, max-age=31536000")
266+	_, _ = w.Write(css)
267+}
268diff --git a/pkg/web/pages/templates/home.gohtml b/pkg/web/pages/templates/home.gohtml
269new file mode 100644
270index 0000000000000000000000000000000000000000..bcd576eaf2022349b65f4019423e63db41254405
271--- /dev/null
272+++ b/pkg/web/pages/templates/home.gohtml
273@@ -0,0 +1,17 @@
274+{{ define "home" }}<!doctype html>
275+<html lang="en">
276+<head>
277+  <meta charset="utf-8">
278+  <meta name="viewport" content="width=device-width, initial-scale=1">
279+  <title>{{ .ServerName }}</title>
280+  <link rel="stylesheet" href="/@/assets/site.css">
281+</head>
282+<body>
283+  <header>
284+    <a href="/">{{ .ServerName }}</a>
285+  </header>
286+  <main>
287+    <h1>{{ .ServerName }}</h1>
288+  </main>
289+</body>
290+</html>{{ end }}
291diff --git a/pkg/web/server.go b/pkg/web/server.go
292index 8167af260cd7a69fe82b6cb7e69d2b6293c62ce2..e9c5701c316684354bfc977e090a60845a13b73d 100644
293--- a/pkg/web/server.go
294+++ b/pkg/web/server.go
295@@ -6,6 +6,7 @@ import (
296 
297 	"charm.land/log/v2"
298 	"github.com/charmbracelet/soft-serve/pkg/config"
299+	"github.com/charmbracelet/soft-serve/pkg/web/pages"
300 	"github.com/gorilla/handlers"
301 	"github.com/gorilla/mux"
302 )
303@@ -18,9 +19,16 @@ func NewRouter(ctx context.Context) http.Handler {
304 	// Health routes
305 	HealthController(ctx, router)
306 
307-	// Git routes
308+	// Go import metadata routes
309+	GoGetController(ctx, router)
310+
311+	// Git and LFS routes
312 	GitController(ctx, router)
313 
314+	if config.FromContext(ctx).HTTP.WebUI.Enabled {
315+		pages.Controller(ctx, router)
316+	}
317+
318 	router.PathPrefix("/").HandlerFunc(renderNotFound)
319 
320 	// Context handler
321diff --git a/pkg/web/server_test.go b/pkg/web/server_test.go
322new file mode 100644
323index 0000000000000000000000000000000000000000..c0b3ca47e18cd30b8526365fe76aa69fccdf4239
324--- /dev/null
325+++ b/pkg/web/server_test.go
326@@ -0,0 +1,65 @@
327+package web
328+
329+import (
330+	"net/http"
331+	"net/http/httptest"
332+	"strings"
333+	"testing"
334+
335+	"github.com/charmbracelet/soft-serve/pkg/access"
336+	"github.com/charmbracelet/soft-serve/pkg/config"
337+	"github.com/charmbracelet/soft-serve/pkg/proto"
338+	"github.com/matryer/is"
339+)
340+
341+func TestRouterWebUIShellIsOptIn(t *testing.T) {
342+	is := is.New(t)
343+	ctx, _, _ := newLFSTestContext(t)
344+	cfg := config.FromContext(ctx)
345+
346+	w := httptest.NewRecorder()
347+	NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
348+	is.Equal(w.Code, http.StatusNotFound)
349+
350+	cfg.HTTP.WebUI.Enabled = true
351+	router := NewRouter(ctx)
352+
353+	w = httptest.NewRecorder()
354+	router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
355+	is.Equal(w.Code, http.StatusOK)
356+	is.True(strings.Contains(w.Body.String(), cfg.Name))
357+	is.Equal(w.Header().Get("Content-Type"), "text/html; charset=utf-8")
358+
359+	w = httptest.NewRecorder()
360+	router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/@/assets/site.css", nil))
361+	is.Equal(w.Code, http.StatusOK)
362+	is.Equal(w.Header().Get("Content-Type"), "text/css; charset=utf-8")
363+	is.True(strings.Contains(w.Body.String(), "font-family"))
364+}
365+
366+func TestRouterGoGetAndGitRoutesPrecedePages(t *testing.T) {
367+	is := is.New(t)
368+	ctx, be, _ := newLFSTestContext(t)
369+	cfg := config.FromContext(ctx)
370+	cfg.HTTP.WebUI.Enabled = true
371+	allowKeyless := true
372+	cfg.AllowKeyless = &allowKeyless
373+	anonAccess := access.ReadOnlyAccess
374+	cfg.AnonAccess = &anonAccess
375+
376+	owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
377+	is.NoErr(err)
378+	_, err = be.CreateRepository(ctx, "example", owner, proto.RepositoryOptions{})
379+	is.NoErr(err)
380+
381+	router := NewRouter(ctx)
382+
383+	w := httptest.NewRecorder()
384+	router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/example?go-get=1", nil))
385+	is.Equal(w.Code, http.StatusOK)
386+	is.True(strings.Contains(w.Body.String(), `name="go-import"`))
387+
388+	w = httptest.NewRecorder()
389+	router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/example.git/info/refs", nil))
390+	is.Equal(w.Code, http.StatusMethodNotAllowed)
391+}