You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

175 lines
4.6 KiB

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 <diskRoot>/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 <diskRoot>/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,
})
}
}