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