package blossom import ( "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "git.sovbit.dev/enki/torrentGateway/internal/config" "git.sovbit.dev/enki/torrentGateway/internal/proxy" "git.sovbit.dev/enki/torrentGateway/internal/storage" "golang.org/x/time/rate" ) // Server implements a Blossom server type Server struct { storage *storage.Backend config *config.BlossomServerConfig rateLimiter *rate.Limiter mux *http.ServeMux smartProxy *proxy.SmartProxy fullConfig *config.Config } // BlobUploadResponse represents the response for blob uploads type BlobUploadResponse struct { Hash string `json:"hash"` Size int64 `json:"size"` Type string `json:"type"` Timestamp time.Time `json:"timestamp"` Message string `json:"message,omitempty"` } // ErrorResponse represents an error response type ErrorResponse struct { Error string `json:"error"` Code int `json:"code"` Message string `json:"message"` } // NewServer creates a new Blossom server func NewServer(storage *storage.Backend, config *config.BlossomServerConfig, fullConfig *config.Config) *Server { // Create rate limiter limiter := rate.NewLimiter( rate.Limit(config.RateLimit.RequestsPerMinute)/60, // requests per second config.RateLimit.BurstSize, ) var smartProxy *proxy.SmartProxy if fullConfig.Proxy.Enabled { smartProxy = proxy.NewSmartProxy(storage, fullConfig) } server := &Server{ storage: storage, config: config, rateLimiter: limiter, mux: http.NewServeMux(), smartProxy: smartProxy, fullConfig: fullConfig, } server.setupRoutes() return server } // setupRoutes configures the HTTP routes func (s *Server) setupRoutes() { // Blob download endpoint: GET /{hash} s.mux.HandleFunc("/", s.handleBlobRequest) // Upload endpoint: PUT /upload s.mux.HandleFunc("/upload", s.handleUpload) // Server info endpoint s.mux.HandleFunc("/info", s.handleInfo) // Health check s.mux.HandleFunc("/health", s.handleHealth) } // ServeHTTP implements http.Handler func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Apply rate limiting if !s.rateLimiter.Allow() { s.writeError(w, http.StatusTooManyRequests, "rate limit exceeded") return } // Add CORS headers for web clients w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } s.mux.ServeHTTP(w, r) } // handleBlobRequest handles GET requests for blobs func (s *Server) handleBlobRequest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { if r.URL.Path == "/" { s.handleRoot(w, r) return } s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } // Extract hash from path path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { s.handleRoot(w, r) return } // Validate hash format (should be 64 character hex) if len(path) != 64 || !isValidHash(path) { s.writeError(w, http.StatusBadRequest, "invalid hash format") return } // Get blob from storage reader, info, err := s.storage.GetBlobData(path) if err != nil { log.Printf("Error retrieving blob %s: %v", path, err) s.writeError(w, http.StatusInternalServerError, "internal server error") return } if reader == nil { // Try smart proxy if enabled and configured if s.smartProxy != nil && s.fullConfig.Proxy.Enabled { log.Printf("Blob %s not found in storage, trying smart proxy for chunked file", path) if err := s.smartProxy.ServeBlob(w, path); err != nil { log.Printf("Smart proxy failed for hash %s: %v", path, err) s.writeError(w, http.StatusNotFound, "blob not found") return } log.Printf("Successfully served chunked file via smart proxy: %s", path) return } s.writeError(w, http.StatusNotFound, "blob not found") return } defer reader.Close() // Set appropriate headers if info.MimeType != "" { w.Header().Set("Content-Type", info.MimeType) } else { w.Header().Set("Content-Type", "application/octet-stream") } w.Header().Set("Content-Length", strconv.FormatInt(info.Size, 10)) w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year w.Header().Set("ETag", fmt.Sprintf(`"%s"`, path)) // Check for conditional requests if match := r.Header.Get("If-None-Match"); match != "" { if strings.Contains(match, path) { w.WriteHeader(http.StatusNotModified) return } } // Stream the blob if _, err := io.Copy(w, reader); err != nil { log.Printf("Error streaming blob %s: %v", path, err) return } } // handleUpload handles PUT requests for blob uploads func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut && r.Method != http.MethodPost { s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } // Check content length contentLength := r.ContentLength if contentLength <= 0 { s.writeError(w, http.StatusBadRequest, "content-length required") return } // Check max blob size maxSize, err := parseSize(s.config.MaxBlobSize) if err != nil { log.Printf("Error parsing max blob size: %v", err) maxSize = 100 * 1024 * 1024 // Default to 100MB if config invalid } if contentLength > maxSize { s.writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("blob too large (max %d bytes)", maxSize)) return } // Determine content type contentType := r.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } // Create a limited reader to prevent DoS limitedReader := io.LimitReader(r.Body, maxSize+1) // Store the blob using unified storage metadata, err := s.storage.StoreBlobAsFile(limitedReader, "blob", contentType) if err != nil { log.Printf("Error storing blob: %v", err) s.writeError(w, http.StatusInternalServerError, "failed to store blob") return } hash := metadata.Hash // Return success response response := BlobUploadResponse{ Hash: hash, Size: contentLength, Type: contentType, Timestamp: time.Now(), Message: "blob stored successfully", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(response) } // handleInfo provides server information func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } maxSize, _ := parseSize(s.config.MaxBlobSize) info := map[string]interface{}{ "server": "Blossom-BitTorrent Gateway", "version": "1.0.0", "blossom_spec": "draft-01", "max_blob_size": maxSize, "supported_types": []string{"*/*"}, "features": []string{ "upload", "download", "rate_limiting", "caching", }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(info) } // handleHealth provides health check func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } health := map[string]interface{}{ "status": "ok", "timestamp": time.Now(), "service": "blossom-server", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(health) } // handleRoot handles requests to the root path func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { s.writeError(w, http.StatusMethodNotAllowed, "method not allowed") return } info := map[string]interface{}{ "service": "Blossom Server", "message": "This is a Blossom blob storage server. Use GET /{hash} to retrieve blobs or PUT /upload to store new blobs.", "endpoints": map[string]string{ "upload": "PUT /upload - Upload a new blob", "download": "GET /{hash} - Download a blob by hash", "info": "GET /info - Server information", "health": "GET /health - Health check", }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(info) } // writeError writes a JSON error response func (s *Server) writeError(w http.ResponseWriter, code int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) response := ErrorResponse{ Error: http.StatusText(code), Code: code, Message: message, } json.NewEncoder(w).Encode(response) } // isValidHash checks if a string is a valid SHA-256 hash func isValidHash(hash string) bool { if len(hash) != 64 { return false } for _, c := range hash { if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { return false } } return true } // parseSize parses size strings like "100MB", "2GB", etc. func parseSize(sizeStr string) (int64, error) { if sizeStr == "" { return 100 * 1024 * 1024, nil // Default 100MB if not configured } var size int64 var unit string n, err := fmt.Sscanf(sizeStr, "%d%s", &size, &unit) if err != nil || n != 2 { return 0, fmt.Errorf("invalid size format: %s", sizeStr) } switch strings.ToUpper(unit) { case "B": return size, nil case "KB", "K": return size * 1024, nil case "MB", "M": return size * 1024 * 1024, nil case "GB", "G": return size * 1024 * 1024 * 1024, nil default: return 0, fmt.Errorf("unknown unit: %s", unit) } } // Start starts the Blossom server func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.config.Port) log.Printf("Starting Blossom server on port %d", s.config.Port) return http.ListenAndServe(addr, s) }