refactor: deduplicate sha1Hex/writeJSON/writeError into pkg/utils

- admin.go: replace local sha1Hex, sha256Hex, writeJSON, writeError with pkg/utils equivalents
- auth.go: replace local writeJSON with utils.WriteJSON; rewrite writeError as thin wrapper
- cas.go: remove local sha1Hex and unused writeJSON; use utils.SHA1Bytes
- pkg/utils.go: add WriteJSON, WriteError; reorder imports
This commit is contained in:
2026-05-29 23:53:33 +03:00
parent d418ae2b54
commit e1cc999ea8
5 changed files with 131 additions and 166 deletions

View File

@@ -5,10 +5,7 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context" "context"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -18,6 +15,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
"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"
@@ -55,7 +53,7 @@ func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
token := auth.ExtractBearer(r.Header.Get("Authorization")) token := auth.ExtractBearer(r.Header.Get("Authorization"))
if token == "" { if token == "" {
writeError(w, http.StatusUnauthorized, "Missing authorization token") utils.WriteError(w, http.StatusUnauthorized, "Missing authorization token")
return return
} }
@@ -70,11 +68,11 @@ func (h *Handler) auth(next http.HandlerFunc) http.HandlerFunc {
).Scan(&userID, &role) ).Scan(&userID, &role)
if err != nil { if err != nil {
writeError(w, http.StatusUnauthorized, "Invalid token") utils.WriteError(w, http.StatusUnauthorized, "Invalid token")
return return
} }
if role != "admin" { if role != "admin" {
writeError(w, http.StatusForbidden, "Admin access required") utils.WriteError(w, http.StatusForbidden, "Admin access required")
return return
} }
@@ -87,7 +85,7 @@ func (h *Handler) ciToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-CI-Token") token := r.Header.Get("X-CI-Token")
if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(h.cfg.CIsecret)) != 1 { if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(h.cfg.CIsecret)) != 1 {
writeError(w, http.StatusForbidden, "Invalid CI token") utils.WriteError(w, http.StatusForbidden, "Invalid CI token")
return return
} }
next(w, r) next(w, r)
@@ -119,7 +117,7 @@ func (h *Handler) listModpacks(w http.ResponseWriter, r *http.Request) {
`SELECT id, slug, name, minecraft_version, java_version, server_ip, is_active `SELECT id, slug, name, minecraft_version, java_version, server_ip, is_active
FROM modpacks ORDER BY created_at DESC`) FROM modpacks ORDER BY created_at DESC`)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Database error") utils.WriteError(w, http.StatusInternalServerError, "Database error")
return return
} }
defer rows.Close() defer rows.Close()
@@ -132,24 +130,24 @@ func (h *Handler) listModpacks(w http.ResponseWriter, r *http.Request) {
} }
modpacks = append(modpacks, m) modpacks = append(modpacks, m)
} }
writeJSON(w, http.StatusOK, modpacks) utils.WriteJSON(w, http.StatusOK, modpacks)
} }
func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) { func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read body") utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return return
} }
var req modpackRequest var req modpackRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON") utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return return
} }
if req.Slug == "" || req.Name == "" || req.MinecraftVersion == "" { if req.Slug == "" || req.Name == "" || req.MinecraftVersion == "" {
writeError(w, http.StatusBadRequest, "slug, name, and minecraft_version are required") utils.WriteError(w, http.StatusBadRequest, "slug, name, and minecraft_version are required")
return return
} }
@@ -159,29 +157,29 @@ func (h *Handler) createModpack(w http.ResponseWriter, r *http.Request) {
req.Slug, req.Name, req.MinecraftVersion, req.JavaVersion, req.ServerIP, req.Slug, req.Name, req.MinecraftVersion, req.JavaVersion, req.ServerIP,
) )
if err != nil { if err != nil {
writeError(w, http.StatusConflict, "Modpack with this slug already exists") utils.WriteError(w, http.StatusConflict, "Modpack with this slug already exists")
return return
} }
writeJSON(w, http.StatusCreated, map[string]string{"status": "created"}) utils.WriteJSON(w, http.StatusCreated, map[string]string{"status": "created"})
} }
func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) { func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id")) id, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Invalid modpack ID") utils.WriteError(w, http.StatusBadRequest, "Invalid modpack ID")
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read body") utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return return
} }
var req modpackRequest var req modpackRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON") utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return return
} }
@@ -191,28 +189,28 @@ func (h *Handler) updateModpack(w http.ResponseWriter, r *http.Request) {
req.Name, req.Slug, req.MinecraftVersion, req.JavaVersion, req.ServerIP, id, req.Name, req.Slug, req.MinecraftVersion, req.JavaVersion, req.ServerIP, id,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Update failed") utils.WriteError(w, http.StatusInternalServerError, "Update failed")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "updated"})
} }
func (h *Handler) deleteModpack(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteModpack(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id")) id, err := strconv.Atoi(r.PathValue("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Invalid modpack ID") utils.WriteError(w, http.StatusBadRequest, "Invalid modpack ID")
return return
} }
_, err = h.db.Pool().Exec(r.Context(), _, err = h.db.Pool().Exec(r.Context(),
`UPDATE modpacks SET is_active = false WHERE id = $1`, id) `UPDATE modpacks SET is_active = false WHERE id = $1`, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Delete failed") utils.WriteError(w, http.StatusInternalServerError, "Delete failed")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
} }
// ── File Upload ─────────────────────────────────────────────── // ── File Upload ───────────────────────────────────────────────
@@ -220,7 +218,7 @@ func (h *Handler) deleteModpack(w http.ResponseWriter, r *http.Request) {
func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) { func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") slug := r.PathValue("slug")
if slug == "" { if slug == "" {
writeError(w, http.StatusBadRequest, "Modpack slug is required") utils.WriteError(w, http.StatusBadRequest, "Modpack slug is required")
return return
} }
@@ -229,18 +227,18 @@ func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) {
`SELECT EXISTS(SELECT 1 FROM modpacks WHERE slug = $1 AND is_active = true)`, slug, `SELECT EXISTS(SELECT 1 FROM modpacks WHERE slug = $1 AND is_active = true)`, slug,
).Scan(&exists) ).Scan(&exists)
if err != nil || !exists { if err != nil || !exists {
writeError(w, http.StatusNotFound, "Modpack not found") utils.WriteError(w, http.StatusNotFound, "Modpack not found")
return return
} }
if err := r.ParseMultipartForm(500 << 20); err != nil { if err := r.ParseMultipartForm(500 << 20); err != nil {
writeError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)") utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 500 MB)")
return return
} }
files := r.MultipartForm.File["files"] files := r.MultipartForm.File["files"]
if len(files) == 0 { if len(files) == 0 {
writeError(w, http.StatusBadRequest, "No files uploaded (field name: 'files')") utils.WriteError(w, http.StatusBadRequest, "No files uploaded (field name: 'files')")
return return
} }
@@ -267,7 +265,7 @@ func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) {
} }
} }
hash := sha1Hex(data) hash := utils.SHA1Bytes(data)
destDir := filepath.Join(h.cfg.CASDir, hash[:2]) destDir := filepath.Join(h.cfg.CASDir, hash[:2])
os.MkdirAll(destDir, 0o755) os.MkdirAll(destDir, 0o755)
os.WriteFile(filepath.Join(destDir, hash), data, 0o644) os.WriteFile(filepath.Join(destDir, hash), data, 0o644)
@@ -285,7 +283,7 @@ func (h *Handler) uploadFiles(w http.ResponseWriter, r *http.Request) {
uploaded = append(uploaded, fh.Filename) uploaded = append(uploaded, fh.Filename)
} }
writeJSON(w, http.StatusOK, map[string]interface{}{ utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
"status": "uploaded", "status": "uploaded",
"files": uploaded, "files": uploaded,
"count": len(uploaded), "count": len(uploaded),
@@ -370,19 +368,19 @@ type manifestLaunch struct {
func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) { func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") slug := r.PathValue("slug")
if slug == "" { if slug == "" {
writeError(w, http.StatusBadRequest, "Modpack slug is required") utils.WriteError(w, http.StatusBadRequest, "Modpack slug is required")
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read body") utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return return
} }
var req manifestRequest var req manifestRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON") utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return return
} }
@@ -392,7 +390,7 @@ func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) {
WHERE slug = $1 AND is_active = true`, slug, WHERE slug = $1 AND is_active = true`, slug,
).Scan(&mp.ID, &mp.Name, &mp.MinecraftVersion, &mp.JavaVersion, &mp.ServerIP) ).Scan(&mp.ID, &mp.Name, &mp.MinecraftVersion, &mp.JavaVersion, &mp.ServerIP)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "Modpack not found") utils.WriteError(w, http.StatusNotFound, "Modpack not found")
return return
} }
@@ -410,7 +408,7 @@ func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
return nil return nil
} }
hash := sha1Hex(data) hash := utils.SHA1Bytes(data)
casDir := filepath.Join(h.cfg.CASDir, hash[:2]) casDir := filepath.Join(h.cfg.CASDir, hash[:2])
os.MkdirAll(casDir, 0o755) os.MkdirAll(casDir, 0o755)
@@ -452,14 +450,14 @@ func (h *Handler) generateManifest(w http.ResponseWriter, r *http.Request) {
data, _ := json.MarshalIndent(manifest, "", " ") data, _ := json.MarshalIndent(manifest, "", " ")
os.WriteFile(filepath.Join(manifestDir, "manifest.json"), data, 0o644) os.WriteFile(filepath.Join(manifestDir, "manifest.json"), data, 0o644)
writeJSON(w, http.StatusOK, manifest) utils.WriteJSON(w, http.StatusOK, manifest)
} }
// ── Launcher Release (CI/CD) ────────────────────────────────── // ── Launcher Release (CI/CD) ──────────────────────────────────
func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) { func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(100 << 20); err != nil { if err := r.ParseMultipartForm(100 << 20); err != nil {
writeError(w, http.StatusBadRequest, "Cannot parse form (max 100 MB)") utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 100 MB)")
return return
} }
@@ -469,13 +467,13 @@ func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) {
sha256 := r.FormValue("sha256") sha256 := r.FormValue("sha256")
if version == "" || osParam == "" || arch == "" || sha256 == "" { if version == "" || osParam == "" || arch == "" || sha256 == "" {
writeError(w, http.StatusBadRequest, "version, os, arch, and sha256 are required") utils.WriteError(w, http.StatusBadRequest, "version, os, arch, and sha256 are required")
return return
} }
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "No file uploaded (field name: 'file')") utils.WriteError(w, http.StatusBadRequest, "No file uploaded (field name: 'file')")
return return
} }
defer file.Close() defer file.Close()
@@ -485,18 +483,18 @@ func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(file) data, err := io.ReadAll(file)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Cannot read uploaded file") utils.WriteError(w, http.StatusInternalServerError, "Cannot read uploaded file")
return return
} }
if got := sha256Hex(data); got != sha256 { if got := utils.SHA256Bytes(data); got != sha256 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("SHA-256 mismatch: expected %s, got %s", sha256, got)) utils.WriteError(w, http.StatusBadRequest, fmt.Sprintf("SHA-256 mismatch: expected %s, got %s", sha256, got))
return return
} }
dest := filepath.Join(destDir, header.Filename) dest := filepath.Join(destDir, header.Filename)
if err := os.WriteFile(dest, data, 0o755); err != nil { if err := os.WriteFile(dest, data, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to store binary") utils.WriteError(w, http.StatusInternalServerError, "Failed to store binary")
return return
} }
@@ -506,34 +504,12 @@ func (h *Handler) launcherRelease(w http.ResponseWriter, r *http.Request) {
version, osParam, arch, sha256, dest, version, osParam, arch, sha256, dest,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to register release") utils.WriteError(w, http.StatusInternalServerError, "Failed to register release")
return return
} }
writeJSON(w, http.StatusCreated, map[string]string{ utils.WriteJSON(w, http.StatusCreated, map[string]string{
"status": "released", "status": "released",
"version": version, "version": version,
}) })
} }
// ── Helpers ───────────────────────────────────────────────────
func sha1Hex(data []byte) string {
h := sha1.Sum(data)
return hex.EncodeToString(h[:])
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(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, map[string]string{"error": msg})
}

