feat: implement CAS module, middleware, utils, and templates

- CAS: GET /files/{hash} with immutable cache headers, launcher asset
  serving, hash validation, StoreFile/VerifyAndStore helpers
- Middleware: CORS, request logging, per-IP token bucket rate limiter
- Utils: SHA1Bytes, SHA256Bytes, SHA1File, Unzip with zip-slip protection
- Templates: placeholder handler with html/template discovery
- Wire CAS routes and middleware chain (Logging → CORS) in main.go
This commit is contained in:
2026-05-26 15:11:41 +03:00
parent 2f07fbf379
commit e4fea937aa
5 changed files with 517 additions and 6 deletions

View File

@@ -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.

View File

@@ -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 <cas_dir>/<first_2_chars>/<full_hash>.
// 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)
}

View File

@@ -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
}

View File

@@ -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("<h1>MrixsCraft</h1>"))
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("<h1>Login</h1>"))
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("<h1>Register</h1>"))
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")
}

View File

@@ -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
}