package handlers import ( "errors" "io" "mime" "net/http" "os" "strconv" "strings" agentfiles "zlh-agent/internal/files" "zlh-agent/internal/state" ) 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 { writeFilesError(w, err) return } 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 { writeFilesError(w, err) return } 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 { writeFilesError(w, err) return } 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 { writeFilesError(w, err) return } 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 } }