feat: serve the student app from the plugin

This commit is contained in:
Jakub Zych
2026-10-02 14:43:02 +02:00
parent 5106239c97
commit 21107e9804
7 changed files with 491 additions and 0 deletions

View File

@@ -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 `<backend.uri>/api/v1/jz/bm/<name>`), all in the "Studia BM" navigation group:

View File

@@ -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" }

View File

@@ -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)

0
public/.gitkeep Normal file
View File

View File

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

205
static.go Normal file
View File

@@ -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)
}

263
static_test.go Normal file
View File

@@ -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 = "<!doctype html><div id=app></div>"
)
// fixturePublic is a built public/ tree in miniature.
func fixturePublic() fstest.MapFS {
files := map[string]string{
"index.html": indexBody,
"favicon.svg": "<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, "<svg/>", 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()), "<!doctype html") {
t.Fatalf("GET /plan = %d", rec.Code)
}
}
// TestStaticThroughRouter assembles the real router: the API keeps its
// routes and its 401, the reserved paths do not fall back to the index, and
// the rest does.
func TestStaticThroughRouter(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=testing", "SUMMER_GOLEM15__USER__JWT__SECRET=jz-bm-test-only-user-jwt-secret-32bytes",
"SUMMER_ADMIN__JWT__SECRET=jz-bm-test-only-admin-jwt-secret-32bytes",
}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, []string{"golem15.user", "jz.bm"})
if err != nil {
t.Fatal(err)
}
for _, pl := range plugins {
if p, ok := pl.(*Plugin); ok {
p.publicFS = fixturePublic()
}
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatalf("Assemble: %v", err)
}
cases := []struct {
method, target string
status int
body string
}{
{http.MethodGet, "/", 200, indexBody},
{http.MethodGet, "/lektury", 200, indexBody},
{http.MethodHead, "/lektury", 200, ""},
{http.MethodGet, "/assets/index-abc.js", 200, "console.log(1)"},
{http.MethodGet, "/_bm/api/v1/dashboard", 401, ""},
{http.MethodGet, "/_bm/api/v1/nope", 404, ""},
{http.MethodGet, "/_user/api/v1/nope", 404, ""},
{http.MethodGet, "/_quizzes/api/v1/nope", 404, ""},
{http.MethodGet, "/storage/app/uploads/public/x.pdf", 404, ""},
}
for _, c := range cases {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(c.method, c.target, nil))
if rec.Code != c.status {
t.Fatalf("%s %s = %d, want %d (%s)", c.method, c.target, rec.Code, c.status, rec.Body.String())
}
if c.body != "" && rec.Body.String() != c.body {
t.Fatalf("%s %s body %q", c.method, c.target, rec.Body.String())
}
if c.status == 404 && strings.Contains(rec.Body.String(), "<div") {
t.Fatalf("%s %s fell back to the index", c.method, c.target)
}
}
}