// package admin implements admin panel endpoints (modpack management, CI/CD release). package admin import ( "archive/zip" "bytes" "context" "crypto/sha1" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" ) // Handler serves admin endpoints. type Handler struct { db *database.DB cfg *config.Config } // NewHandler creates a new admin handler. func NewHandler(db *database.DB, cfg *config.Config) *Handler { return &Handler{db: db, cfg: cfg} } // RegisterRoutes mounts the admin endpoints. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/admin/modpacks", h.auth(h.listModpacks)) mux.HandleFunc("POST /api/admin/modpacks", h.auth(h.createModpack)) mux.HandleFunc("PUT /api/admin/modpacks/{id}", h.auth(h.updateModpack)) mux.HandleFunc("DELETE /api/admin/modpacks/{id}", h.auth(h.deleteModpack)) mux.HandleFunc("POST /api/admin/modpacks/{slug}/upload", h.auth(h.uploadFiles)) mux.HandleFunc("POST /api/admin/modpacks/{slug}/manifest", h.auth(h.generateManifest)) mux.HandleFunc("POST /api/admin/launcher/release", h.ciToken(h.launcherRelease)) } // ── Middleware ────────────────────────────────────────────────── type ctxKey int const ctxKeyUserID ctxKey = 0 func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := extractBearer(r.Header.Get("Authorization")) if token == "" { writeError(w, http.StatusUnauthorized, "Missing authorization token") return } var userID int var role string err := h.db.Pool().QueryRow(r.Context(), `SELECT u.id, u.role FROM yggdrasil_sessions s JOIN users u ON u.id = s.user_id WHERE s.access_token = $1 AND s.expires_at > NOW()`, token, ).Scan(&userID, &role) if err != nil { writeError(w, http.StatusUnauthorized, "Invalid token") return } if role != "admin" { writeError(w, http.StatusForbidden, "Admin access required") return } ctx := context.WithValue(r.Context(), ctxKeyUserID, userID) next(w, r.WithContext(ctx)) } } func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-CI-Token") if token == "" || token != h.cfg.CIsecret { writeError(w, http.StatusForbidden, "Invalid CI token") return } next(w, r) } } func extractBearer(h string) string { if strings.HasPrefix(h, "Bearer ") { return h[7:] } return "" } // ── Modpack CRUD ────────────────────────────────────────────── type modpackRequest struct { Name string `json:"name"` Slug string `json:"slug"` MinecraftVersion string `json:"minecraft_version"` JavaVersion int `json:"java_version"` ServerIP string `json:"server_ip"` } type modpackResponse struct { ID int `json:"id"` Slug string `json:"slug"` Name string `json:"name"` MinecraftVersion string `json:"minecraft_version"` JavaVersion int `json:"java_version"` ServerIP string `json:"server_ip"` IsActive bool `json:"is_active"` } func (h *Handler) listModpacks(w http.ResponseWriter, r *http.Request) { rows, err := h.db.Pool().Query(r.Context(), `SELECT id, slug, name, minecraft_version, java_version, server_ip, is_active FROM modpacks ORDER BY created_at DESC`) if err != nil { writeError(w, http.StatusInternalServerError, "Database error") return } defer rows.Close() var modpacks []modpackResponse for rows.Next() { var m modpackResponse if err := rows.Scan(&m.ID, &m.Slug, &m.Name, &m.MinecraftVersion, &m.JavaVersion, &m.ServerIP, &m.IsActive); err != nil { continue } modpacks = append(modpacks, m) } writeJSON(w, http.StatusOK, modpacks) } func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "Cannot read body") return } var req modpackRequest if err := json.Unmarshal(body, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if req.Slug == "" || req.Name == "" || req.MinecraftVersion == "" { writeError(w, http.StatusBadRequest, "slug, name, and minecraft_version are required") return } _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO modpacks (slug, name, minecraft_version, java_version, server_ip, is_active) VALUES ($1, $2, $3, $4, $5, true)`, req.Slug, req.Name, req.MinecraftVersion, req.JavaVersion, req.ServerIP, ) if err != nil { writeError(w, http.StatusConflict, "Modpack with this slug already exists") return } writeJSON(w, http.StatusCreated, map[string]string{"status": "created"}) } func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "Invalid modpack ID") return } body, err := io.ReadAll(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "Cannot read body") return } var req modpackRequest if err := json.Unmarshal(body, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } _, err = h.db.Pool().Exec(r.Context(), `UPDATE modpacks SET name=$1, slug=$2, minecraft_version=$3, java_version=$4, server_ip=$5 WHERE id=$6`, req.Name, req.Slug, req.MinecraftVersion, req.JavaVersion, req.ServerIP, id, ) if err != nil { writeError(w, http.StatusInternalServerError, "Update failed") return } writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } func (h *Handler) deleteModpack(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "Invalid modpack ID") return } _, err = h.db.Pool().Exec(r.Context(), `UPDATE modpacks SET is_active = false WHERE id = $1`, id) if err != nil { writeError(w, http.StatusInternalServerError, "Delete failed") return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ── File Upload ─────────────────────────────────────────────── func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { writeError(w, http.StatusBadRequest, "Modpack slug is required") return } var exists bool err := h.db.Pool().QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM modpacks WHERE slug = $1 AND is_active = true)`, slug, ).Scan(&exists) if err != nil || !exists { writeError(w, http.StatusNotFound, "Modpack not found") return } if err := r.ParseMultipartForm(500 << 20); err != nil { writeError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)") return } files := r.MultipartForm.File["files"] if len(files) == 0 { writeError(w, http.StatusBadRequest, "No files uploaded (field name: 'files')") return } uploadDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug) var uploaded []string for _, fh := range files { src, err := fh.Open() if err != nil { continue } data, err := io.ReadAll(src) src.Close() if err != nil { continue } if strings.HasSuffix(strings.ToLower(fh.Filename), ".zip") { extracted, err := h.extractZip(data, uploadDir) if err == nil { uploaded = append(uploaded, extracted...) continue } } hash := sha1Hex(data) destDir := filepath.Join(h.cfg.CASDir, hash[:2]) os.MkdirAll(destDir, 0o755) os.WriteFile(filepath.Join(destDir, hash), data, 0o644) h.db.Pool().Exec(r.Context(), `INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3) ON CONFLICT (sha1) DO NOTHING`, hash, int64(len(data)), fh.Filename, ) instPath := filepath.Join(uploadDir, "mods", fh.Filename) os.MkdirAll(filepath.Dir(instPath), 0o755) os.WriteFile(instPath, data, 0o644) uploaded = append(uploaded, fh.Filename) } writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "uploaded", "files": uploaded, "count": len(uploaded), }) } func (h *Handler) extractZip(data []byte, dest string) ([]string, error) { reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { return nil, err } var extracted []string for _, f := range reader.File { if f.FileInfo().IsDir() || strings.Contains(f.Name, "..") { continue } target := filepath.Join(dest, f.Name) if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { continue } os.MkdirAll(filepath.Dir(target), 0o755) rc, err := f.Open() if err != nil { continue } out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { rc.Close() continue } io.Copy(out, rc) out.Close() rc.Close() extracted = append(extracted, f.Name) } return extracted, nil } // ── Manifest Generation ─────────────────────────────────────── type manifestRequest struct { MainClass string `json:"mainClass"` JVMArgs []string `json:"jvmArgs"` GameArgs []string `json:"gameArgs"` AuthLibInjector string `json:"authLibInjector"` } type manifestFile struct { Path string `json:"path"` Hash string `json:"hash"` Size int64 `json:"size"` URL string `json:"url"` } type manifestResponse struct { MinecraftVersion string `json:"minecraft_version"` JavaVersion int `json:"java_version"` ServerInfo manifestServer `json:"server_info"` Files []manifestFile `json:"files"` Launch manifestLaunch `json:"launch"` } type manifestServer struct { Name string `json:"name"` IP string `json:"ip"` } type manifestLaunch struct { MainClass string `json:"mainClass"` JVMArgs []string `json:"jvmArgs"` GameArgs []string `json:"gameArgs"` AuthLibInjector string `json:"authLibInjector"` } func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { writeError(w, http.StatusBadRequest, "Modpack slug is required") return } body, err := io.ReadAll(r.Body) if err != nil { writeError(w, http.StatusBadRequest, "Cannot read body") return } var req manifestRequest if err := json.Unmarshal(body, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } var mp database.Modpack err = h.db.Pool().QueryRow(r.Context(), `SELECT id, name, minecraft_version, java_version, server_ip FROM modpacks WHERE slug = $1 AND is_active = true`, slug, ).Scan(&mp.ID, &mp.Name, &mp.MinecraftVersion, &mp.JavaVersion, &mp.ServerIP) if err != nil { writeError(w, http.StatusNotFound, "Modpack not found") return } instanceDir := filepath.Join(h.cfg.CASDir, "..", "instances", slug) var files []manifestFile filepath.Walk(instanceDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } rel, _ := filepath.Rel(instanceDir, path) rel = filepath.ToSlash(rel) data, err := os.ReadFile(path) if err != nil { return nil } hash := sha1Hex(data) casDir := filepath.Join(h.cfg.CASDir, hash[:2]) os.MkdirAll(casDir, 0o755) casPath := filepath.Join(casDir, hash) if _, err := os.Stat(casPath); os.IsNotExist(err) { os.WriteFile(casPath, data, 0o644) } h.db.Pool().Exec(r.Context(), `INSERT INTO global_files (sha1, size_bytes, file_name) VALUES ($1, $2, $3) ON CONFLICT (sha1) DO NOTHING`, hash, info.Size(), filepath.Base(rel), ) files = append(files, manifestFile{ Path: rel, Hash: hash, Size: info.Size(), URL: fmt.Sprintf("%s/files/%s", h.cfg.BaseURL, hash), }) return nil }) manifest := manifestResponse{ MinecraftVersion: mp.MinecraftVersion, JavaVersion: mp.JavaVersion, ServerInfo: manifestServer{Name: mp.Name, IP: mp.ServerIP}, Files: files, Launch: manifestLaunch{ MainClass: req.MainClass, JVMArgs: req.JVMArgs, GameArgs: req.GameArgs, AuthLibInjector: req.AuthLibInjector, }, } manifestDir := filepath.Join(h.cfg.CASDir, "..", "manifests", slug) os.MkdirAll(manifestDir, 0o755) data, _ := json.MarshalIndent(manifest, "", " ") os.WriteFile(filepath.Join(manifestDir, "manifest.json"), data, 0o644) writeJSON(w, http.StatusOK, manifest) } // ── Launcher Release (CI/CD) ────────────────────────────────── func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(100 << 20); err != nil { writeError(w, http.StatusBadRequest, "Cannot parse form (max 100 MB)") return } version := r.FormValue("version") osParam := r.FormValue("os") arch := r.FormValue("arch") sha256 := r.FormValue("sha256") if version == "" || osParam == "" || arch == "" || sha256 == "" { writeError(w, http.StatusBadRequest, "version, os, arch, and sha256 are required") return } file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "No file uploaded (field name: 'file')") return } defer file.Close() destDir := filepath.Join(h.cfg.CASDir, "..", "launcher", version, osParam, arch) os.MkdirAll(destDir, 0o755) data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusInternalServerError, "Cannot read uploaded file") return } if got := sha256Hex(data); got != sha256 { writeError(w, http.StatusBadRequest, fmt.Sprintf("SHA-256 mismatch: expected %s, got %s", sha256, got)) return } dest := filepath.Join(destDir, header.Filename) if err := os.WriteFile(dest, data, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "Failed to store binary") return } _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO launcher_releases (version, os, arch, sha256, file_path, is_active, is_mandatory) VALUES ($1, $2, $3, $4, $5, true, true)`, version, osParam, arch, sha256, dest, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to register release") return } writeJSON(w, http.StatusCreated, map[string]string{ "status": "released", "version": version, }) } // ── Helpers ─────────────────────────────────────────────────── func sha1Hex(data []byte) string { h := sha1.Sum(data) return hex.EncodeToString(h[:]) } func sha256Hex(data []byte) string { h := sha256.Sum256(data) return hex.EncodeToString(h[:]) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }