diff --git a/cmd/server/main.go b/cmd/server/main.go index 51bf614..e2eb5ce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,8 +13,11 @@ import ( "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/admin" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/api" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/auth" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/cas" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/middleware" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/templates" ) func main() { @@ -33,7 +36,7 @@ func main() { mux := http.NewServeMux() - // Health check. + // Health check — no auth needed. mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) @@ -47,16 +50,27 @@ func main() { apiHandler := api.NewHandler(db, cfg) apiHandler.RegisterRoutes(mux) + // CAS (Content-Addressable Storage) file serving. + casHandler := cas.NewHandler(db, cfg) + casHandler.RegisterRoutes(mux) + // Admin panel. adminHandler := admin.NewHandler(db, cfg) adminHandler.RegisterRoutes(mux) - // TODO: register CAS routes. + // Templates (web UI). + templatesHandler := templates.NewHandler(db, cfg) + templatesHandler.RegisterRoutes(mux) + + // Wrapper chain: Logging → CORS → mux. + var handler http.Handler = mux + handler = middleware.CORS(handler) + handler = middleware.Logging(handler) addr := ":" + itoa(cfg.Port) srv := &http.Server{ Addr: addr, - Handler: mux, + Handler: handler, } // Graceful shutdown. diff --git a/internal/cas/cas.go b/internal/cas/cas.go index 52f6238..9484aba 100644 --- a/internal/cas/cas.go +++ b/internal/cas/cas.go @@ -1,2 +1,161 @@ -// package cas implements Content-Addressable Storage for files (mods, assets, etc). +// package cas implements Content-Addressable Storage for immutable files. +// +// Files are stored by SHA-1 hash under //. +// Because files are immutable (changing content changes the hash), long cache +// headers are safe and desirable. package cas + +import ( + "crypto/sha1" + "crypto/subtle" + "encoding/hex" + "net/http" + "os" + "path/filepath" + "strings" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" +) + +// Handler serves CAS endpoints. +type Handler struct { + db *database.DB + cfg *config.Config +} + +// NewHandler creates a new CAS handler. +func NewHandler(db *database.DB, cfg *config.Config) *Handler { + return &Handler{db: db, cfg: cfg} +} + +// RegisterRoutes mounts the CAS endpoints. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // Public file serving — immutable, long cache. + mux.HandleFunc("GET /files/{hash}", h.serveFile) + + // Launcher binary downloads — also served from CAS-like paths. + mux.HandleFunc("GET /files/launcher/{version}/{os}/{arch}/{filename}", h.serveLauncherAsset) +} + +// serveFile serves a file from CAS by its SHA-1 hash. +// Files are immutable, so we set Cache-Control: public, max-age=31536000 (1 year). +func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request) { + hash := r.PathValue("hash") + if !isValidHash(hash) { + http.NotFound(w, r) + return + } + + path := filepath.Join(h.cfg.CASDir, hash[:2], hash) + data, err := os.ReadFile(path) + if err != nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Write(data) +} + +// serveLauncherAsset serves a released launcher binary. +// Path format: /files/launcher/{version}/{os}/{arch}/{filename} +func (h *Handler) serveLauncherAsset(w http.ResponseWriter, r *http.Request) { + version := r.PathValue("version") + osParam := r.PathValue("os") + arch := r.PathValue("arch") + filename := r.PathValue("filename") + + // Basic validation against path traversal. + for _, v := range []string{version, osParam, arch, filename} { + if strings.ContainsAny(v, "/\\") { + http.NotFound(w, r) + return + } + } + + path := filepath.Join(h.cfg.CASDir, "..", "launcher", version, osParam, arch, filename) + // Verify the resolved path is under the expected launcher storage dir. + clean := filepath.Clean(path) + expected := filepath.Clean(filepath.Join(h.cfg.CASDir, "..", "launcher")) + if !strings.HasPrefix(clean, expected+string(os.PathSeparator)) { + http.NotFound(w, r) + return + } + + data, err := os.ReadFile(clean) + if err != nil { + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Write(data) +} + +// isValidHash checks that a hash string is exactly 40 hex characters (SHA-1 length). +func isValidHash(hash string) bool { + if len(hash) != 40 { + return false + } + for _, c := range hash { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// StoreFile writes data to the CAS directory structure. +// Returns the SHA-1 hash of the stored data. +func StoreFile(casDir string, data []byte) (string, error) { + hash := sha1Hex(data) + destDir := filepath.Join(casDir, hash[:2]) + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", err + } + dest := filepath.Join(destDir, hash) + if err := os.WriteFile(dest, data, 0o644); err != nil { + return "", err + } + return hash, nil +} + +// FileExists checks if a file with the given SHA-1 hash exists in CAS. +func FileExists(casDir, hash string) bool { + path := filepath.Join(casDir, hash[:2], hash) + _, err := os.Stat(path) + return err == nil +} + +// VerifyAndStore writes data to CAS only if the SHA-1 matches the expected hash. +// This prevents corrupt or tampered uploads from being stored. +func VerifyAndStore(casDir string, data []byte, expectedHash string) (string, error) { + got := sha1Hex(data) + if subtle.ConstantTimeCompare([]byte(got), []byte(expectedHash)) != 1 { + return "", ErrHashMismatch + } + if FileExists(casDir, expectedHash) { + return expectedHash, nil // Already stored; idempotent. + } + return StoreFile(casDir, data) +} + +var errHashMismatch = &hashMismatchError{} + +// ErrHashMismatch is returned when computed hash doesn't match expected. +var ErrHashMismatch = errHashMismatch + +type hashMismatchError struct{} + +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) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 57f4d00..cd431c5 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -1,2 +1,159 @@ -// package middleware provides HTTP middleware (JWT, CORS, rate limiting, logging). +// package middleware provides HTTP middleware (CORS, logging, rate limiting). package middleware + +import ( + "log" + "net/http" + "strings" + "sync" + "time" +) + +// CORS adds permissive CORS headers for API endpoints. +// In production, restrict AllowOrigins to your actual domains. +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CI-Token") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// Logging logs each request with method, path, status code, and duration. +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(ww, r) + log.Printf("%s %s %d %s %s", + r.Method, r.RequestURI, ww.status, + time.Since(start), r.RemoteAddr, + ) + }) +} + +// statusWriter wraps http.ResponseWriter to capture the status code. +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +// RateLimiter implements a simple per-IP token bucket rate limiter. +// Not suitable for production behind a proxy (use a real rate limiter then), +// but sufficient for development and single-instance deployments. +type RateLimiter struct { + mu sync.Mutex + clients map[string]*bucket + rate int // tokens per interval + interval time.Duration + burst int // max bucket size +} + +type bucket struct { + tokens int + last time.Time +} + +// NewRateLimiter creates a rate limiter allowing `rate` requests per `interval`, +// with a maximum burst of `burst`. +func NewRateLimiter(rate int, interval time.Duration, burst int) *RateLimiter { + rl := &RateLimiter{ + clients: make(map[string]*bucket), + rate: rate, + interval: interval, + burst: burst, + } + // Periodically clean up stale entries. + go rl.cleanup() + return rl +} + +// Limit returns an HTTP middleware that rate-limits requests by client IP. +func (rl *RateLimiter) Limit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !rl.allow(ip) { + w.Header().Set("Retry-After", "60") + http.Error(w, `{"error":"Rate limit exceeded"}`, http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +func (rl *RateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + b, ok := rl.clients[ip] + if !ok { + rl.clients[ip] = &bucket{tokens: rl.burst - 1, last: time.Now()} + return true + } + + // Refill tokens based on elapsed time. + elapsed := time.Since(b.last) + refill := int(elapsed / rl.interval * time.Duration(rl.rate)) + if refill > 0 { + b.tokens = min(b.tokens+refill, rl.burst) + b.last = time.Now() + } + + if b.tokens > 0 { + b.tokens-- + return true + } + return false +} + +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + rl.mu.Lock() + now := time.Now() + for ip, b := range rl.clients { + if now.Sub(b.last) > 10*time.Minute { + delete(rl.clients, ip) + } + } + rl.mu.Unlock() + } +} + +func clientIP(r *http.Request) string { + // Check X-Forwarded-For first (if behind a proxy). + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if idx := strings.IndexByte(xff, ','); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + return strings.TrimSpace(xff) + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + // Fall back to RemoteAddr (strip port). + host, _, ok := strings.Cut(r.RemoteAddr, ":") + if !ok { + return r.RemoteAddr + } + return host +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 443604c..b77101a 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -1,2 +1,96 @@ // package templates handles Go html/template rendering for the site and admin panel. +// +// This is a placeholder implementation. Actual templates will be added +// when the web UI is designed. package templates + +import ( + "html/template" + "log" + "net/http" + "os" + "path/filepath" + + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" + "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" +) + +// Handler serves template-rendered pages. +type Handler struct { + db *database.DB + cfg *config.Config + layout *template.Template +} + +// NewHandler creates a new templates handler and parses embedded/ondisk templates. +func NewHandler(db *database.DB, cfg *config.Config) *Handler { + h := &Handler{db: db, cfg: cfg} + h.parseTemplates() + return h +} + +// RegisterRoutes mounts template-rendered pages. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /", h.index) + mux.HandleFunc("GET /login", h.loginPage) + mux.HandleFunc("GET /register", h.registerPage) +} + +// ── Page handlers ────────────────────────────────────────────── + +func (h *Handler) index(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + if h.layout == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("

