zlh-agent/internal/handlers/mods.go
2026-03-07 20:59:27 +00:00

208 lines
5.2 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"zlh-agent/internal/mods"
"zlh-agent/internal/state"
)
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
}
_, 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 {
writeJSON(w, http.StatusOK, map[string]any{
"status": "already-installed",
"fileName": filename,
"enabled": true,
})
return
}
if _, err := os.Stat(disabledPath); err == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "already-installed",
"fileName": filename,
"enabled": false,
})
return
}
resp, err := mods.InstallCurated(serverRoot, req)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
_ = resp
writeJSON(w, http.StatusOK, map[string]any{
"status": "installed",
"fileName": filename,
})
}
func HandleGameModByID(w http.ResponseWriter, r *http.Request) {
_, 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 {
if errors.Is(err, os.ErrNotExist) {
writeJSONError(w, http.StatusNotFound, "mod not found")
return
}
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
case http.MethodDelete:
resp, err := mods.DeleteMod(serverRoot, modID)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSONError(w, http.StatusNotFound, "mod not found")
return
}
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
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})
}