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", "/_presentations", "/api", "/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) }