View File

@@ -2,8 +2,6 @@
package api package api
import ( import (
"crypto/sha1"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image/png" "image/png"
@@ -16,6 +14,7 @@ import (
"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"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
) )
// Max skin file size: 1 MB (Minecraft skins are ~2-10 KB). // Max skin file size: 1 MB (Minecraft skins are ~2-10 KB).
@@ -142,24 +141,24 @@ type errorResponse struct {
func (h *Handler) register(w http.ResponseWriter, r *http.Request) { func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read body") utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return return
} }
var req registerRequest var req registerRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON") utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return return
} }
if req.Username == "" || req.Email == "" || req.Password == "" { if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Username, email and password are required") utils.WriteError(w, http.StatusBadRequest, "Username, email and password are required")
return return
} }
// Basic email validation. // Basic email validation.
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
writeError(w, http.StatusBadRequest, "Invalid email address") utils.WriteError(w, http.StatusBadRequest, "Invalid email address")
return return
} }
@@ -170,18 +169,18 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
req.Username, req.Email, req.Username, req.Email,
).Scan(&exists) ).Scan(&exists)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Database error") utils.WriteError(w, http.StatusInternalServerError, "Database error")
return return
} }
if exists > 0 { if exists > 0 {
writeError(w, http.StatusConflict, "Username or email already taken") utils.WriteError(w, http.StatusConflict, "Username or email already taken")
return return
} }
uuid := auth.GenerateUUID() uuid := auth.GenerateUUID()
passwordHash, err := auth.HashPassword(req.Password) passwordHash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to hash password") utils.WriteError(w, http.StatusInternalServerError, "Failed to hash password")
return return
} }
@@ -191,28 +190,28 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
req.Username, req.Email, passwordHash, uuid, req.Username, req.Email, passwordHash, uuid,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to create user") utils.WriteError(w, http.StatusInternalServerError, "Failed to create user")
return return
} }
writeJSON(w, http.StatusCreated, registerResponse{UUID: uuid}) utils.WriteJSON(w, http.StatusCreated, registerResponse{UUID: uuid})
} }
func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) { func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read body") utils.WriteError(w, http.StatusBadRequest, "Cannot read body")
return return
} }
var req webLoginRequest var req webLoginRequest
if err := json.Unmarshal(body, &req); err != nil { if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON") utils.WriteError(w, http.StatusBadRequest, "Invalid JSON")
return return
} }
if req.Username == "" || req.Password == "" { if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Username and password are required") utils.WriteError(w, http.StatusBadRequest, "Username and password are required")
return return
} }
@@ -224,7 +223,7 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID) ).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.UUID)
if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) { if err != nil || !auth.VerifyPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "Invalid credentials") utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
return return
} }
@@ -237,11 +236,11 @@ func (h *Handler) webLogin(w http.ResponseWriter, r *http.Request) {
token, token, user.ID, token, token, user.ID,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to create session") utils.WriteError(w, http.StatusInternalServerError, "Failed to create session")
return return
} }
writeJSON(w, http.StatusOK, webLoginResponse{ utils.WriteJSON(w, http.StatusOK, webLoginResponse{
Token: token, Token: token,
UUID: user.UUID, UUID: user.UUID,
Username: user.Username, Username: user.Username,
@@ -256,39 +255,39 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxSkinSize) r.Body = http.MaxBytesReader(w, r.Body, maxSkinSize)
if err := r.ParseMultipartForm(maxSkinSize); err != nil { if err := r.ParseMultipartForm(maxSkinSize); err != nil {
writeError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)") utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 1 MB)")
return return
} }
file, _, err := r.FormFile("skin") file, _, err := r.FormFile("skin")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "No skin file provided") utils.WriteError(w, http.StatusBadRequest, "No skin file provided")
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(file) data, err := io.ReadAll(file)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read skin file") utils.WriteError(w, http.StatusBadRequest, "Cannot read skin file")
return return
} }
if !isValidSkinPNG(data) { if !isValidSkinPNG(data) {
writeError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)") utils.WriteError(w, http.StatusBadRequest, "Invalid skin: must be a valid Minecraft skin PNG (64x32, 64x64, 128x128, or 128x64)")
return return
} }
hash := sha1Hex(data) hash := utils.SHA1Bytes(data)
// Store in CAS. // Store in CAS.
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2]) destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
if err := os.MkdirAll(destDir, 0o755); err != nil { if err := os.MkdirAll(destDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to store skin") utils.WriteError(w, http.StatusInternalServerError, "Failed to store skin")
return return
} }
dest := filepath.Join(destDir, hash+".png") dest := filepath.Join(destDir, hash+".png")
if err := os.WriteFile(dest, data, 0o644); err != nil { if err := os.WriteFile(dest, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to write skin") utils.WriteError(w, http.StatusInternalServerError, "Failed to write skin")
return return
} }
@@ -300,11 +299,11 @@ func (h *Handler) uploadSkin(w http.ResponseWriter, r *http.Request) {
userID, hash, userID, hash,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to update profile") utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"hash": hash}) utils.WriteJSON(w, http.StatusOK, map[string]string{"hash": hash})
} }
func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) { func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) {
@@ -315,40 +314,40 @@ func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxCapeSize) r.Body = http.MaxBytesReader(w, r.Body, maxCapeSize)
if err := r.ParseMultipartForm(maxCapeSize); err != nil { if err := r.ParseMultipartForm(maxCapeSize); err != nil {
writeError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)") utils.WriteError(w, http.StatusBadRequest, "Cannot parse form (max 2 MB)")
return return
} }
file, _, err := r.FormFile("cape") file, _, err := r.FormFile("cape")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "No cape file provided") utils.WriteError(w, http.StatusBadRequest, "No cape file provided")
return return
} }
defer file.Close() defer file.Close()
data, err := io.ReadAll(file) data, err := io.ReadAll(file)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "Cannot read cape file") utils.WriteError(w, http.StatusBadRequest, "Cannot read cape file")
return return
} }
// Validate PNG header. // Validate PNG header.
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
writeError(w, http.StatusBadRequest, "Invalid PNG file") utils.WriteError(w, http.StatusBadRequest, "Invalid PNG file")
return return
} }
hash := sha1Hex(data) hash := utils.SHA1Bytes(data)
// Store in skins dir alongside skins (same CAS layout). // Store in skins dir alongside skins (same CAS layout).
destDir := filepath.Join(h.cfg.SkinsDir, hash[:2]) destDir := filepath.Join(h.cfg.SkinsDir, hash[:2])
if err := os.MkdirAll(destDir, 0o755); err != nil { if err := os.MkdirAll(destDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to store cape") utils.WriteError(w, http.StatusInternalServerError, "Failed to store cape")
return return
} }
dest := filepath.Join(destDir, hash+".png") dest := filepath.Join(destDir, hash+".png")
if err := os.WriteFile(dest, data, 0o644); err != nil { if err := os.WriteFile(dest, data, 0o644); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to write cape") utils.WriteError(w, http.StatusInternalServerError, "Failed to write cape")
return return
} }
@@ -360,11 +359,11 @@ func (h *Handler) uploadCape(w http.ResponseWriter, r *http.Request) {
userID, hash, userID, hash,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to update profile") utils.WriteError(w, http.StatusInternalServerError, "Failed to update profile")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"hash": hash}) utils.WriteJSON(w, http.StatusOK, map[string]string{"hash": hash})
} }
func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) {
@@ -378,11 +377,11 @@ func (h *Handler) deleteSkin(w http.ResponseWriter, r *http.Request) {
userID, userID,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to remove skin") utils.WriteError(w, http.StatusInternalServerError, "Failed to remove skin")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "skin removed"}) utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "skin removed"})
} }
func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) { func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) {
@@ -396,11 +395,11 @@ func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) {
userID, userID,
) )
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to remove cape") utils.WriteError(w, http.StatusInternalServerError, "Failed to remove cape")
return return
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "cape removed"}) utils.WriteJSON(w, http.StatusOK, map[string]string{"status": "cape removed"})
} }
// authenticateRequest extracts and validates the Bearer token from the request, // authenticateRequest extracts and validates the Bearer token from the request,
@@ -408,7 +407,7 @@ func (h *Handler) deleteCape(w http.ResponseWriter, r *http.Request) {
func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) int { func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) int {
token := auth.ExtractBearer(r.Header.Get("Authorization")) token := auth.ExtractBearer(r.Header.Get("Authorization"))
if token == "" { if token == "" {
writeError(w, http.StatusUnauthorized, "Missing authorization token") utils.WriteError(w, http.StatusUnauthorized, "Missing authorization token")
return 0 return 0
} }
@@ -419,7 +418,7 @@ func (h *Handler) authenticateRequest(w http.ResponseWriter, r *http.Request) in
token, token,
).Scan(&userID) ).Scan(&userID)
if err != nil { if err != nil {
writeError(w, http.StatusUnauthorized, "Invalid or expired token") utils.WriteError(w, http.StatusUnauthorized, "Invalid or expired token")
return 0 return 0
} }
return userID return userID
@@ -430,7 +429,7 @@ func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
archParam := r.URL.Query().Get("arch") archParam := r.URL.Query().Get("arch")
if osParam == "" || archParam == "" { if osParam == "" || archParam == "" {
writeError(w, http.StatusBadRequest, "os and arch query parameters are required") utils.WriteError(w, http.StatusBadRequest, "os and arch query parameters are required")
return return
} }
@@ -443,14 +442,14 @@ func (h *Handler) launcherLatest(w http.ResponseWriter, r *http.Request) {
).Scan(&release.Version, &release.FilePath, &release.SHA256, &release.IsMandatory) ).Scan(&release.Version, &release.FilePath, &release.SHA256, &release.IsMandatory)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "No release found for this platform") utils.WriteError(w, http.StatusNotFound, "No release found for this platform")
return return
} }
downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s/%s", downloadURL := fmt.Sprintf("%s/files/launcher/%s/%s/%s/%s",
h.cfg.BaseURL, release.Version, osParam, archParam, filepath.Base(release.FilePath)) h.cfg.BaseURL, release.Version, osParam, archParam, filepath.Base(release.FilePath))
writeJSON(w, http.StatusOK, launcherLatestResponse{ utils.WriteJSON(w, http.StatusOK, launcherLatestResponse{
Version: release.Version, Version: release.Version,
Downloads: map[string]string{osParam + "_" + archParam: downloadURL}, Downloads: map[string]string{osParam + "_" + archParam: downloadURL},
SHA256: release.SHA256, SHA256: release.SHA256,
@@ -463,7 +462,7 @@ func (h *Handler) serversList(w http.ResponseWriter, r *http.Request) {
`SELECT slug, name, minecraft_version, server_ip FROM modpacks `SELECT slug, name, minecraft_version, server_ip FROM modpacks
WHERE is_active = true ORDER BY created_at DESC`) WHERE is_active = true ORDER BY created_at DESC`)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Database error") utils.WriteError(w, http.StatusInternalServerError, "Database error")
return return
} }
defer rows.Close() defer rows.Close()
@@ -477,13 +476,13 @@ func (h *Handler) serversList(w http.ResponseWriter, r *http.Request) {
servers = append(servers, s) servers = append(servers, s)
} }
writeJSON(w, http.StatusOK, serversResponse{Servers: servers}) utils.WriteJSON(w, http.StatusOK, serversResponse{Servers: servers})
} }
func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) { func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug") slug := r.PathValue("slug")
if slug == "" { if slug == "" {
writeError(w, http.StatusBadRequest, "Instance slug is required") utils.WriteError(w, http.StatusBadRequest, "Instance slug is required")
return return
} }
@@ -494,7 +493,7 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
slug, slug,
).Scan(&exists) ).Scan(&exists)
if err != nil || !exists { if err != nil || !exists {
writeError(w, http.StatusNotFound, "Instance not found") utils.WriteError(w, http.StatusNotFound, "Instance not found")
return return
} }
@@ -502,7 +501,7 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
manifestPath := filepath.Join(h.cfg.CASDir, "..", "manifests", slug, "manifest.json") manifestPath := filepath.Join(h.cfg.CASDir, "..", "manifests", slug, "manifest.json")
data, err := os.ReadFile(manifestPath) data, err := os.ReadFile(manifestPath)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "Manifest not found") utils.WriteError(w, http.StatusNotFound, "Manifest not found")
return return
} }
@@ -513,7 +512,7 @@ func (h *Handler) instanceManifest(w http.ResponseWriter, r *http.Request) {
func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) { func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
uuid := r.PathValue("uuid") uuid := r.PathValue("uuid")
if uuid == "" { if uuid == "" {
writeError(w, http.StatusBadRequest, "UUID is required") utils.WriteError(w, http.StatusBadRequest, "UUID is required")
return return
} }
@@ -528,7 +527,7 @@ func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
uuid, uuid,
).Scan(&p.UUID, &p.Username, &skinHash, &capeHash, &isSlim) ).Scan(&p.UUID, &p.Username, &skinHash, &capeHash, &isSlim)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "Player not found") utils.WriteError(w, http.StatusNotFound, "Player not found")
return return
} }
@@ -540,7 +539,7 @@ func (h *Handler) getProfile(w http.ResponseWriter, r *http.Request) {
} }
} }
writeJSON(w, http.StatusOK, p) utils.WriteJSON(w, http.StatusOK, p)
} }
func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) { func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
@@ -572,11 +571,6 @@ func (h *Handler) serveSkin(w http.ResponseWriter, r *http.Request) {
// ── Helpers ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
func sha1Hex(data []byte) string {
h := sha1.Sum(data)
return hex.EncodeToString(h[:])
}
// isValidSkinPNG checks that data is a valid PNG with accepted Minecraft skin dimensions. // isValidSkinPNG checks that data is a valid PNG with accepted Minecraft skin dimensions.
func isValidSkinPNG(data []byte) bool { func isValidSkinPNG(data []byte) bool {
if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" { if len(data) < 8 || string(data[:8]) != "\x89PNG\r\n\x1a\n" {
@@ -602,13 +596,3 @@ func deref(s *string) string {
} }
return *s return *s
} }
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})
}

