zlh-agent/internal/handlers/files.go
2026-03-20 23:17:19 +00:00

285 lines
7.8 KiB
Go

package handlers
import (
"errors"
"io"
"log"
"mime"
"net/http"
"os"
"strconv"
"strings"
agentfiles "zlh-agent/internal/files"
"zlh-agent/internal/state"
)
func filesLogf(cfg *state.Config, format string, args ...any) {
log.Printf("[files] vmid=%d type=%s "+format, append([]any{cfg.VMID, cfg.ContainerType}, args...)...)
}
func HandleGameFilesList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
resp, err := agentfiles.List(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesStat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
cfg, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
resp, err := agentfiles.Stat(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesRead(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
resp, err := agentfiles.Read(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
writeJSON(w, http.StatusOK, resp)
}
func HandleGameFilesDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "GET only")
return
}
_, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
file, info, name, err := agentfiles.OpenDownload(serverRoot, r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
defer file.Close()
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
http.ServeContent(w, r, name, info.ModTime(), file)
}
func HandleGameFilesRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodDelete:
handleGameFilesDelete(w, r)
case http.MethodPut:
handleGameFilesWrite(w, r)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "PUT or DELETE only")
}
}
func HandleGameFilesUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "POST only")
return
}
cfg, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || !strings.HasPrefix(mediaType, "multipart/") {
writeJSONError(w, http.StatusBadRequest, "multipart/form-data required")
return
}
normalizedPath, err := agentfiles.NormalizeVisiblePath(r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
overwrite := strings.EqualFold(r.URL.Query().Get("overwrite"), "true")
mr, err := r.MultipartReader()
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid multipart body")
return
}
for {
part, err := mr.NextPart()
if err == io.EOF {
writeJSONError(w, http.StatusBadRequest, "missing file part")
return
}
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid multipart body")
return
}
if part.FormName() != "file" {
part.Close()
continue
}
size, overwritten, err := agentfiles.Upload(cfg.ContainerType, serverRoot, normalizedPath, part, 0, overwrite)
part.Close()
if err != nil {
filesLogf(cfg, "action=upload path=%s overwrite=%t status=failed err=%v", normalizedPath, overwrite, err)
writeFilesError(w, err)
return
}
filesLogf(cfg, "action=upload path=%s size=%d overwritten=%t status=ok", normalizedPath, size, overwritten)
writeJSON(w, http.StatusOK, map[string]any{
"uploaded": true,
"path": normalizedPath,
"size": size,
"overwritten": overwritten,
})
return
}
}
func HandleGameFilesRevert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "POST only")
return
}
cfg, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
revertedPath, err := agentfiles.Revert(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
if err != nil {
filesLogf(cfg, "action=revert path=%s status=failed err=%v", r.URL.Query().Get("path"), err)
writeFilesError(w, err)
return
}
filesLogf(cfg, "action=revert path=%s status=ok", revertedPath)
writeJSON(w, http.StatusOK, map[string]any{
"reverted": true,
"path": revertedPath,
})
}
func handleGameFilesDelete(w http.ResponseWriter, r *http.Request) {
cfg, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
deletedPath, err := agentfiles.Delete(cfg.ContainerType, serverRoot, r.URL.Query().Get("path"))
if err != nil {
filesLogf(cfg, "action=delete path=%s status=failed err=%v", r.URL.Query().Get("path"), err)
writeFilesError(w, err)
return
}
filesLogf(cfg, "action=delete path=%s status=ok", deletedPath)
writeJSON(w, http.StatusOK, map[string]any{
"deleted": true,
"path": deletedPath,
})
}
func handleGameFilesWrite(w http.ResponseWriter, r *http.Request) {
cfg, serverRoot, ok := requireFileContainer(w)
if !ok {
return
}
normalizedPath, err := agentfiles.NormalizeVisiblePath(r.URL.Query().Get("path"))
if err != nil {
writeFilesError(w, err)
return
}
limitedBody := io.LimitReader(r.Body, agentfiles.MaxWriteSize+1)
data, err := io.ReadAll(limitedBody)
if err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body")
return
}
backupCreated, err := agentfiles.Write(cfg.ContainerType, serverRoot, normalizedPath, data)
if err != nil {
filesLogf(cfg, "action=write path=%s size=%d status=failed err=%v", normalizedPath, len(data), err)
writeFilesError(w, err)
return
}
filesLogf(cfg, "action=write path=%s size=%d backup_created=%t status=ok", normalizedPath, len(data), backupCreated)
writeJSON(w, http.StatusOK, map[string]any{
"saved": true,
"path": normalizedPath,
"backupCreated": backupCreated,
})
}
func writeFilesError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, agentfiles.ErrInvalidPath):
writeJSONError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, agentfiles.ErrPathEscape), errors.Is(err, agentfiles.ErrForbiddenPath):
writeJSONError(w, http.StatusForbidden, err.Error())
case errors.Is(err, agentfiles.ErrDeleteDenied), errors.Is(err, agentfiles.ErrWriteDenied), errors.Is(err, agentfiles.ErrUploadDenied):
writeJSONError(w, http.StatusForbidden, err.Error())
case errors.Is(err, agentfiles.ErrAlreadyExists):
writeJSONError(w, http.StatusConflict, err.Error())
case errors.Is(err, agentfiles.ErrNotDir), errors.Is(err, agentfiles.ErrNotFile), errors.Is(err, agentfiles.ErrBinaryFile):
writeJSONError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, agentfiles.ErrTooLarge), errors.Is(err, agentfiles.ErrWriteTooLarge):
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
case errors.Is(err, os.ErrNotExist), errors.Is(err, http.ErrMissingFile):
writeJSONError(w, http.StatusNotFound, "path not found")
default:
writeJSONError(w, http.StatusInternalServerError, err.Error())
}
}
func requireFileContainer(w http.ResponseWriter) (*state.Config, string, bool) {
cfg, err := state.LoadConfig()
if err != nil {
writeJSONError(w, http.StatusBadRequest, "no config loaded")
return nil, "", false
}
switch strings.ToLower(cfg.ContainerType) {
case "game", "dev":
return cfg, agentfiles.RuntimeRoot(cfg), true
default:
writeJSONError(w, http.StatusBadRequest, "unsupported container type")
return nil, "", false
}
}