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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <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))
|
||||
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ 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"`
|
||||
|
||||
Reference in New Issue
Block a user