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