From 2f07fbf379c1755d00de375a4365f2b4382e220d Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Tue, 26 May 2026 14:03:17 +0300 Subject: [PATCH] feat: add admin handler (modpack CRUD, file upload, manifest, launcher release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modpack CRUD: GET/POST/PUT/DELETE /api/admin/modpacks - file upload: POST /api/admin/modpacks/{slug}/upload — multipart, ZIP extraction, CAS storage - manifest: POST /api/admin/modpacks/{slug}/manifest — scan instance dir, generate manifest.json - launcher release: POST /api/admin/launcher/release — CI/CD endpoint, SHA-256 verify, DB registration - auth middleware: Bearer token + admin role check + X-CI-Token for CI/CD - zip-slip protection in file extraction Co-Authored-By: OWL --- cmd/server/main.go | 7 +- internal/admin/admin.go | 542 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 4a858cb..51bf614 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/admin" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" @@ -46,7 +47,11 @@ func main() { apiHandler := api.NewHandler(db, cfg) apiHandler.RegisterRoutes(mux) - // TODO: register Admin, CAS routes. + // Admin panel. + adminHandler := admin.NewHandler(db, cfg) + adminHandler.RegisterRoutes(mux) + + // TODO: register CAS routes. addr := ":" + itoa(cfg.Port) srv := &http.Server{ diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 810e714..b8a19fb 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -1,2 +1,544 @@ // 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}) +}