package files import ( "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 /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 } 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, }) } }