diff --git a/README.md b/README.md index 8573c24..afba321 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,10 @@ Newest publication first. The detail adds the Markdown body and the public files "files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}]}} ``` +## Student app + +The built single-page app (Vue, source in the application repository's `frontend/`) is embedded from `public/` and served at `/`. Existing files are served as they are: `assets/*` (content-hashed) with `Cache-Control: public, max-age=31536000, immutable`, everything else with `no-cache` and an ETag (304 on revalidation). A GET for any other extension-less path answers `index.html` (client routes); a missing path with an extension is a 404. `/_user`, `/_bm`, `/_quizzes`, `/storage` and the admin path (`backend.uri`, default `/bmadmin`) are never answered by the app. Routes fails when `public/index.html` is missing. The output of `npm run build` in `frontend/` is committed in `public/`. + ## Admin Controllers (admin API path `/api/v1/jz/bm/`), all in the "Studia BM" navigation group: diff --git a/plugin.go b/plugin.go index 852133d..8275eae 100644 --- a/plugin.go +++ b/plugin.go @@ -46,6 +46,9 @@ var adminFS embed.FS // (Bezpieczeństwo międzynarodowe) master's programme. type Plugin struct { app *backpack.App + // publicFS is the tree of the student app. Nil means the embedded + // public/ directory; tests plant their own. + publicFS fs.FS } func (p *Plugin) ID() string { return "jz.bm" } diff --git a/plugin_test.go b/plugin_test.go index bd4ee6c..6fc2613 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -115,6 +115,13 @@ func TestRoutes(t *testing.T) { if rt.PluginID != "jz.bm" { continue } + if rt.Method+" "+rt.Pattern == "GET /{path...}" { + // The student app, public by design; see static_test.go. + if len(rt.Middleware) != 0 { + t.Errorf("the student app has middleware %v", rt.Middleware) + } + continue + } got = append(got, rt.Method+" "+rt.Pattern) if !slices.Contains(rt.Middleware, "jwt.auth") { t.Errorf("%s %s is not behind jwt.auth: %v", rt.Method, rt.Pattern, rt.Middleware) diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/routes.go b/routes.go index e4bb245..4daa3b4 100644 --- a/routes.go +++ b/routes.go @@ -34,6 +34,15 @@ func (p *Plugin) Routes(r pact.Router) error { g.Get("/materials/{id}", h.Material) g.Where("id", "[0-9]+") }) + // The student app: the least specific pattern, so every route above and + // the admin's keep precedence. It also answers (404) the reserved paths. + static, err := newStaticHandler(p.app, p.publicFS) + if err != nil { + return err + } + r.GroupRaw("", nil, func(g pact.Router) { + g.Get("/{path...}", static.ServeHTTP) + }) return nil } diff --git a/static.go b/static.go new file mode 100644 index 0000000..f48978d --- /dev/null +++ b/static.go @@ -0,0 +1,205 @@ +package bm + +import ( + "bytes" + "crypto/sha256" + "embed" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "mime" + "net/http" + "path" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" +) + +// publicFS holds the build output of the student app (frontend/ in the +// application repository). The all: prefix keeps dot-less underscore names +// that embed would otherwise drop. +// +//go:embed all:public +var publicFS embed.FS + +const ( + // cacheImmutable is sent for content-hashed assets (Vite writes them to + // assets/). + cacheImmutable = "public, max-age=31536000, immutable" + // cacheNoCache is sent for every other file; the ETag makes the + // revalidation a cheap 304. + cacheNoCache = "no-cache" +) + +// defaultAdminPath is the admin mount used when backend.uri is not set. +const defaultAdminPath = "/bmadmin" + +// reservedPrefixes are the path prefixes the student app never answers: the +// APIs of the plugins and the public uploads. The admin path is added from +// the configuration. +var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/storage"} + +// errMissingIndex reports a tree without index.html (an unbuilt tree). +var errMissingIndex = errors.New("jz.bm: public/index.html missing; run the frontend build (cd frontend && npm ci && npm run build)") + +// contentTypes is consulted before the platform MIME table, which lacks +// .woff2, .woff and .webmanifest on a minimal server. +var contentTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json", + ".map": "application/json", + ".txt": "text/plain; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".webmanifest": "application/manifest+json", +} + +func contentType(name string) string { + ext := strings.ToLower(path.Ext(name)) + if ct, ok := contentTypes[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} + +type staticFile struct { + body []byte + etag string + ctype string +} + +// staticApp serves the built single-page app: files by path, index.html for +// every other extension-less GET outside the reserved prefixes. +type staticApp struct { + files map[string]*staticFile + reserved []string +} + +// newStaticApp loads every regular file of fsys whose path has no +// dot-segment. adminPath is the admin mount, kept out of the fallback. +func newStaticApp(fsys fs.FS, adminPath string) (*staticApp, error) { + if info, err := fs.Stat(fsys, "index.html"); err != nil || !info.Mode().IsRegular() { + return nil, errMissingIndex + } + s := &staticApp{files: map[string]*staticFile{}, reserved: append([]string{}, reservedPrefixes...)} + if adminPath = "/" + strings.Trim(adminPath, "/"); adminPath != "/" { + s.reserved = append(s.reserved, adminPath) + } + err := fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if name != "." && strings.HasPrefix(d.Name(), ".") { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if !d.Type().IsRegular() { + return nil + } + body, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + sum := sha256.Sum256(body) + s.files[name] = &staticFile{ + body: body, + etag: `"` + hex.EncodeToString(sum[:])[:16] + `"`, + ctype: contentType(name), + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("jz.bm: public: %w", err) + } + return s, nil +} + +// isReserved reports whether p is one of the reserved prefixes or below one. +func (s *staticApp) isReserved(p string) bool { + for _, r := range s.reserved { + if p == r || strings.HasPrefix(p, r+"/") { + return true + } + } + return false +} + +func (s *staticApp) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, `\`) { + http.NotFound(w, r) + return + } + clean := path.Clean("/" + r.URL.Path) + if s.isReserved(clean) { + http.NotFound(w, r) + return + } + name := strings.TrimPrefix(clean, "/") + if name == "" { + name = "index.html" + } + for _, seg := range strings.Split(name, "/") { + if strings.HasPrefix(seg, ".") { + http.NotFound(w, r) + return + } + } + if f, ok := s.files[name]; ok { + s.serve(w, r, name, f) + return + } + // A missing file with an extension is a missing asset, not a client route. + if path.Ext(name) != "" { + http.NotFound(w, r) + return + } + s.serve(w, r, "index.html", s.files["index.html"]) +} + +func (s *staticApp) serve(w http.ResponseWriter, r *http.Request, name string, f *staticFile) { + h := w.Header() + h.Set("Content-Type", f.ctype) + if strings.HasPrefix(name, "assets/") { + h.Set("Cache-Control", cacheImmutable) + } else { + h.Set("Cache-Control", cacheNoCache) + } + h.Set("ETag", f.etag) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(f.body)) +} + +// newStaticHandler builds the handler over public (nil means the embedded +// public/ directory) with the admin path read from backend.uri. +func newStaticHandler(app *backpack.App, public fs.FS) (http.Handler, error) { + if public == nil { + sub, err := fs.Sub(publicFS, "public") + if err != nil { + return nil, fmt.Errorf("jz.bm: public: %w", err) + } + public = sub + } + admin := defaultAdminPath + if app != nil && app.Config != nil { + if v := strings.TrimSpace(app.Config.String("backend.uri")); v != "" { + admin = v + } + } + return newStaticApp(public, admin) +} diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..cd130ee --- /dev/null +++ b/static_test.go @@ -0,0 +1,263 @@ +package bm + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "testing/fstest" + + _ "git.golem15.com/golem15/sm-user-plugin" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" +) + +const ( + ctHTML = "text/html; charset=utf-8" + indexBody = "
" +) + +// fixturePublic is a built public/ tree in miniature. +func fixturePublic() fstest.MapFS { + files := map[string]string{ + "index.html": indexBody, + "favicon.svg": "", + "manifest.webmanifest": `{"name":"BM"}`, + "assets/index-abc.js": "console.log(1)", + "assets/index-abc.css": "body{}", + "assets/font-abc.woff2": "wOF2", + ".hidden": "secret", + ".git/config": "secret", + } + fsys := fstest.MapFS{} + for name, body := range files { + fsys[name] = &fstest.MapFile{Data: []byte(body)} + } + return fsys +} + +func handler(t *testing.T, admin string) http.Handler { + t.Helper() + h, err := newStaticApp(fixturePublic(), admin) + if err != nil { + t.Fatalf("newStaticApp: %v", err) + } + return h +} + +func get(h http.Handler, method, target string, header http.Header) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/", nil) + r.URL.Path = target + for k, vs := range header { + for _, v := range vs { + r.Header.Add(k, v) + } + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func TestStaticFilesAndFallback(t *testing.T) { + h := handler(t, "/bmadmin") + cases := []struct { + path, ctype, cache, body string + status int + }{ + {"/", ctHTML, cacheNoCache, indexBody, 200}, + {"/index.html", ctHTML, cacheNoCache, indexBody, 200}, + {"/favicon.svg", "image/svg+xml", cacheNoCache, "", 200}, + {"/manifest.webmanifest", "application/manifest+json", cacheNoCache, `{"name":"BM"}`, 200}, + {"/assets/index-abc.js", "text/javascript; charset=utf-8", cacheImmutable, "console.log(1)", 200}, + {"/assets/index-abc.css", "text/css; charset=utf-8", cacheImmutable, "body{}", 200}, + {"/assets/font-abc.woff2", "font/woff2", cacheImmutable, "wOF2", 200}, + // Client routes fall back to the index. + {"/plan", ctHTML, cacheNoCache, indexBody, 200}, + {"/przedmioty/12", ctHTML, cacheNoCache, indexBody, 200}, + {"/logowanie/", ctHTML, cacheNoCache, indexBody, 200}, + {"/reset-password", ctHTML, cacheNoCache, indexBody, 200}, + // A missing asset is a 404, never the index. + {"/assets/missing.js", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + {"/robots.txt", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + // Dot files and traversal. + {"/.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + {"/.git/config", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + {"/assets/../.hidden", "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + {`/assets\..\.hidden`, "text/plain; charset=utf-8", "", "404 page not found\n", 404}, + } + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + rec := get(h, http.MethodGet, c.path, nil) + if rec.Code != c.status { + t.Fatalf("status %d, want %d", rec.Code, c.status) + } + if got := rec.Header().Get("Content-Type"); got != c.ctype { + t.Fatalf("Content-Type %q, want %q", got, c.ctype) + } + if got := rec.Header().Get("Cache-Control"); got != c.cache { + t.Fatalf("Cache-Control %q, want %q", got, c.cache) + } + if rec.Body.String() != c.body { + t.Fatalf("body %q, want %q", rec.Body.String(), c.body) + } + }) + } +} + +func TestStaticReservedPaths(t *testing.T) { + h := handler(t, "/bmadmin") + for _, p := range []string{ + "/_user", "/_user/", "/_user/api/v1/nope", "/_bm/api/v1/nope", "/_quizzes/api/v1/nope", + "/bmadmin", "/bmadmin/", "/bmadmin/login", "/storage/app/uploads/public/x.pdf", + "/storage", "/_user/../_user/x", "//_bm/api", + } { + rec := get(h, http.MethodGet, p, nil) + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "app") { + t.Fatalf("GET %s = %d %q, want a plain 404", p, rec.Code, rec.Body.String()) + } + } + // Names that merely start like a reserved prefix are client routes. + for _, p := range []string{"/bmadmins", "/storage-info", "/_users"} { + if rec := get(h, http.MethodGet, p, nil); rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d, want the fallback", p, rec.Code) + } + } + // A custom admin mount is reserved too, the default is then a client route. + h = handler(t, "/panel/") + if rec := get(h, http.MethodGet, "/panel/x", nil); rec.Code != http.StatusNotFound { + t.Fatalf("GET /panel/x = %d, want 404", rec.Code) + } + if rec := get(h, http.MethodGet, "/bmadmin", nil); rec.Code != http.StatusOK { + t.Fatalf("GET /bmadmin with admin at /panel = %d, want 200", rec.Code) + } +} + +var etagForm = regexp.MustCompile(`^"[0-9a-f]{16}"$`) + +func TestStaticConditionalHeadAndRange(t *testing.T) { + h := handler(t, "/bmadmin") + for _, p := range []string{"/", "/plan", "/assets/index-abc.js"} { + rec := get(h, http.MethodGet, p, nil) + etag := rec.Header().Get("ETag") + if !etagForm.MatchString(etag) { + t.Fatalf("GET %s ETag %q", p, etag) + } + rec = get(h, http.MethodGet, p, http.Header{"If-None-Match": {etag}}) + if rec.Code != http.StatusNotModified || rec.Body.Len() != 0 { + t.Fatalf("GET %s If-None-Match = %d (%d bytes), want an empty 304", p, rec.Code, rec.Body.Len()) + } + if rec := get(h, http.MethodGet, p, http.Header{"If-None-Match": {`"0000000000000000"`}}); rec.Code != http.StatusOK { + t.Fatalf("GET %s stale tag = %d, want 200", p, rec.Code) + } + } + if a, b := get(h, http.MethodGet, "/", nil).Header().Get("ETag"), get(h, http.MethodGet, "/assets/index-abc.css", nil).Header().Get("ETag"); a == b { + t.Fatal("different files share an ETag") + } + rec := get(h, http.MethodHead, "/", nil) + if rec.Code != 200 || rec.Body.Len() != 0 || rec.Header().Get("Content-Length") == "" { + t.Fatalf("HEAD /: %d, %d body bytes, Content-Length %q", rec.Code, rec.Body.Len(), rec.Header().Get("Content-Length")) + } + rec = get(h, http.MethodGet, "/assets/font-abc.woff2", http.Header{"Range": {"bytes=0-1"}}) + if rec.Code != http.StatusPartialContent || rec.Body.String() != "wO" { + t.Fatalf("Range = %d %q", rec.Code, rec.Body.String()) + } + if got := get(h, http.MethodGet, "/", nil).Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Fatalf("X-Content-Type-Options = %q", got) + } +} + +func TestStaticNeedsIndex(t *testing.T) { + for name, fsys := range map[string]fstest.MapFS{ + "empty": {}, + "no index": {"assets/a.js": {Data: []byte("x")}}, + } { + if _, err := newStaticApp(fsys, "/bmadmin"); err == nil || !strings.Contains(err.Error(), "index.html missing") { + t.Fatalf("%s: err = %v", name, err) + } + } + // A dot directory is skipped whole while loading. + s, err := newStaticApp(fstest.MapFS{ + "index.html": {Data: []byte("x")}, ".git/config": {Data: []byte("s")}, "a/.env": {Data: []byte("s")}, "a/b.txt": {Data: []byte("ok")}, + }, "/bmadmin") + if err != nil { + t.Fatal(err) + } + if len(s.files) != 2 { + t.Fatalf("loaded %d files, want index.html and a/b.txt", len(s.files)) + } +} + +func TestEmbeddedPublicHasIndex(t *testing.T) { + h, err := newStaticHandler(nil, nil) + if err != nil { + t.Fatalf("the embedded public/ is not a built app: %v", err) + } + rec := get(h, http.MethodGet, "/plan", nil) + if rec.Code != 200 || !strings.Contains(strings.ToLower(rec.Body.String()), "