// package api implements public HTTP endpoints for the launcher and website. package api import ( "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "image/png" "io" "net/http" "os" "path/filepath" "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" ) // Max skin file size: 1 MB (Minecraft skins are ~2-10 KB). const maxSkinSize = 1 << 20 // Max cape file size: 2 MB. const maxCapeSize = 2 << 20 // Valid Minecraft skin dimensions. var skinDims = []struct { W, H int }{ {64, 32}, // legacy (pre-1.8) {64, 64}, // modern {128, 128}, {128, 64}, } // Handler serves public API endpoints. type Handler struct { db *database.DB cfg *config.Config } // NewHandler creates a new API handler. func NewHandler(db *database.DB, cfg *config.Config) *Handler { return &Handler{db: db, cfg: cfg} } // RegisterRoutes mounts the API endpoints. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Website endpoints. mux.HandleFunc("POST /api/web/register", h.register) mux.HandleFunc("POST /api/web/login", h.webLogin) mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin) mux.HandleFunc("POST /api/web/profile/cape", h.uploadCape) mux.HandleFunc("DELETE /api/web/profile/skin", h.deleteSkin) mux.HandleFunc("DELETE /api/web/profile/cape", h.deleteCape) // Launcher endpoints. mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest) mux.HandleFunc("GET /api/servers.json", h.serversList) mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest) // Profile — public read. mux.HandleFunc("GET /api/web/profile/{uuid}", h.getProfile) // Skin serving. mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin) } // ── Request / Response types ────────────────────────────────── type registerRequest struct { Username string `json:"username"` Email string `json:"email"` Password string `json:"password"` } type registerResponse struct { UUID string `json:"uuid"` } type webLoginRequest struct { Username string `json:"username"` Password string `json:"password"` } type webLoginResponse struct { Token string `json:"token"` UUID string `json:"uuid"` Username string `json:"username"` } type serversResponse struct { Servers []serverInfo `json:"servers"` } type serverInfo struct { Slug string `json:"slug"` Name string `json:"name"` Version string `json:"version"` IP string `json:"ip"` } type launcherLatestResponse struct { Version string `json:"version"` Downloads map[string]string `json:"downloads"` // os+arch -> url SHA256 string `json:"sha256"` IsMandatory bool `json:"is_mandatory"` } type profileResponse struct { UUID string `json:"uuid"` Username string `json:"username"` Textures *textureInfo `json:"textures,omitempty"` } type textureInfo struct { SkinHash string `json:"skin_hash,omitempty"` CapeHash string `json:"cape_hash,omitempty"` IsSlim bool `json:"is_slim"` } // Mojang session server profile response (for game client). type sessionProfileResponse struct { ID string `json:"id"` Name string `json:"name"` Props []sessionProfileProp `json:"properties"` } type sessionProfileProp struct { Name string `json:"name"` Value string `json:"value"` Signature string `json:"signature,omitempty"` } type errorResponse struct { Error string `json:"error"` } // ── Handlers ────────────────────────────────────────────────── func (h *Handler) register(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 registerRequest if err := json.Unmarshal(body, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if req.Username == "" || req.Email == "" || req.Password == "" { writeError(w, http.StatusBadRequest, "Username, email and password are required") return } // Basic email validation. if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { writeError(w, http.StatusBadRequest, "Invalid email address") return } // Check uniqueness. var exists int err = h.db.Pool().QueryRow(r.Context(), `SELECT COUNT(*) FROM users WHERE username = $1 OR email = $2`, req.Username, req.Email, ).Scan(&exists) if err != nil { writeError(w, http.StatusInternalServerError, "Database error") return } if exists > 0 { writeError(w, http.StatusConflict, "Username or email already taken") return } uuid := auth.GenerateUUID() passwordHash := auth.HashPassword(req.Password) _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO users (username, email, password_hash, uuid, role) VALUES ($1, $2, $3, $4, 'user')`, req.Username, req.Email, passwordHash, uuid, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to create user") return } writeJSON(w, http.StatusCreated, registerResponse{UUID: uuid}) } func (h *Handler) webLogin(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 webLoginRequest if err := json.Unmarshal(body, &req); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } var user database.User err = h.db.Pool().QueryRow(r.Context(), `SELECT id, username, password_hash, uuid FROM users WHERE username = $1 OR email = $1`, req.Username, ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID) if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) { writeError(w, http.StatusUnauthorized, "Invalid credentials") return } // Generate a simple session token (for web; launcher uses Yggdrasil). token := auth.GenerateToken() _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO yggdrasil_sessions (client_token, access_token, user_id, expires_at) VALUES ($1, $2, $3, NOW() + INTERVAL '7 days')`, token, token, user.ID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to create session") return } writeJSON(w, http.StatusOK, webLoginResponse{ Token: token, UUID: user.UUID, Username: user.Username, }) } func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) { userID := h.authenticateRequest(w, r) if userID == 0 { return } r.Body = http.MaxBytesReader(w, r.Body, maxSkinSize) if err := r.ParseMultipartForm(maxSkinSize); err != nil { writeError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)") return } file, _, err := r.FormFile("skin") if err != nil { writeError(w, http.StatusBadRequest, "No skin file provided") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusBadRequest, "Cannot read skin file") return } if !isValidSkinPNG(data) { writeError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)") return } hash := sha1Hex(data) // Store in CAS. destDir := filepath.Join(h.cfg.SkinsDir, hash[:2]) if err := os.MkdirAll(destDir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "Failed to store skin") return } dest := filepath.Join(destDir, hash+".png") if err := os.WriteFile(dest, data, 0o644); err != nil { writeError(w, http.StatusInternalServerError, "Failed to write skin") return } // Update user profile. _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO player_textures (user_id, skin_hash) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET skin_hash = $2`, userID, hash, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to update profile") return } writeJSON(w, http.StatusOK, map[string]string{"hash": hash}) } func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) { userID := h.authenticateRequest(w, r) if userID == 0 { return } r.Body = http.MaxBytesReader(w, r.Body, maxCapeSize) if err := r.ParseMultipartForm(maxCapeSize); err != nil { writeError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)") return } file, _, err := r.FormFile("cape") if err != nil { writeError(w, http.StatusBadRequest, "No cape file provided") return } defer file.Close() data, err := io.ReadAll(file) if err != nil { writeError(w, http.StatusBadRequest, "Cannot read cape file") return } // Validate PNG header. if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { writeError(w, http.StatusBadRequest, "Invalid PNG file") return } hash := sha1Hex(data) // Store in skins dir alongside skins (same CAS layout). destDir := filepath.Join(h.cfg.SkinsDir, hash[:2]) if err := os.MkdirAll(destDir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "Failed to store cape") return } dest := filepath.Join(destDir, hash+".png") if err := os.WriteFile(dest, data, 0o644); err != nil { writeError(w, http.StatusInternalServerError, "Failed to write cape") return } // Update user profile. _, err = h.db.Pool().Exec(r.Context(), `INSERT INTO player_textures (user_id, cape_hash) VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET cape_hash = $2`, userID, hash, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to update profile") return } writeJSON(w, http.StatusOK, map[string]string{"hash": hash}) } func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) { userID := h.authenticateRequest(w, r) if userID == 0 { return } _, err := h.db.Pool().Exec(r.Context(), `UPDATE player_textures SET skin_hash = NULL WHERE user_id = $1`, userID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to remove skin") return } writeJSON(w, http.StatusOK, map[string]string{"status": "skin removed"}) } func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) { userID := h.authenticateRequest(w, r) if userID == 0 { return } _, err := h.db.Pool().Exec(r.Context(), `UPDATE player_textures SET cape_hash = NULL WHERE user_id = $1`, userID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to remove cape") return } writeJSON(w, http.StatusOK, map[string]string{"status": "cape removed"}) } // authenticateRequest extracts and validates the Bearer token from the request, // returning the user ID on success or 0 on failure (having already written the error). func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) int { token := auth.ExtractBearer(r.Header.Get("Authorization")) if token == "" { writeError(w, http.StatusUnauthorized, "Missing authorization token") return 0 } var userID int err := h.db.Pool().QueryRow(r.Context(), `SELECT user_id FROM yggdrasil_sessions WHERE access_token = $1 AND expires_at > NOW()`, token, ).Scan(&userID) if err != nil { writeError(w, http.StatusUnauthorized, "Invalid or expired token") return 0 } return userID } func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) { osParam := r.URL.Query().Get("os") archParam := r.URL.Query().Get("arch") if osParam == "" || archParam == "" { writeError(w, http.StatusBadRequest, "os and arch query parameters are required") return } var release database.LauncherRelease err := h.db.Pool().QueryRow(r.Context(), `SELECT version, file_path, sha256, is_mandatory FROM launcher_releases WHERE os = $1 AND arch = $2 AND is_active = true ORDER BY created_at DESC LIMIT 1`, osParam, archParam, ).Scan(&release.Version, &release.FilePath, &release.SHA256, &release.IsMandatory) if err != nil { writeError(w, http.StatusNotFound, "No release found for this platform") return } downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s", h.cfg.BaseURL, osParam, archParam, filepath.Base(release.FilePath)) writeJSON(w, http.StatusOK, launcherLatestResponse{ Version: release.Version, Downloads: map[string]string{osParam + "_" + archParam: downloadURL}, SHA256: release.SHA256, IsMandatory: release.IsMandatory, }) } func (h *Handler) serversList(w http.ResponseWriter, r *http.Request) { rows, err := h.db.Pool().Query(r.Context(), `SELECT slug, name, minecraft_version, server_ip FROM modpacks WHERE is_active = true ORDER BY created_at DESC`) if err != nil { writeError(w, http.StatusInternalServerError, "Database error") return } defer rows.Close() var servers []serverInfo for rows.Next() { var s serverInfo if err := rows.Scan(&s.Slug, &s.Name, &s.Version, &s.IP); err != nil { continue } servers = append(servers, s) } writeJSON(w, http.StatusOK, serversResponse{Servers: servers}) } func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { writeError(w, http.StatusBadRequest, "Instance slug is required") return } // Check modpack exists. 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, "Instance not found") return } // Read manifest file. manifestPath := filepath.Join(h.cfg.CASDir, "..", "manifests", slug, "manifest.json") data, err := os.ReadFile(manifestPath) if err != nil { writeError(w, http.StatusNotFound, "Manifest not found") return } w.Header().Set("Content-Type", "application/json") w.Write(data) } func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("uuid") if uuid == "" { writeError(w, http.StatusBadRequest, "UUID is required") return } var p profileResponse var skinHash, capeHash *string var isSlim bool err := h.db.Pool().QueryRow(r.Context(), `SELECT u.uuid, u.username, pt.skin_hash, pt.cape_hash, COALESCE(pt.is_slim, false) FROM users u LEFT JOIN player_textures pt ON pt.user_id = u.id WHERE u.uuid = $1`, uuid, ).Scan(&p.UUID, &p.Username, &skinHash, &capeHash, &isSlim) if err != nil { writeError(w, http.StatusNotFound, "Player not found") return } if skinHash != nil || capeHash != nil { p.Textures = &textureInfo{ SkinHash: deref(skinHash), CapeHash: deref(capeHash), IsSlim: isSlim, } } writeJSON(w, http.StatusOK, p) } func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) { hash := r.PathValue("hash") if hash == "" { http.NotFound(w, r) return } // Sanitize: only hex chars. for _, c := range hash { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { http.NotFound(w, r) return } } path := filepath.Join(h.cfg.SkinsDir, hash[:2], hash+".png") data, err := os.ReadFile(path) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } // ── Helpers ─────────────────────────────────────────────────── func sha1Hex(data []byte) string { h := sha1.Sum(data) return hex.EncodeToString(h[:]) } // isValidSkinPNG checks that data is a valid PNG with accepted Minecraft skin dimensions. func isValidSkinPNG(data []byte) bool { if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { return false } cfg, err := png.DecodeConfig(strings.NewReader(string(data))) if err != nil { return false } for _, d := range skinDims { if cfg.Width == d.W && cfg.Height == d.H { return true } } return false } func deref(s *string) string { if s == nil { return "" } return *s } 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, errorResponse{Error: msg}) }