Files
MrixsCraft-server/internal/api/api.go
Vladimir Zagainov 7ad02cb1b2
Some checks failed
CI / lint (push) Failing after 21s
CI / build (push) Has been skipped
CI / test (push) Has been skipped
CI / docker (push) Has been skipped
feat: implement email validation, CI/CD pipeline, migration history, and web templates
Email validation:
- Replace @/. check with net/mail.ParseAddress on register
- Add size limit check (max 254 chars, RFC 5321)

CI/CD Pipeline:
- Add .gitea/workflows/ci.yml (lint → test → build → docker push)
- Registry: gitea.mrixs.me/Mrixs/MrixsCraft-server
- Push only on main branch

Database:
- Add migrations/002_migration_history.sql (tracking applied migrations)
- Add migrations/README.md (manual apply instructions)

Web Templates:
- Add base.html with Minecraft-themed layout (dark + green accent)
- Add index.html, login.html, register.html with POST forms
- Rewrite templates.go for data-driven rendering with pageData struct
- Fallback placeholder preserved when templates dir missing
2026-05-30 00:39:51 +03:00

604 lines
16 KiB
Go

// package api implements public HTTP endpoints for the launcher and website.
package api
import (
"encoding/json"
"fmt"
"image/png"
"io"
"net/http"
"net/mail"
"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"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
)
// 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}", 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 {
utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return
}
var req registerRequest
if err := json.Unmarshal(body, &req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
utils.WriteError(w, http.StatusBadRequest, "Username, email and password are required")
return
}
// Basic email validation (RFC 5321).
if len(req.Email) > 254 {
utils.WriteError(w, http.StatusBadRequest, "Email too long (max 254 characters)")
return
}
if _, err := mail.ParseAddress(req.Email); err != nil {
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Database error")
return
}
if exists > 0 {
utils.WriteError(w, http.StatusConflict, "Username or email already taken")
return
}
uuid := auth.GenerateUUID()
passwordHash, err := auth.HashPassword(req.Password)
if err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Failed to hash password")
return
}
_, 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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to create user")
return
}
utils.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 {
utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return
}
var req webLoginRequest
if err := json.Unmarshal(body, &req); err != nil {
utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Username == "" || req.Password == "" {
utils.WriteError(w, http.StatusBadRequest, "Username and password are required")
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) {
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to create session")
return
}
utils.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 {
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)")
return
}
file, _, err := r.FormFile("skin")
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "No skin file provided")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Cannot read skin file")
return
}
if !isValidSkinPNG(data) {
utils.WriteError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)")
return
}
hash := utils.SHA1Bytes(data)
// Store in CAS.
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
if err := os.MkdirAll(destDir, 0o755); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "Failed to store skin")
return
}
dest := filepath.Join(destDir, hash+".png")
if err := os.WriteFile(dest, data, 0o644); err != nil {
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
return
}
utils.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 {
utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)")
return
}
file, _, err := r.FormFile("cape")
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "No cape file provided")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "Cannot read cape file")
return
}
// Validate PNG header.
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
utils.WriteError(w, http.StatusBadRequest, "Invalid PNG file")
return
}
hash := utils.SHA1Bytes(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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to store cape")
return
}
dest := filepath.Join(destDir, hash+".png")
if err := os.WriteFile(dest, data, 0o644); err != nil {
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
return
}
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to remove skin")
return
}
utils.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 {
utils.WriteError(w, http.StatusInternalServerError, "Failed to remove cape")
return
}
utils.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 == "" {
utils.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 {
utils.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 == "" {
utils.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 {
utils.WriteError(w, http.StatusNotFound, "No release found for this platform")
return
}
downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s/%s",
h.cfg.BaseURL, release.Version, osParam, archParam, filepath.Base(release.FilePath))
utils.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 {
utils.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)
}
utils.WriteJSON(w, http.StatusOK, serversResponse{Servers: servers})
}
func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
utils.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 {
utils.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 {
utils.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 == "" {
utils.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 {
utils.WriteError(w, http.StatusNotFound, "Player not found")
return
}
if skinHash != nil || capeHash != nil {
p.Textures = &textureInfo{
SkinHash: deref(skinHash),
CapeHash: deref(capeHash),
IsSlim: isSlim,
}
}
utils.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 ───────────────────────────────────────────────────
// 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
}