// Package staticfiles serves files from the configured local upload store. // It owns the Gin/storage adapter so the server root only has to compose it. package staticfiles import ( "mime" "net/http" "os" "path" "strings" "kra/internal/conf" "github.com/gin-gonic/gin" ) // Register adds local upload routes when local storage is enabled. A route is // skipped when its root conflicts with an existing GET/HEAD route. func Register(engine *gin.Engine, runtime *conf.Runtime) { config := localConfig(runtime) if config == nil || strings.Trim(config.PathPrefix, "/") == "" { return } prefix := "/" + strings.Trim(config.PathPrefix, "/") if localStorageRouteConflicts(engine.Routes(), prefix) { return } handler := func(c *gin.Context) { if !serveAt(c, runtime, prefix) { c.Status(http.StatusNotFound) } } engine.GET(prefix+"/*filepath", handler) engine.HEAD(prefix+"/*filepath", handler) } // Serve resolves the current runtime configuration on every request so a // configuration reload takes effect without rebuilding the Gin engine. func Serve(c *gin.Context, runtime *conf.Runtime) bool { config := localConfig(runtime) if config == nil { return false } prefix := "/" + strings.Trim(config.PathPrefix, "/") return serveAt(c, runtime, prefix) } func localConfig(runtime *conf.Runtime) *conf.AdminBackend_Local { if runtime == nil { return nil } config := runtime.Admin() if config == nil || config.Local == nil || config.Local.StorePath == "" { return nil } if config.Storage != nil && config.Storage.Type != "" && config.Storage.Type != "local" { return nil } return config.Local } func localStorageRouteConflicts(routes []gin.RouteInfo, prefix string) bool { staticRoot := strings.Split(strings.TrimPrefix(prefix, "/"), "/")[0] for _, route := range routes { if route.Method != http.MethodGet && route.Method != http.MethodHead { continue } routeRoot := strings.Split(strings.TrimPrefix(route.Path, "/"), "/")[0] if routeRoot == staticRoot { return true } } return false } func serveAt(c *gin.Context, runtime *conf.Runtime, prefix string) bool { config := localConfig(runtime) if config == nil || "/"+strings.Trim(config.PathPrefix, "/") != prefix { return false } if prefix == "/" || (c.Request.URL.Path != prefix && !strings.HasPrefix(c.Request.URL.Path, prefix+"/")) { return false } c.Header("X-Content-Type-Options", "nosniff") filename := path.Base(c.Request.URL.Path) if !canServeInline(filename) { c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename})) } http.StripPrefix(prefix, http.FileServer(filesOnly{FileSystem: http.Dir(config.StorePath)})).ServeHTTP(c.Writer, c.Request) return true } func canServeInline(filename string) bool { switch strings.ToLower(path.Ext(filename)) { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".avif", ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".mp4", ".webm", ".mov", ".avi", ".mkv": return true default: return false } } type filesOnly struct{ http.FileSystem } func (f filesOnly) Open(name string) (http.File, error) { file, err := f.FileSystem.Open(name) if err != nil { return nil, err } info, statErr := file.Stat() if statErr != nil { _ = file.Close() return nil, statErr } if info.IsDir() { _ = file.Close() return nil, os.ErrPermission } return file, nil }