206 lines
5.6 KiB
Go
206 lines
5.6 KiB
Go
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)
|
|
}
|