// 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}) }