package files import ( "encoding/json" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) type Entry struct { Name string `json:"name"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` Modified time.Time `json:"modified"` } type listResponse struct { Path string `json:"path"` Entries []Entry `json:"entries"` } // PublicDownloadHandler returns a handler for downloading files from /public/. // // useXAccel=true (production behind nginx): the backend only validates the path and // returns the X-Accel-Redirect header — nginx serves the file directly from disk // (sendfile), without streaming data through Go. // // useXAccel=false (dev with Vite proxy): data is streamed via http.ServeContent // with Range (HTTP 206), ETag and Last-Modified support. func PublicDownloadHandler(diskRoot string, useXAccel bool) http.HandlerFunc { publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) return func(w http.ResponseWriter, r *http.Request) { subPath := strings.TrimPrefix(filepath.Clean("/"+chi.URLParam(r, "*")), "/") if subPath == "" || subPath == "." { http.NotFound(w, r) return } targetPath, _ := filepath.Abs(filepath.Join(publicRoot, subPath)) // Path traversal protection if !strings.HasPrefix(targetPath+string(filepath.Separator), publicRoot+string(filepath.Separator)) { http.Error(w, "forbidden", http.StatusForbidden) return } info, err := os.Stat(targetPath) if err != nil || info.IsDir() { http.NotFound(w, r) return } w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(info.Name())) if useXAccel { // Nginx handles the file transfer — disk → nginx → client (sendfile) accelURI := (&url.URL{Path: "/internal/public/" + filepath.ToSlash(subPath)}).EscapedPath() w.Header().Set("X-Accel-Redirect", accelURI) w.WriteHeader(http.StatusOK) return } // Fallback: stream through Go (dev) f, err := os.Open(targetPath) if err != nil { http.NotFound(w, r) return } defer f.Close() http.ServeContent(w, r, info.Name(), info.ModTime(), f) } } // PublicRawHandler serves raw file content for in-browser rendering (e.g. README.md). // Limited to 1 MB. No Content-Disposition header — intended for programmatic fetch. func PublicRawHandler(diskRoot string) http.HandlerFunc { publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) const maxBytes = 1 << 20 // 1 MB return func(w http.ResponseWriter, r *http.Request) { subPath := strings.TrimPrefix(filepath.Clean("/"+chi.URLParam(r, "*")), "/") if subPath == "" || subPath == "." { http.NotFound(w, r) return } targetPath, _ := filepath.Abs(filepath.Join(publicRoot, subPath)) // Path traversal protection if !strings.HasPrefix(targetPath+string(filepath.Separator), publicRoot+string(filepath.Separator)) { http.Error(w, "forbidden", http.StatusForbidden) return } info, err := os.Stat(targetPath) if err != nil || info.IsDir() { http.NotFound(w, r) return } if info.Size() > maxBytes { http.Error(w, "file too large", http.StatusRequestEntityTooLarge) return } data, err := os.ReadFile(targetPath) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(data) } } // PublicListHandler returns a handler that lists /public/ or a subdirectory. func PublicListHandler(diskRoot string) http.HandlerFunc { publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public")) return func(w http.ResponseWriter, r *http.Request) { subPath := chi.URLParam(r, "*") subPath = filepath.Clean("/" + subPath) subPath = strings.TrimPrefix(subPath, "/") targetDir := filepath.Join(publicRoot, subPath) targetDir, _ = filepath.Abs(targetDir) // Path traversal protection if !strings.HasPrefix(targetDir+string(filepath.Separator), publicRoot+string(filepath.Separator)) && targetDir != publicRoot { http.Error(w, "forbidden", http.StatusForbidden) return } entries, err := os.ReadDir(targetDir) result := make([]Entry, 0) if err == nil { for _, e := range entries { info, err := e.Info() if err != nil { continue } size := int64(0) if !e.IsDir() { size = info.Size() } result = append(result, Entry{ Name: e.Name(), IsDir: e.IsDir(), Size: size, Modified: info.ModTime(), }) } } // If the directory doesn't exist — return an empty list without an error w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(listResponse{ Path: subPath, Entries: result, }) } }