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.
307 lines
7.4 KiB
307 lines
7.4 KiB
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 <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
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
|