The connection-token route is guarded by jz.bm.realtime-access instead of presentations.realtime-owner: chat-group members get a token, and when golem15.presentations is active so does everyone its owner guard lets through (looked up by name among the activated plugins, no import). Everyone else gets 401, 403 or 503 in the guard's error shape. The realtime routes mount when golem15.chat or golem15.presentations is active. /_chat, /_chatchannels and /_chatbots are reserved prefixes of the student app.
208 lines
5.8 KiB
Go
208 lines
5.8 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 (the chat plugins' included), the public uploads and
|
|
// /oauth (the social sign-in routes of golem15.sociallogin and their
|
|
// provider callbacks). The admin path is added from the configuration.
|
|
var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/_chat", "/_chatchannels", "/_chatbots", "/api", "/storage", "/oauth"}
|
|
|
|
// 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",
|
|
".pdf": "application/pdf",
|
|
".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)
|
|
}
|