View File

@@ -17,6 +17,7 @@ import (
"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"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
) )
// Handler serves Yggdrasil endpoints. // Handler serves Yggdrasil endpoints.
@@ -159,7 +160,7 @@ func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) {
}}, }},
} }
writeJSON(w, http.StatusOK, resp) utils.WriteJSON(w, http.StatusOK, resp)
} }
func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) { func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
@@ -216,7 +217,7 @@ func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, refreshResponse{ utils.WriteJSON(w, http.StatusOK, refreshResponse{
AccessToken: newAccessToken, AccessToken: newAccessToken,
ClientToken: req.ClientToken, ClientToken: req.ClientToken,
SelectedProfile: &profile{ SelectedProfile: &profile{
@@ -356,7 +357,7 @@ func (h *Handler) sessionProfile(w http.ResponseWriter, r *http.Request) {
Props: props, Props: props,
} }
writeJSON(w, http.StatusOK, prof) utils.WriteJSON(w, http.StatusOK, prof)
} }
// ── Helpers ─────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────
@@ -394,14 +395,8 @@ func GenerateToken() string {
return hex.EncodeToString(b) return hex.EncodeToString(b)
} }
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, err, msg string) { func writeError(w http.ResponseWriter, status int, err, msg string) {
writeJSON(w, status, errorResponse{ utils.WriteJSON(w, status, errorResponse{
Error: err, Error: err,
ErrorMessage: msg, ErrorMessage: msg,
}) })

View File

@@ -6,14 +6,14 @@
package cas package cas
import ( import (
"crypto/sha1"
"crypto/subtle" "crypto/subtle"
"encoding/hex"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils"
"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"
) )
@@ -148,7 +148,7 @@ func isValidHash(hash string) bool {
// StoreFile writes data to the CAS directory structure. // StoreFile writes data to the CAS directory structure.
// Returns the SHA-1 hash of the stored data. // Returns the SHA-1 hash of the stored data.
func StoreFile(casDir string, data []byte) (string, error) { func StoreFile(casDir string, data []byte) (string, error) {
hash := sha1Hex(data) hash := utils.SHA1Bytes(data)
destDir := filepath.Join(casDir, hash[:2]) destDir := filepath.Join(casDir, hash[:2])
if err := os.MkdirAll(destDir, 0o755); err != nil { if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", err return "", err
@@ -170,7 +170,7 @@ func FileExists(casDir, hash string) bool {
// VerifyAndStore writes data to CAS only if the SHA-1 matches the expected hash. // VerifyAndStore writes data to CAS only if the SHA-1 matches the expected hash.
// This prevents corrupt or tampered uploads from being stored. // This prevents corrupt or tampered uploads from being stored.
func VerifyAndStore(casDir string, data []byte, expectedHash string) (string, error) { func VerifyAndStore(casDir string, data []byte, expectedHash string) (string, error) {
got := sha1Hex(data) got := utils.SHA1Bytes(data)
if subtle.ConstantTimeCompare([]byte(got), []byte(expectedHash)) != 1 { if subtle.ConstantTimeCompare([]byte(got), []byte(expectedHash)) != 1 {
return "", ErrHashMismatch return "", ErrHashMismatch
} }
@@ -188,13 +188,3 @@ var ErrHashMismatch = errHashMismatch
type hashMismatchError struct{} type hashMismatchError struct{}
func (e *hashMismatchError) Error() string { return "SHA-1 hash mismatch" } func (e *hashMismatchError) Error() string { return "SHA-1 hash mismatch" }
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)
}

View File

@@ -1,19 +1,23 @@
// package utils provides shared utility functions (SHA-1, SHA-256, ZIP, etc.). // package utils provides shared utility functions (hashing, HTTP helpers, ZIP).
package utils package utils
import ( import (
"archive/zip"
"bytes" "bytes"
"crypto/sha1" "crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"io" "io"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"archive/zip"
) )
// ── Hashing ────────────────────────────────────────────────────
// SHA1Bytes returns the SHA-1 hex string of the given data. // SHA1Bytes returns the SHA-1 hex string of the given data.
func SHA1Bytes(data []byte) string { func SHA1Bytes(data []byte) string {
h := sha1.Sum(data) h := sha1.Sum(data)
@@ -41,6 +45,22 @@ func SHA1File(path string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
// ── HTTP helpers ───────────────────────────────────────────────
// WriteJSON writes a JSON response with the given status code.
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// WriteError writes a JSON error response.
func WriteError(w http.ResponseWriter, status int, msg string) {
WriteJSON(w, status, map[string]string{"error": msg})
}
// ── ZIP ────────────────────────────────────────────────────────
// Unzip extracts a ZIP archive to the destination directory. // Unzip extracts a ZIP archive to the destination directory.
// Returns the list of extracted file paths. // Returns the list of extracted file paths.
// Protects against zip-slip by validating that each entry's target path // Protects against zip-slip by validating that each entry's target path