enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

368 lines
9.6 KiB
Go

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