// 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/subtle" "net/http" "os" "path/filepath" "strings" "sync" "gitea.mrixs.me/Mrixs/MrixsCraft-server/pkg/utils" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/config" "gitea.mrixs.me/Mrixs/MrixsCraft-server/internal/database" ) // hashLocks provides per-hash mutexes to prevent concurrent writes // to the same CAS entry. Protected by mu. var ( hashLocks = make(map[string]*sync.Mutex) hashLocksMu sync.Mutex ) // acquireLock returns (and creates if needed) the mutex for a given hash // and locks it. Caller MUST call releaseLock for the same hash. func acquireLock(hash string) { hashLocksMu.Lock() mu, ok := hashLocks[hash] if !ok { mu = &sync.Mutex{} hashLocks[hash] = mu } hashLocksMu.Unlock() mu.Lock() } // releaseLock unlocks the per-hash mutex. Must be called after acquireLock // to avoid deadlocks. func releaseLock(hash string) { hashLocksMu.Lock() mu, ok := hashLocks[hash] hashLocksMu.Unlock() if ok { mu.Unlock() } } // mimeByExtension maps common file extensions to MIME types for CAS serving. var mimeByExtension = map[string]string{ ".jar": "application/java-archive", ".json": "application/json", ".png": "image/png", ".zip": "application/zip", ".toml": "application/toml", ".cfg": "text/plain", ".conf": "text/plain", ".txt": "text/plain", ".log": "text/plain", ".xml": "application/xml", ".yml": "application/x-yaml", ".yaml": "application/x-yaml", ".properties": "text/plain", } // detectContentType returns a MIME type based on the file's extension. // Falls back to application/octet-stream for unknown types. func detectContentType(fileName string) string { ext := strings.ToLower(filepath.Ext(fileName)) if mime, ok := mimeByExtension[ext]; ok { return mime } return "application/octet-stream" } // 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 — served from /files/launcher/{version}/{os}/{arch}/{filename}. 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). // Content-Type is detected from the original file name stored in global_files. 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 } // Look up the original file name for Content-Type detection. var fileName string err = h.db.Pool().QueryRow(r.Context(), `SELECT file_name FROM global_files WHERE sha1 = $1`, hash, ).Scan(&fileName) if err != nil { fileName = hash // fallback: no extension info } w.Header().Set("Content-Type", detectContentType(fileName)) 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("Content-Type", detectContentType(filename)) 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. // Uses a per-hash mutex to prevent concurrent writes of the same entry. func StoreFile(casDir string, data []byte) (string, error) { hash := utils.SHA1Bytes(data) acquireLock(hash) defer releaseLock(hash) if FileExists(casDir, hash) { return hash, nil // Already stored by a concurrent caller. } 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 := utils.SHA1Bytes(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" }