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
604 lines
16 KiB
Go
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
|
|
}
|