267 lines
6.2 KiB
Go
267 lines
6.2 KiB
Go
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
|
|
}
|