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

@@ -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)