package handlers import ( "encoding/json" "errors" "log" "net/http" "net/url" "os" "path/filepath" "strings" "zlh-agent/internal/mods" "zlh-agent/internal/state" ) func modsLogf(cfg *state.Config, format string, args ...any) { log.Printf("[mods] vmid=%d type=%s game=%s variant=%s "+format, append([]any{cfg.VMID, cfg.ContainerType, cfg.Game, cfg.Variant}, args...)...) } type jsonError struct { Error string `json:"error"` } func HandleGameMods(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSONError(w, http.StatusMethodNotAllowed, "GET only") return } _, serverRoot, ok := requireMinecraftGame(w) if !ok { return } resp, err := mods.ScanMods(serverRoot) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } func HandleGameModsInstall(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSONError(w, http.StatusMethodNotAllowed, "POST only") return } cfg, serverRoot, ok := requireMinecraftGame(w) if !ok { return } var req mods.InstallRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") return } filename := strings.TrimSpace(req.Filename) if strings.TrimSpace(strings.ToLower(req.Source)) != "modrinth" { filename = "" if u, err := url.Parse(req.ArtifactURL); err == nil { base := filepath.Base(u.Path) safeBase := base != "." && base != "/" && !strings.Contains(base, "..") && !strings.Contains(base, "/") && !strings.Contains(base, "\\") && !strings.Contains(base, "~") && !strings.ContainsRune(base, 0) if safeBase { valid := true for _, r := range base { if r <= 31 || r == 127 || r == ' ' || r == '\t' || r == '\n' || r == '\r' { valid = false break } if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' || r == '+') { valid = false break } } if valid { filename = base } } } if filename == "" { name := strings.TrimSpace(req.ModID) if strings.TrimSpace(req.Version) != "" { name = name + "-" + strings.TrimSpace(req.Version) } name = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { return r } return '_' }, name) if len(name) > 64 { name = name[:64] } if name == "" { name = "unknown" } filename = name + ".jar" } } modsDir := filepath.Join(serverRoot, "mods") enabledPath := filepath.Join(modsDir, filename) disabledPath := enabledPath + ".disabled" if _, err := os.Stat(enabledPath); err == nil { modsLogf(cfg, "action=install mod_id=%s source=%s filename=%s status=already_installed enabled=true", req.ModID, req.Source, filename) writeJSON(w, http.StatusOK, map[string]any{ "status": "already-installed", "fileName": filename, "enabled": true, }) return } if _, err := os.Stat(disabledPath); err == nil { modsLogf(cfg, "action=install mod_id=%s source=%s filename=%s status=already_installed enabled=false", req.ModID, req.Source, filename) writeJSON(w, http.StatusOK, map[string]any{ "status": "already-installed", "fileName": filename, "enabled": false, }) return } resp, err := mods.InstallCurated(serverRoot, req) if err != nil { modsLogf(cfg, "action=install mod_id=%s source=%s status=failed err=%v", req.ModID, req.Source, err) writeJSONError(w, http.StatusBadRequest, err.Error()) return } _ = resp modsLogf(cfg, "action=install mod_id=%s source=%s filename=%s status=ok", req.ModID, req.Source, filename) writeJSON(w, http.StatusOK, map[string]any{ "status": "installed", "fileName": filename, }) } func HandleGameModByID(w http.ResponseWriter, r *http.Request) { cfg, serverRoot, ok := requireMinecraftGame(w) if !ok { return } modID := strings.TrimPrefix(r.URL.Path, "/game/mods/") if modID == "" || strings.Contains(modID, "/") || !mods.IsValidModID(modID) { writeJSONError(w, http.StatusBadRequest, "invalid mod_id") return } switch r.Method { case http.MethodPatch: var req mods.PatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") return } resp, err := mods.SetEnabled(serverRoot, modID, req.Enabled) if err != nil { modsLogf(cfg, "action=set_enabled mod_id=%s enabled=%t status=failed err=%v", modID, req.Enabled, err) if errors.Is(err, os.ErrNotExist) { writeJSONError(w, http.StatusNotFound, "mod not found") return } writeJSONError(w, http.StatusBadRequest, err.Error()) return } modsLogf(cfg, "action=set_enabled mod_id=%s enabled=%t status=ok", modID, req.Enabled) writeJSON(w, http.StatusOK, resp) case http.MethodDelete: resp, err := mods.DeleteMod(serverRoot, modID) if err != nil { modsLogf(cfg, "action=delete mod_id=%s status=failed err=%v", modID, err) if errors.Is(err, os.ErrNotExist) { writeJSONError(w, http.StatusNotFound, "mod not found") return } writeJSONError(w, http.StatusBadRequest, err.Error()) return } modsLogf(cfg, "action=delete mod_id=%s status=ok", modID) writeJSON(w, http.StatusOK, resp) default: writeJSONError(w, http.StatusMethodNotAllowed, "PATCH or DELETE only") } } func requireMinecraftGame(w http.ResponseWriter) (*state.Config, string, bool) { cfg, err := state.LoadConfig() if err != nil { writeJSONError(w, http.StatusBadRequest, "no config loaded") return nil, "", false } if strings.ToLower(cfg.ContainerType) != "game" { writeJSONError(w, http.StatusBadRequest, "not a game container") return nil, "", false } if strings.ToLower(cfg.Game) != "minecraft" { writeJSONError(w, http.StatusNotImplemented, "unsupported game") return nil, "", false } return cfg, mods.ResolveServerRoot(cfg), true } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeJSONError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, jsonError{Error: msg}) }