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:
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api"
|
||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth"
|
||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config"
|
||||
"gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database"
|
||||
@@ -41,7 +42,11 @@ func main() {
|
||||
authHandler := auth.NewHandler(db, cfg)
|
||||
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)
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -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
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user