feat: implement skins/capes, profile endpoints, session server

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
This commit is contained in:
2026-05-27 11:45:33 +03:00
parent e4fea937aa
commit 01cce981c5
4 changed files with 397 additions and 36 deletions

View File

@@ -17,6 +17,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" "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 { func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
token := extractBearer(r.Header.Get("Authorization")) token := auth.ExtractBearer(r.Header.Get("Authorization"))
if token == "" { if token == "" {
writeError(w, http.StatusUnauthorized, "Missing authorization token") writeError(w, http.StatusUnauthorized, "Missing authorization token")
return 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 ────────────────────────────────────────────── // ── Modpack CRUD ──────────────────────────────────────────────
type modpackRequest struct { type modpackRequest struct {

View File

@@ -3,22 +3,37 @@ package api
import ( import (
"crypto/sha1" "crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image/png"
"io" "io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strings"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" "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. // Handler serves public API endpoints.
type Handler struct { type Handler struct {
db *database.DB 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/register", h.register)
mux.HandleFunc("POST /api/web/login", h.webLogin) mux.HandleFunc("POST /api/web/login", h.webLogin)
mux.HandleFunc("POST /api/web/profile/skin", h.uploadSkin) 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. // Launcher endpoints.
mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest) mux.HandleFunc("GET /api/launcher/latest", h.launcherLatest)
mux.HandleFunc("GET /api/servers.json", h.serversList) mux.HandleFunc("GET /api/servers.json", h.serversList)
mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest) mux.HandleFunc("GET /api/instances/{slug}/manifest.json", h.instanceManifest)
// Profile — public read.
mux.HandleFunc("GET /api/web/profile/{uuid}", h.getProfile)
// Skin serving. // Skin serving.
mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin) mux.HandleFunc("GET /skins/{hash}.png", h.serveSkin)
} }
@@ -87,6 +108,31 @@ type launcherLatestResponse struct {
IsMandatory bool `json:"is_mandatory"` 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 { type errorResponse struct {
Error string `json:"error"` Error string `json:"error"`
} }
@@ -111,6 +157,12 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
return return
} }
// Basic email validation.
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
writeError(w, http.StatusBadRequest, "Invalid email address")
return
}
// Check uniqueness. // Check uniqueness.
var exists int var exists int
err = h.db.Pool().QueryRow(r.Context(), err = h.db.Pool().QueryRow(r.Context(),
@@ -162,7 +214,7 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
req.Username, req.Username,
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID) ).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") writeError(w, http.StatusUnauthorized, "Invalid credentials")
return 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) { func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
// TODO: authenticate via session token from header. userID := h.authenticateRequest(w, r)
// For now, accept user_id from form (to be replaced with proper auth). if userID == 0 {
userID, err := strconv.Atoi(r.FormValue("user_id")) return
if err != nil || userID == 0 { }
writeError(w, http.StatusUnauthorized, "Authentication required")
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 return
} }
@@ -209,13 +264,11 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
return return
} }
// Validate PNG. if !isValidSkinPNG(data) {
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { writeError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)")
writeError(w, http.StatusBadRequest, "Invalid PNG file")
return return
} }
// Compute SHA-1 hash.
hash := sha1Hex(data) hash := sha1Hex(data)
// Store in CAS. // 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}) 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) { func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
osParam := r.URL.Query().Get("os") osParam := r.URL.Query().Get("os")
archParam := r.URL.Query().Get("arch") archParam := r.URL.Query().Get("arch")
@@ -330,6 +501,39 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
w.Write(data) 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) { func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash") hash := r.PathValue("hash")
if hash == "" { if hash == "" {
@@ -359,16 +563,37 @@ func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
// ── Helpers ─────────────────────────────────────────────────── // ── 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 { func sha1Hex(data []byte) string {
h := sha1.Sum(data) h := sha1.Sum(data)
return hex.EncodeToString(h[:]) 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) { func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)

View File

@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "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/authenticate", h.authenticate)
mux.HandleFunc("POST /authserver/refresh", h.refresh) mux.HandleFunc("POST /authserver/refresh", h.refresh)
mux.HandleFunc("POST /authserver/validate", h.validate) 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 ────────────────────────────────── // ── Request / Response types ──────────────────────────────────
@@ -81,6 +88,19 @@ type errorResponse struct {
ErrorMessage string `json:"errorMessage"` 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 ────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────
func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) { 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). // 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") writeError(w, http.StatusUnauthorized, "Forbidden", "Invalid credentials")
return return
} }
@@ -233,6 +253,111 @@ func (h *Handler) validate(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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 ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
func (h *Handler) findUser(ctx context.Context, login string) (*database.User, error) { 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 return &user, nil
} }
func verifyPassword(password, hash string) bool { // ExtractBearer extracts the token from "Authorization: Bearer <token>" 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)) h := sha256.Sum256([]byte(password))
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1 return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
} }

View File

@@ -74,14 +74,22 @@ type GlobalFile struct {
FileName string `db:"file_name"` 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. // LauncherRelease tracks published launcher binaries.
type LauncherRelease struct { type LauncherRelease struct {
ID int `db:"id"` ID int `db:"id"`
Version string `db:"version"` Version string `db:"version"`
OS string `db:"os"` OS string `db:"os"`
Arch string `db:"arch"` Arch string `db:"arch"`
SHA256 string `db:"sha256"` SHA256 string `db:"sha256"`
FilePath string `db:"file_path"` FilePath string `db:"file_path"`
IsActive bool `db:"is_active"` IsActive bool `db:"is_active"`
IsMandatory bool `db:"is_mandatory"` IsMandatory bool `db:"is_mandatory"`
} }