diff --git a/internal/admin/admin.go b/internal/admin/admin.go index b8a19fb..7b33e55 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -17,6 +17,7 @@ import ( "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" ) @@ -51,7 +52,7 @@ 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")) + token := auth.ExtractBearer(r.Header.Get("Authorization")) if token == "" { writeError(w, http.StatusUnauthorized, "Missing authorization token") return @@ -92,13 +93,6 @@ func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc { } } -func extractBearer(h string) string { - if strings.HasPrefix(h, "Bearer ") { - return h[7:] - } - return "" -} - // ── Modpack CRUD ────────────────────────────────────────────── type modpackRequest struct { diff --git a/internal/api/api.go b/internal/api/api.go index d4c5f2e..227f33a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,22 +3,37 @@ package api import ( "crypto/sha1" - "crypto/sha256" - "crypto/subtle" "encoding/hex" "encoding/json" "fmt" + "image/png" "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" ) +// 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 @@ -36,12 +51,18 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 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) } @@ -87,6 +108,31 @@ type launcherLatestResponse struct { 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"` } @@ -111,6 +157,12 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) { 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(), @@ -162,7 +214,7 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) { req.Username, ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID) - if err != nil || !verifyPassword(req.Password, user.PasswordHash) { + if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) { writeError(w, http.StatusUnauthorized, "Invalid credentials") return } @@ -188,11 +240,14 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) { } func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) { - // TODO: authenticate via session token from header. - // For now, accept user_id from form (to be replaced with proper auth). - userID, err := strconv.Atoi(r.FormValue("user_id")) - if err != nil || userID == 0 { - writeError(w, http.StatusUnauthorized, "Authentication required") + 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 } @@ -209,13 +264,11 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) { return } - // Validate PNG. - if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { - writeError(w, http.StatusBadRequest, "Invalid PNG file") + if !isValidSkinPNG(data) { + writeError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)") return } - // Compute SHA-1 hash. hash := sha1Hex(data) // Store in CAS. @@ -245,6 +298,124 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) { 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") @@ -330,6 +501,39 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) { 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 == "" { @@ -359,16 +563,37 @@ func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) { // ── Helpers ─────────────────────────────────────────────────── -func verifyPassword(password, hash string) bool { - h := sha256.Sum256([]byte(password)) - return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1 -} - 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) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a39eef6..1c427a5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" @@ -33,6 +34,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /authserver/authenticate", h.authenticate) mux.HandleFunc("POST /authserver/refresh", h.refresh) mux.HandleFunc("POST /authserver/validate", h.validate) + mux.HandleFunc("POST /authserver/invalidate", h.invalidate) + mux.HandleFunc("POST /authserver/signout", h.signout) + + // Session server — game client queries player skins/profile. + mux.HandleFunc("GET /sessionserver/session/minecraft/profile/{uuid}", h.sessionProfile) + mux.HandleFunc("GET /sessionserver/session/minecraft/profile/{unsigned}", h.sessionProfile) } // ── Request / Response types ────────────────────────────────── @@ -81,6 +88,19 @@ type errorResponse struct { ErrorMessage string `json:"errorMessage"` } +// Session server types (Mojang-compatible). +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"` +} + // ── Handlers ────────────────────────────────────────────────── func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) { @@ -104,7 +124,7 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) { } // Verify password (SHA-256 hex comparison). - if !verifyPassword(req.Password, user.PasswordHash) { + if !VerifyPassword(req.Password, user.PasswordHash) { writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials") return } @@ -233,6 +253,111 @@ func (h *Handler) validate(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) invalidate(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var req refreshRequest + if err := json.Unmarshal(body, &req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + _, _ = h.db.Pool().Exec(r.Context(), + `DELETE FROM yggdrasil_sessions WHERE access_token = $1 AND client_token = $2`, + req.AccessToken, req.ClientToken, + ) + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) signout(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.Unmarshal(body, &req); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + user, err := h.findUser(r.Context(), req.Username) + if err != nil || !VerifyPassword(req.Password, user.PasswordHash) { + w.WriteHeader(http.StatusForbidden) + return + } + + _, _ = h.db.Pool().Exec(r.Context(), + `DELETE FROM yggdrasil_sessions WHERE user_id = $1`, user.ID, + ) + + w.WriteHeader(http.StatusNoContent) +} + +// sessionProfile returns the Mojang-compatible profile for the game client. +// URL: GET /sessionserver/session/minecraft/profile/{uuid} +// The game client uses this to look up player textures (skin + cape). +func (h *Handler) sessionProfile(w http.ResponseWriter, r *http.Request) { + uuid := r.PathValue("uuid") + if uuid == "" { + http.NotFound(w, r) + return + } + + // Look up user + textures by UUID. + var username string + var skinHash, capeHash *string + err := h.db.Pool().QueryRow(r.Context(), + `SELECT u.username, pt.skin_hash, pt.cape_hash + FROM users u + LEFT JOIN player_textures pt ON pt.user_id = u.id + WHERE u.uuid = $1`, + uuid, + ).Scan(&username, &skinHash, &capeHash) + if err != nil { + http.NotFound(w, r) + return + } + + // Build texture URL prefix. + textureBase := h.cfg.BaseURL + "/skins/" + + // Build properties (Mojang format). + props := make([]sessionProfileProp, 0, 1) + texObj := make(map[string]string) + if skinHash != nil && *skinHash != "" { + texObj["skin"] = textureBase + *skinHash + ".png" + } + if capeHash != nil && *capeHash != "" { + texObj["cape"] = textureBase + *capeHash + ".png" + } + + if len(texObj) > 0 { + texJSON, _ := json.Marshal(texObj) + props = append(props, sessionProfileProp{ + Name: "textures", + Value: string(texJSON), + }) + } + + prof := sessionProfileResponse{ + ID: uuid, + Name: username, + Props: props, + } + + writeJSON(w, http.StatusOK, prof) +} + // ── Helpers ─────────────────────────────────────────────────── func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) { @@ -247,7 +372,16 @@ func (h *Handler) findUser(ctx context.Context, login string) (*database.User, e return &user, nil } -func verifyPassword(password, hash string) bool { +// ExtractBearer extracts the token from "Authorization: Bearer " header. +func ExtractBearer(h string) string { + if strings.HasPrefix(h, "Bearer ") { + return h[7:] + } + return "" +} + +// VerifyPassword checks a plaintext password against a SHA-256 hex hash. +func VerifyPassword(password, hash string) bool { h := sha256.Sum256([]byte(password)) return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1 } diff --git a/internal/database/database.go b/internal/database/database.go index 39579b5..08e6280 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -74,14 +74,22 @@ type GlobalFile struct { FileName string `db:"file_name"` } +// PlayerTextures holds skin/cape references for a user. +type PlayerTextures struct { + UserID int `db:"user_id"` + SkinHash string `db:"skin_hash"` + CapeHash string `db:"cape_hash"` + IsSlim bool `db:"is_slim"` +} + // LauncherRelease tracks published launcher binaries. type LauncherRelease struct { - ID int `db:"id"` - Version string `db:"version"` - OS string `db:"os"` - Arch string `db:"arch"` - SHA256 string `db:"sha256"` - FilePath string `db:"file_path"` - IsActive bool `db:"is_active"` - IsMandatory bool `db:"is_mandatory"` + ID int `db:"id"` + Version string `db:"version"` + OS string `db:"os"` + Arch string `db:"arch"` + SHA256 string `db:"sha256"` + FilePath string `db:"file_path"` + IsActive bool `db:"is_active"` + IsMandatory bool `db:"is_mandatory"` }