Skins & capes:
- Fix uploadSkin auth: Bearer token instead of user_id form hack
- Add POST /api/web/profile/cape (upload cape)
- Add DELETE /api/web/profile/skin and DELETE /api/web/profile/cape
- Validate skin PNG dimensions (64x32, 64x64, 128x128, 128x64)
- Add size limits: 1 MB for skins, 2 MB for capes
- Add basic email validation on register
Profile & session server:
- Add GET /api/web/profile/{uuid} — public profile with skin/cape hashes
- Add GET /sessionserver/session/minecraft/profile/{uuid} — Mojang-compatible
texture response for game client
- Add POST /authserver/invalidate and POST /authserver/signout
- Export VerifyPassword and ExtractBearer from auth package
- Remove duplicate verifyPassword from api.go
- Add PlayerTextures model to database.go
539 lines
15 KiB
Go
539 lines
15 KiB
Go
// 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/auth"
|
|
"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 := auth.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)
|
|
}
|
|
}
|
|
|
|
// ── 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})
|
|
}
|