feat: add API handler (register, login, skin, launcher, servers, manifest)

- register: POST /api/web/register — create user with SHA-256 password hash
- login: POST /api/web/login — credentials check + session token
- uploadSkin: POST /api/web/profile/skin — PNG upload, SHA-1 CAS storage
- launcherLatest: GET /api/launcher/latest — latest launcher version + download URL
- serversList: GET /api/servers.json — active modpacks list
- instanceManifest: GET /api/instances/{slug}/manifest.json — modpack manifest
- serveSkin: GET /skins/{hash}.png — skin file serving with cache headers
- PathValue-based routing (Go 1.22+)

Co-Authored-By: OWL <noreply@anthropic.com>
This commit is contained in:
2026-05-26 13:31:22 +03:00
parent d205320e0e
commit 475ff9bfa2
2 changed files with 384 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api"
"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"
@@ -41,7 +42,11 @@ func main() {
authHandler := auth.NewHandler(db, cfg) authHandler := auth.NewHandler(db, cfg)
authHandler.RegisterRoutes(mux) authHandler.RegisterRoutes(mux)
// TODO: register API, Admin, CAS routes. // Public API.
apiHandler := api.NewHandler(db, cfg)
apiHandler.RegisterRoutes(mux)
// TODO: register Admin, CAS routes.
addr := ":" + itoa(cfg.Port) addr := ":" + itoa(cfg.Port)
srv := &http.Server{ srv := &http.Server{

View File

@@ -1,3 +1,380 @@
// Package api implements public HTTP endpoints for the launcher and website. // package api implements public HTTP endpoints for the launcher and website.
package api package api
import (
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
)
// 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)
// 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)
// Skin serving.
mux.HandleFunc("GET /skins/{hash}.png", 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 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 {
writeError(w, http.StatusBadRequest, "Cannot read body")
return
}
var req registerRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Username, email and password are required")
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 {
writeError(w, http.StatusInternalServerError, "Database error")
return
}
if exists > 0 {
writeError(w, http.StatusConflict, "Username or email already taken")
return
}
uuid := auth.GenerateUUID()
passwordHash := auth.HashPassword(req.Password)
_, 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 {
writeError(w, http.StatusInternalServerError, "Failed to create user")
return
}
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 {
writeError(w, http.StatusBadRequest, "Cannot read body")
return
}
var req webLoginRequest
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
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 || !verifyPassword(req.Password, user.PasswordHash) {
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 {
writeError(w, http.StatusInternalServerError, "Failed to create session")
return
}
writeJSON(w, http.StatusOK, webLoginResponse{
Token: token,
UUID: user.UUID,
Username: user.Username,
})
}
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")
return
}
file, _, err := r.FormFile("skin")
if err != nil {
writeError(w, http.StatusBadRequest, "No skin file provided")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read skin file")
return
}
// Validate PNG.
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
writeError(w, http.StatusBadRequest, "Invalid PNG file")
return
}
// Compute SHA-1 hash.
hash := sha1Hex(data)
// Store in CAS.
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
if err := os.MkdirAll(destDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to store skin")
return
}
dest := filepath.Join(destDir, hash+".png")
if err := os.WriteFile(dest, data, 0o644); err != nil {
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 {
writeError(w, http.StatusInternalServerError, "Failed to update profile")
return
}
writeJSON(w, http.StatusOK, map[string]string{"hash": hash})
}
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 == "" {
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 {
writeError(w, http.StatusNotFound, "No release found for this platform")
return
}
downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s",
h.cfg.BaseURL, osParam, archParam, filepath.Base(release.FilePath))
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 {
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)
}
writeJSON(w, http.StatusOK, serversResponse{Servers: servers})
}
func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
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 {
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 {
writeError(w, http.StatusNotFound, "Manifest not found")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
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 ───────────────────────────────────────────────────
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[:])
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorResponse{Error: msg})
}