server.go raw

   1  package blossom
   2  
   3  import (
   4  	"net/http"
   5  	"strings"
   6  
   7  	"next.orly.dev/pkg/acl"
   8  	"next.orly.dev/pkg/database"
   9  )
  10  
  11  // Server provides a Blossom server implementation
  12  type Server struct {
  13  	db      database.Database
  14  	storage *Storage
  15  	acl     *acl.S
  16  	baseURL string
  17  
  18  	// Configuration
  19  	maxBlobSize            int64
  20  	allowedMimeTypes       map[string]bool
  21  	requireAuth            bool
  22  	deleteRequireServerTag bool
  23  
  24  	// Rate limiting for uploads
  25  	bandwidthLimiter *BandwidthLimiter
  26  }
  27  
  28  // Config holds configuration for the Blossom server
  29  type Config struct {
  30  	BaseURL          string
  31  	MaxBlobSize      int64
  32  	AllowedMimeTypes []string
  33  	RequireAuth      bool
  34  
  35  	// Rate limiting (for non-followed users)
  36  	RateLimitEnabled bool
  37  	DailyLimitMB     int64
  38  	BurstLimitMB     int64
  39  
  40  	// Delete replay protection (proposed BUD enhancement)
  41  	// When true, DELETE auth events must include a 'server' tag matching this server
  42  	DeleteRequireServerTag bool
  43  }
  44  
  45  // NewServer creates a new Blossom server instance
  46  func NewServer(db database.Database, aclRegistry *acl.S, cfg *Config) *Server {
  47  	if cfg == nil {
  48  		cfg = &Config{
  49  			MaxBlobSize: 100 * 1024 * 1024, // 100MB default
  50  			RequireAuth: false,
  51  		}
  52  	}
  53  
  54  	storage := NewStorage(db)
  55  
  56  	// Build allowed MIME types map
  57  	allowedMap := make(map[string]bool)
  58  	if len(cfg.AllowedMimeTypes) > 0 {
  59  		for _, mime := range cfg.AllowedMimeTypes {
  60  			allowedMap[mime] = true
  61  		}
  62  	}
  63  
  64  	// Initialize bandwidth limiter if enabled
  65  	var bwLimiter *BandwidthLimiter
  66  	if cfg.RateLimitEnabled {
  67  		dailyMB := cfg.DailyLimitMB
  68  		if dailyMB <= 0 {
  69  			dailyMB = 10 // 10MB default
  70  		}
  71  		burstMB := cfg.BurstLimitMB
  72  		if burstMB <= 0 {
  73  			burstMB = 50 // 50MB default burst
  74  		}
  75  		bwLimiter = NewBandwidthLimiter(dailyMB, burstMB)
  76  	}
  77  
  78  	return &Server{
  79  		db:                     db,
  80  		storage:                storage,
  81  		acl:                    aclRegistry,
  82  		baseURL:                cfg.BaseURL,
  83  		maxBlobSize:            cfg.MaxBlobSize,
  84  		allowedMimeTypes:       allowedMap,
  85  		requireAuth:            cfg.RequireAuth,
  86  		deleteRequireServerTag: cfg.DeleteRequireServerTag,
  87  		bandwidthLimiter:       bwLimiter,
  88  	}
  89  }
  90  
  91  // Handler returns an http.Handler that can be attached to a router
  92  func (s *Server) Handler() http.Handler {
  93  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  94  		// Set CORS headers (BUD-01 requirement)
  95  		s.setCORSHeaders(w, r)
  96  
  97  		// Handle preflight OPTIONS requests
  98  		if r.Method == http.MethodOptions {
  99  			w.WriteHeader(http.StatusOK)
 100  			return
 101  		}
 102  
 103  		// Route based on path and method
 104  		path := r.URL.Path
 105  
 106  		// Remove leading slash
 107  		path = strings.TrimPrefix(path, "/")
 108  
 109  		// Handle specific endpoints
 110  		switch {
 111  		case r.Method == http.MethodGet && path == "upload":
 112  			// This shouldn't happen, but handle gracefully
 113  			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 114  			return
 115  
 116  		case r.Method == http.MethodHead && path == "upload":
 117  			s.handleUploadRequirements(w, r)
 118  			return
 119  
 120  		case r.Method == http.MethodPut && path == "upload":
 121  			s.handleUpload(w, r)
 122  			return
 123  
 124  		case r.Method == http.MethodHead && path == "media":
 125  			s.handleMediaHead(w, r)
 126  			return
 127  
 128  		case r.Method == http.MethodPut && path == "media":
 129  			s.handleMediaUpload(w, r)
 130  			return
 131  
 132  		case r.Method == http.MethodPut && path == "mirror":
 133  			s.handleMirror(w, r)
 134  			return
 135  
 136  		case r.Method == http.MethodPut && path == "report":
 137  			s.handleReport(w, r)
 138  			return
 139  
 140  		case path == "admin/users":
 141  			if r.Method == http.MethodGet {
 142  				s.handleAdminListUsers(w, r)
 143  				return
 144  			}
 145  			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 146  			return
 147  
 148  		case path == "admin/generate-thumbnails":
 149  			if r.Method == http.MethodPost {
 150  				s.handleGenerateThumbnails(w, r)
 151  				return
 152  			}
 153  			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 154  			return
 155  
 156  		case strings.HasPrefix(path, "list/"):
 157  			if r.Method == http.MethodGet {
 158  				s.handleListBlobs(w, r)
 159  				return
 160  			}
 161  			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 162  			return
 163  
 164  		case r.Method == http.MethodGet:
 165  			// Handle GET /<sha256>
 166  			s.handleGetBlob(w, r)
 167  			return
 168  
 169  		case r.Method == http.MethodHead:
 170  			// Handle HEAD /<sha256>
 171  			s.handleHeadBlob(w, r)
 172  			return
 173  
 174  		case r.Method == http.MethodDelete:
 175  			// Handle DELETE /<sha256>
 176  			s.handleDeleteBlob(w, r)
 177  			return
 178  
 179  		default:
 180  			http.Error(w, "Not found", http.StatusNotFound)
 181  			return
 182  		}
 183  	})
 184  }
 185  
 186  // setCORSHeaders sets CORS headers as required by BUD-01
 187  func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) {
 188  	w.Header().Set("Access-Control-Allow-Origin", "*")
 189  	w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE, OPTIONS")
 190  	// Include all headers used by Blossom clients (BUD-01, BUD-06)
 191  	// Include both cases for maximum compatibility with various clients
 192  	w.Header().Set("Access-Control-Allow-Headers", "Authorization, authorization, Content-Type, content-type, X-SHA-256, x-sha-256, X-Content-Length, x-content-length, X-Content-Type, x-content-type, Accept, accept")
 193  	w.Header().Set("Access-Control-Expose-Headers", "X-Reason, Content-Length, Content-Type, Accept-Ranges")
 194  	w.Header().Set("Access-Control-Max-Age", "86400")
 195  	w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
 196  }
 197  
 198  // setErrorResponse sets an error response with X-Reason header (BUD-01)
 199  func (s *Server) setErrorResponse(w http.ResponseWriter, status int, reason string) {
 200  	w.Header().Set("X-Reason", reason)
 201  	http.Error(w, reason, status)
 202  }
 203  
 204  // getRemoteAddr extracts the remote address from the request
 205  func (s *Server) getRemoteAddr(r *http.Request) string {
 206  	// Check X-Forwarded-For header
 207  	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
 208  		parts := strings.Split(forwarded, ",")
 209  		if len(parts) > 0 {
 210  			return strings.TrimSpace(parts[0])
 211  		}
 212  	}
 213  
 214  	// Check X-Real-IP header
 215  	if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
 216  		return realIP
 217  	}
 218  
 219  	// Fall back to RemoteAddr
 220  	return r.RemoteAddr
 221  }
 222  
 223  // checkACL checks if the user has the required access level
 224  func (s *Server) checkACL(
 225  	pubkey []byte, remoteAddr string, requiredLevel string,
 226  ) bool {
 227  	if s.acl == nil {
 228  		return true // No ACL configured, allow all
 229  	}
 230  
 231  	level := s.acl.GetAccessLevel(pubkey, remoteAddr)
 232  
 233  	// Map ACL levels to permissions
 234  	levelMap := map[string]int{
 235  		"none":  0,
 236  		"read":  1,
 237  		"write": 2,
 238  		"admin": 3,
 239  		"owner": 4,
 240  	}
 241  
 242  	required := levelMap[requiredLevel]
 243  	actual := levelMap[level]
 244  
 245  	return actual >= required
 246  }
 247  
 248  // isRateLimitExempt returns true if the user is exempt from rate limiting.
 249  // Users with write access or higher (followed users, admins, owners) are exempt.
 250  func (s *Server) isRateLimitExempt(pubkey []byte, remoteAddr string) bool {
 251  	if s.acl == nil {
 252  		return true // No ACL configured, no rate limiting
 253  	}
 254  
 255  	level := s.acl.GetAccessLevel(pubkey, remoteAddr)
 256  
 257  	// Followed users get "write" level, admins/owners get higher
 258  	// Only "read" and "none" are rate limited
 259  	return level == "write" || level == "admin" || level == "owner"
 260  }
 261  
 262  // checkBandwidthLimit checks if the upload is allowed under rate limits.
 263  // Returns true if allowed, false if rate limited.
 264  // Exempt users (followed, admin, owner) always return true.
 265  func (s *Server) checkBandwidthLimit(pubkey []byte, remoteAddr string, sizeBytes int64) bool {
 266  	if s.bandwidthLimiter == nil {
 267  		return true // No rate limiting configured
 268  	}
 269  
 270  	// Check if user is exempt
 271  	if s.isRateLimitExempt(pubkey, remoteAddr) {
 272  		return true
 273  	}
 274  
 275  	// Use pubkey hex if available, otherwise IP
 276  	var identity string
 277  	if len(pubkey) > 0 {
 278  		identity = string(pubkey) // Will be converted to hex in handler
 279  	} else {
 280  		identity = remoteAddr
 281  	}
 282  
 283  	return s.bandwidthLimiter.CheckAndConsume(identity, sizeBytes)
 284  }
 285  
 286  // BaseURLKey is the context key for the base URL (exported for use by app handler)
 287  type BaseURLKey struct{}
 288  
 289  // getBaseURL returns the base URL, preferring request context if available
 290  func (s *Server) getBaseURL(r *http.Request) string {
 291  	if baseURL := r.Context().Value(BaseURLKey{}); baseURL != nil {
 292  		if url, ok := baseURL.(string); ok && url != "" {
 293  			return url
 294  		}
 295  	}
 296  	return s.baseURL
 297  }
 298