199 lines
6.7 KiB
Go
199 lines
6.7 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
webassets "ymhut-box/server/unified-management/web"
|
|
)
|
|
|
|
func (r *router) handleDownload(w http.ResponseWriter, req *http.Request) {
|
|
name := strings.TrimPrefix(cleanPath(req.URL.Path), "/downloads/")
|
|
if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid filename"))
|
|
return
|
|
}
|
|
path := filepath.Join(r.cfg.DownloadsDir, name)
|
|
resolved, err := filepath.Abs(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
|
return
|
|
}
|
|
base, _ := filepath.Abs(r.cfg.DownloadsDir)
|
|
if !strings.HasPrefix(resolved, base) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600")
|
|
http.ServeFile(w, req, resolved)
|
|
}
|
|
|
|
func serveStaticAsset(w http.ResponseWriter, req *http.Request, root, embedRoot, assetPath string) {
|
|
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
|
return
|
|
}
|
|
setStaticCacheHeaders(w, assetPath)
|
|
if tryServeDiskFile(w, req, root, assetPath) {
|
|
return
|
|
}
|
|
if serveEmbeddedFile(w, req, embedRoot+"/"+filepath.ToSlash(assetPath)) {
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.NotFound(w, req)
|
|
}
|
|
|
|
func (r *router) serveServerAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
|
|
serveStaticAsset(w, req, filepath.Join(r.cfg.BaseDir, "assets"), "", assetPath)
|
|
}
|
|
|
|
func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, assetPath string) {
|
|
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid asset path"))
|
|
return
|
|
}
|
|
setStaticCacheHeaders(w, assetPath)
|
|
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
|
|
if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) {
|
|
return
|
|
}
|
|
log.Printf("admin web disk build is missing requested asset: %s", assetPath)
|
|
} else {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err)
|
|
}
|
|
if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) {
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
http.NotFound(w, req)
|
|
}
|
|
|
|
func serveSetupServerAsset(w http.ResponseWriter, req *http.Request, cfgRoot, assetPath string) {
|
|
serveStaticAsset(w, req, filepath.Join(cfgRoot, "assets"), "", assetPath)
|
|
}
|
|
|
|
func tryServeDiskFile(w http.ResponseWriter, req *http.Request, root, assetPath string) bool {
|
|
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
|
resolved, err := filepath.Abs(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "PATH_FAILED", err)
|
|
return true
|
|
}
|
|
base, _ := filepath.Abs(root)
|
|
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("path escape rejected"))
|
|
return true
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil || info.IsDir() {
|
|
return false
|
|
}
|
|
http.ServeFile(w, req, resolved)
|
|
return true
|
|
}
|
|
|
|
func serveEmbeddedFile(w http.ResponseWriter, req *http.Request, name string) bool {
|
|
if strings.Contains(name, "..") || strings.ContainsAny(name, `\`) {
|
|
writeError(w, http.StatusForbidden, "FORBIDDEN", errors.New("invalid embedded asset path"))
|
|
return true
|
|
}
|
|
data, err := webassets.ReadFile(name)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
|
|
w.Header().Set("Content-Type", contentType)
|
|
}
|
|
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
|
|
return true
|
|
}
|
|
|
|
func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
index := filepath.Join(r.cfg.PortalWebDir, "index.html")
|
|
if _, err := os.Stat(index); err == nil {
|
|
http.ServeFile(w, req, index)
|
|
return
|
|
}
|
|
if serveEmbeddedFile(w, req, "portal/dist/index.html") {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Box</title></head><body><main><h1>YMhut Box</h1><p>Unified management service is running.</p><p><a href="/api/client/bootstrap">Client bootstrap</a> | <a href="/admin/login">Admin</a></p></main></body></html>`))
|
|
}
|
|
|
|
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
|
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
|
|
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
|
|
http.ServeFile(w, req, index)
|
|
return
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("admin web disk build is incomplete: %v", err)
|
|
}
|
|
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
|
|
}
|
|
|
|
var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
|
|
|
|
func validateAdminDiskBuild(root string) error {
|
|
index := filepath.Join(root, "index.html")
|
|
data, err := os.ReadFile(index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
matches := adminAssetReferencePattern.FindAllSubmatch(data, -1)
|
|
if len(matches) == 0 {
|
|
return errors.New("index.html does not reference any admin assets")
|
|
}
|
|
for _, match := range matches {
|
|
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
|
|
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
|
|
return fmt.Errorf("invalid admin asset reference %s", assetPath)
|
|
}
|
|
path := filepath.Join(root, filepath.FromSlash(assetPath))
|
|
info, statErr := os.Stat(path)
|
|
if statErr != nil {
|
|
return fmt.Errorf("missing %s: %w", assetPath, statErr)
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("%s is not a file", assetPath)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
|
|
extension := strings.ToLower(filepath.Ext(assetPath))
|
|
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600")
|
|
}
|
|
|
|
func isPortalRoute(path string) bool {
|
|
switch path {
|
|
case "/", "/releases", "/sources", "/feedback", "/compatibility":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|