Files
ResolveSpec/pkg/server/staticweb/mount.go
2025-12-30 17:46:33 +02:00

236 lines
6.2 KiB
Go

package staticweb
import (
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strings"
)
// mountPoint represents a mounted filesystem at a specific URL prefix.
type mountPoint struct {
urlPrefix string
provider FileSystemProvider
cachePolicy CachePolicy
mimeResolver MIMETypeResolver
fallbackStrategy FallbackStrategy
fileServer http.Handler
}
// newMountPoint creates a new mount point with the given configuration.
func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error) {
if config.URLPrefix == "" {
return nil, fmt.Errorf("URLPrefix cannot be empty")
}
if !strings.HasPrefix(config.URLPrefix, "/") {
return nil, fmt.Errorf("URLPrefix must start with /")
}
if config.Provider == nil {
return nil, fmt.Errorf("provider cannot be nil")
}
mp := &mountPoint{
urlPrefix: config.URLPrefix,
provider: config.Provider,
cachePolicy: config.CachePolicy,
mimeResolver: config.MIMEResolver,
fallbackStrategy: config.FallbackStrategy,
}
// Apply defaults if policies are not specified
if mp.cachePolicy == nil && defaults != nil {
mp.cachePolicy = defaultCachePolicy(defaults.DefaultCacheTime)
}
if mp.mimeResolver == nil {
mp.mimeResolver = defaultMIMEResolver()
}
// Create an http.FileServer for serving files
mp.fileServer = http.FileServer(http.FS(config.Provider))
return mp, nil
}
// ServeHTTP handles HTTP requests for files in this mount point.
func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Strip the URL prefix to get the file path
filePath := strings.TrimPrefix(r.URL.Path, m.urlPrefix)
if filePath == "" {
filePath = "/"
}
// Clean the path
filePath = path.Clean(filePath)
// Try to open the file
file, err := m.provider.Open(strings.TrimPrefix(filePath, "/"))
if err != nil {
// File doesn't exist - check if we should use fallback
if m.fallbackStrategy != nil && m.fallbackStrategy.ShouldFallback(filePath) {
fallbackPath := m.fallbackStrategy.GetFallbackPath(filePath)
file, err = m.provider.Open(strings.TrimPrefix(fallbackPath, "/"))
if err == nil {
// Successfully opened fallback file
defer file.Close()
m.serveFile(w, r, fallbackPath, file)
return
}
}
// No fallback or fallback failed - return 404
http.NotFound(w, r)
return
}
defer file.Close()
// Serve the file
m.serveFile(w, r, filePath, file)
}
// serveFile serves a single file with appropriate headers.
func (m *mountPoint) serveFile(w http.ResponseWriter, r *http.Request, filePath string, file fs.File) {
// Get file info
stat, err := file.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// If it's a directory, try to serve index.html
if stat.IsDir() {
indexPath := path.Join(filePath, "index.html")
indexFile, err := m.provider.Open(strings.TrimPrefix(indexPath, "/"))
if err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
defer indexFile.Close()
indexStat, err := indexFile.Stat()
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
filePath = indexPath
stat = indexStat
file = indexFile
}
// Set Content-Type header using MIME resolver
if m.mimeResolver != nil {
if mimeType := m.mimeResolver.GetMIMEType(filePath); mimeType != "" {
w.Header().Set("Content-Type", mimeType)
}
}
// Apply cache policy
if m.cachePolicy != nil {
headers := m.cachePolicy.GetCacheHeaders(filePath)
for key, value := range headers {
w.Header().Set(key, value)
}
}
// Serve the content
if seeker, ok := file.(interface {
io.ReadSeeker
}); ok {
http.ServeContent(w, r, stat.Name(), stat.ModTime(), seeker)
} else {
// If the file doesn't support seeking, we need to read it all into memory
data, err := fs.ReadFile(m.provider, strings.TrimPrefix(filePath, "/"))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
http.ServeContent(w, r, stat.Name(), stat.ModTime(), strings.NewReader(string(data)))
}
}
// Close releases resources held by the mount point.
func (m *mountPoint) Close() error {
if m.provider != nil {
return m.provider.Close()
}
return nil
}
// defaultCachePolicy creates a default simple cache policy.
func defaultCachePolicy(cacheTime int) CachePolicy {
// Import the policies package type - we'll need to use the concrete type
// For now, create a simple inline implementation
return &simpleCachePolicy{cacheTime: cacheTime}
}
// simpleCachePolicy is a simple inline implementation of CachePolicy
type simpleCachePolicy struct {
cacheTime int
}
func (p *simpleCachePolicy) GetCacheTime(path string) int {
return p.cacheTime
}
func (p *simpleCachePolicy) GetCacheHeaders(path string) map[string]string {
if p.cacheTime <= 0 {
return map[string]string{
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
}
return map[string]string{
"Cache-Control": fmt.Sprintf("public, max-age=%d", p.cacheTime),
}
}
// defaultMIMEResolver creates a default MIME resolver.
func defaultMIMEResolver() MIMETypeResolver {
// Import the policies package type - we'll need to use the concrete type
// For now, create a simple inline implementation
return &simpleMIMEResolver{
types: map[string]string{
".js": "application/javascript",
".mjs": "application/javascript",
".cjs": "application/javascript",
".css": "text/css",
".html": "text/html",
".htm": "text/html",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".txt": "text/plain",
},
}
}
// simpleMIMEResolver is a simple inline implementation of MIMETypeResolver
type simpleMIMEResolver struct {
types map[string]string
}
func (r *simpleMIMEResolver) GetMIMEType(filePath string) string {
ext := strings.ToLower(path.Ext(filePath))
if mimeType, ok := r.types[ext]; ok {
return mimeType
}
return ""
}
func (r *simpleMIMEResolver) RegisterMIMEType(extension, mimeType string) {
if !strings.HasPrefix(extension, ".") {
extension = "." + extension
}
r.types[strings.ToLower(extension)] = mimeType
}