package mods import ( "errors" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "zlh-agent/internal/provision" "zlh-agent/internal/state" ) var ( modIDPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) filenamePattern = regexp.MustCompile(`^[a-zA-Z0-9._+-]{1,128}$`) cacheMu sync.Mutex scanCache = map[string]cacheEntry{} ) const ( cacheTTL = 5 * time.Minute defaultServerRoot = "/opt/zlh/minecraft/vanilla/world" ) func ResolveServerRoot(cfg *state.Config) string { if v := strings.TrimSpace(os.Getenv("ZLH_SERVER_ROOT")); v != "" { return filepath.Clean(v) } if cfg != nil { return filepath.Clean(provision.ServerDir(*cfg)) } return defaultServerRoot } func ScanMods(serverRoot string) (ScanResponse, error) { serverRoot = filepath.Clean(serverRoot) cacheMu.Lock() if c, ok := scanCache[serverRoot]; ok && time.Now().Before(c.ExpiresAt) { resp := c.Resp cacheMu.Unlock() return resp, nil } cacheMu.Unlock() resp, err := scanModsUncached(serverRoot) if err != nil { return ScanResponse{}, err } cacheMu.Lock() scanCache[serverRoot] = cacheEntry{ExpiresAt: time.Now().Add(cacheTTL), Resp: resp} cacheMu.Unlock() return resp, nil } func InvalidateCache(serverRoot string) { cacheMu.Lock() defer cacheMu.Unlock() if serverRoot == "" { scanCache = map[string]cacheEntry{} return } delete(scanCache, filepath.Clean(serverRoot)) } func scanModsUncached(serverRoot string) (ScanResponse, error) { modsDir := filepath.Join(serverRoot, "mods") entries, err := os.ReadDir(modsDir) if err != nil { if errors.Is(err, os.ErrNotExist) { return ScanResponse{ Variant: detectVariant(serverRoot, nil), Mods: []ModInfo{}, TotalCount: 0, ScanTimestamp: time.Now().UTC().Format(time.RFC3339), }, nil } return ScanResponse{}, err } mods := make([]ModInfo, 0, len(entries)) var evidence []jarMetadata for _, entry := range entries { if entry.IsDir() { continue } filename := entry.Name() enabled, ok := modEnabledState(filename) if !ok || !isSafeFilename(filename) { continue } fullPath := filepath.Join(modsDir, filename) meta, err := parseJarMetadata(fullPath) if err == nil { evidence = append(evidence, meta) } id, name := fallbackNameFromFilename(filename) version := "unknown" if err == nil { if v := strings.TrimSpace(meta.ID); v != "" { id = sanitizeID(v) } if v := strings.TrimSpace(meta.Name); v != "" { name = v } if v := strings.TrimSpace(meta.Version); v != "" { version = v } } if !isValidModID(id) { id = sanitizeID(strings.TrimSuffix(strings.TrimSuffix(filename, ".jar"), ".disabled")) } mods = append(mods, ModInfo{ ID: id, Name: name, Version: version, Filename: filename, Enabled: enabled, Source: "manual", }) } sort.Slice(mods, func(i, j int) bool { return mods[i].Filename < mods[j].Filename }) variant, mcVersion, variantVersion := detectVariantWithVersion(serverRoot, evidence) return ScanResponse{ Variant: variant, MinecraftVersion: mcVersion, VariantVersion: variantVersion, Mods: mods, TotalCount: len(mods), ScanTimestamp: time.Now().UTC().Format(time.RFC3339), }, nil } func detectVariant(serverRoot string, evidence []jarMetadata) string { v, _, _ := detectVariantWithVersion(serverRoot, evidence) return v } func detectVariantWithVersion(serverRoot string, evidence []jarMetadata) (variant string, minecraftVersion string, variantVersion string) { for _, m := range evidence { if m.HasFabricMeta { return "fabric", m.MinecraftVersion, m.Version } } for _, m := range evidence { if m.HasForgeMeta { return "forge", m.MinecraftVersion, m.Version } } for _, m := range evidence { if m.HasPluginMeta { return "paper", m.MinecraftVersion, m.Version } } pluginsDir := filepath.Join(serverRoot, "plugins") if entries, err := os.ReadDir(pluginsDir); err == nil { for _, e := range entries { if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".jar") { return "paper", "", "" } } } forgeChecks := []string{ filepath.Join(serverRoot, "forge-server.toml"), filepath.Join(serverRoot, "forge-client.toml"), filepath.Join(serverRoot, "config", "forge-common.toml"), } for _, p := range forgeChecks { if _, err := os.Stat(p); err == nil { return "forge", "", "" } } return "unknown", "", "" } func modEnabledState(filename string) (enabled bool, ok bool) { lower := strings.ToLower(filename) if strings.HasSuffix(lower, ".jar") { return true, true } if strings.HasSuffix(lower, ".jar.disabled") { return false, true } return false, false } func isSafeFilename(name string) bool { if strings.Contains(name, "..") || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.ContainsRune(name, 0) || strings.Contains(name, "~") { return false } for _, r := range name { if r <= 31 || r == 127 || r == ' ' || r == '\t' || r == '\n' || r == '\r' { return false } } return filenamePattern.MatchString(name) } func sanitizeID(id string) string { id = strings.TrimSpace(id) id = strings.ReplaceAll(id, " ", "_") id = 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 '_' }, id) if len(id) > 64 { id = id[:64] } if id == "" { id = "unknown" } return id } func isValidModID(id string) bool { return modIDPattern.MatchString(id) } func IsValidModID(id string) bool { return isValidModID(id) } func ResolveByModID(serverRoot, modID string) (enabledName string, disabledName string, err error) { if !isValidModID(modID) { return "", "", errors.New("invalid mod_id") } resp, err := ScanMods(serverRoot) if err != nil { return "", "", err } for _, mod := range resp.Mods { if mod.ID != modID { continue } if mod.Enabled { enabledName = mod.Filename } else { disabledName = mod.Filename } } if enabledName != "" && disabledName != "" { return "", "", errors.New("mod has both enabled and disabled files") } if enabledName == "" && disabledName == "" { return "", "", os.ErrNotExist } return enabledName, disabledName, nil }