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.
 
 
 
 
 
 

385 lines
9.7 KiB

package files
import (
"archive/zip"
"encoding/csv"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
type MvdInfo struct {
PlayersA string `json:"players_a"`
PlayersB string `json:"players_b"`
Score string `json:"score"`
Map string `json:"map"`
}
type Entry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
MvdInfo *MvdInfo `json:"mvd_info,omitempty"`
}
type listResponse struct {
Path string `json:"path"`
Entries []Entry `json:"entries"`
}
// mvd report cache — keyed by absolute directory path
type mvdCacheEntry struct {
modTime time.Time
records map[string]MvdInfo // filename → info
}
var (
mvdCacheMu sync.RWMutex
mvdCache = make(map[string]*mvdCacheEntry)
)
const mvdReportName = "mvd_report.csv"
// getMvdReport returns parsed mvd_report.csv for dirPath, using a mod-time-aware cache.
// Returns nil if the file doesn't exist. Safe for concurrent use.
func getMvdReport(dirPath string) map[string]MvdInfo {
csvPath := filepath.Join(dirPath, mvdReportName)
info, err := os.Stat(csvPath)
if err != nil {
return nil
}
modTime := info.ModTime()
mvdCacheMu.RLock()
entry, ok := mvdCache[dirPath]
mvdCacheMu.RUnlock()
if ok && entry.modTime.Equal(modTime) {
return entry.records
}
// Cache miss or stale — parse under write lock
mvdCacheMu.Lock()
defer mvdCacheMu.Unlock()
// Double-check after acquiring write lock
entry, ok = mvdCache[dirPath]
if ok && entry.modTime.Equal(modTime) {
return entry.records
}
records := parseMvdCSV(csvPath)
mvdCache[dirPath] = &mvdCacheEntry{modTime: modTime, records: records}
return records
}
func parseMvdCSV(csvPath string) map[string]MvdInfo {
f, err := os.Open(csvPath)
if err != nil {
return nil
}
defer f.Close()
r := csv.NewReader(f)
r.Comma = ';'
r.TrimLeadingSpace = true
header, err := r.Read()
if err != nil {
return nil
}
// Map header names (lowercase) to column indices
colIdx := make(map[string]int, len(header))
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
col := func(row []string, name string) string {
i, ok := colIdx[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
result := make(map[string]MvdInfo)
for {
row, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
filename := col(row, "file")
if filename == "" {
filename = col(row, "filename")
}
if filename == "" {
continue
}
result[filename] = MvdInfo{
PlayersA: col(row, "players_a"),
PlayersB: col(row, "players_b"),
Score: col(row, "score"),
Map: col(row, "map"),
}
}
return result
}
// 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)
}
}
// PublicZipHandler streams a ZIP archive of selected files from <diskRoot>/public/.
// Expects JSON body: {"dir": "...", "files": [...], "captcha_token": "...", "captcha_answer": "7"}
func PublicZipHandler(diskRoot, jwtSecret string) http.HandlerFunc {
publicRoot, _ := filepath.Abs(filepath.Join(diskRoot, "public"))
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Dir string `json:"dir"`
Files []string `json:"files"`
CaptchaToken string `json:"captcha_token"`
CaptchaAnswer string `json:"captcha_answer"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := verifyCaptcha(body.CaptchaToken, body.CaptchaAnswer, jwtSecret); err != nil {
http.Error(w, "captcha failed: "+err.Error(), http.StatusForbidden)
return
}
if len(body.Files) == 0 {
http.Error(w, "no files specified", http.StatusBadRequest)
return
}
// Resolve and validate directory
dirPath, _ := filepath.Abs(filepath.Join(publicRoot, filepath.Clean("/"+body.Dir)))
if !strings.HasPrefix(dirPath+string(filepath.Separator), publicRoot+string(filepath.Separator)) &&
dirPath != publicRoot {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// Resolve and validate each file before we start writing the response
type resolvedFile struct {
abs string
name string
}
resolved := make([]resolvedFile, 0, len(body.Files))
for _, name := range body.Files {
// Reject any path separators in the filename — files must be in dirPath directly
if strings.ContainsAny(name, "/\\") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
abs := filepath.Join(dirPath, name)
info, err := os.Stat(abs)
if err != nil || info.IsDir() {
http.Error(w, "not found: "+name, http.StatusNotFound)
return
}
resolved = append(resolved, resolvedFile{abs: abs, name: info.Name()})
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`)
zw := zip.NewWriter(w)
defer zw.Close()
for _, rf := range resolved {
fw, err := zw.Create(rf.name)
if err != nil {
return
}
f, err := os.Open(rf.abs)
if err != nil {
return
}
io.Copy(fw, f) //nolint:errcheck
f.Close()
}
}
}
// 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
}
mvd := getMvdReport(targetDir)
entries, err := os.ReadDir(targetDir)
result := make([]Entry, 0)
if err == nil {
for _, e := range entries {
// Hide mvd_report.csv from the listing
if !e.IsDir() && e.Name() == mvdReportName {
continue
}
info, err := e.Info()
if err != nil {
continue
}
size := int64(0)
if !e.IsDir() {
size = info.Size()
}
entry := Entry{
Name: e.Name(),
IsDir: e.IsDir(),
Size: size,
Modified: info.ModTime(),
}
if !e.IsDir() && mvd != nil {
mi, ok := mvd[e.Name()]
if !ok {
// CSV exists but this file is not listed in it — hide it
continue
}
entry.MvdInfo = &mi
}
result = append(result, entry)
}
}
// 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,
})
}
}