MrixsCraft

")) + return + } + h.layout.ExecuteTemplate(w, "index", nil) +} + +func (h *Handler) loginPage(w http.ResponseWriter, r *http.Request) { + if h.layout == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("

Login

")) + return + } + h.layout.ExecuteTemplate(w, "login", nil) +} + +func (h *Handler) registerPage(w http.ResponseWriter, r *http.Request) { + if h.layout == nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("

Register

")) + return + } + h.layout.ExecuteTemplate(w, "register", nil) +} + +// ── Template parsing ─────────────────────────────────────────── + +func (h *Handler) parseTemplates() { + // Look for templates in common locations. + dirs := []string{ + filepath.Join("internal", "templates", "html"), + "templates", + } + for _, dir := range dirs { + if _, err := os.Stat(dir); err != nil { + continue + } + tmpl, err := template.ParseGlob(filepath.Join(dir, "*.html")) + if err == nil && tmpl != nil { + h.layout = tmpl + log.Printf("Loaded templates from %s", dir) + return + } + if err != nil { + log.Printf("Template parse error in %s: %v", dir, err) + } + } + // No templates found — handler will use placeholder responses. + log.Println("No HTML templates found; using placeholder responses") +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e9c09c6..7df8734 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -1,2 +1,89 @@ -// package utils provides shared utility functions (SHA-1, ZIP, etc.). +// package utils provides shared utility functions (SHA-1, SHA-256, ZIP, etc.). package utils + +import ( + "bytes" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + + "archive/zip" +) + +// SHA1Bytes returns the SHA-1 hex string of the given data. +func SHA1Bytes(data []byte) string { + h := sha1.Sum(data) + return hex.EncodeToString(h[:]) +} + +// SHA256Bytes returns the SHA-256 hex string of the given data. +func SHA256Bytes(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +// SHA1File computes the SHA-1 hash of a file at the given path. +func SHA1File(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha1.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// Unzip extracts a ZIP archive to the destination directory. +// Returns the list of extracted file paths. +// Protects against zip-slip by validating that each entry's target path +// stays within the destination directory. +func Unzip(data []byte, dest string) ([]string, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + + var extracted []string + for _, f := range reader.File { + if f.FileInfo().IsDir() { + continue + } + + target := filepath.Join(dest, f.Name) + + // Zip-slip protection. + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { + continue + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + continue + } + + rc, err := f.Open() + if err != nil { + continue + } + + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + continue + } + + io.Copy(out, rc) + out.Close() + rc.Close() + + extracted = append(extracted, f.Name) + } + return extracted, nil +}