- 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>
381 lines
10 KiB
Go
381 lines
10 KiB
Go
// 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})
|
|
}
|