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+}