diff --git a/cmd/server/main.go b/cmd/server/main.go index c914b35..4a858cb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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{ diff --git a/internal/api/api.go b/internal/api/api.go index b1e1070..d4c5f2e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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}) +}