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

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