Files
next.orly.dev/pkg/blossom/server.go
mleku edcdec9c7e Add Blossom blob storage server and subscription management
- Introduced the `initializeBlossomServer` function to set up the Blossom blob storage server with dynamic base URL handling and ACL configuration.
- Implemented the `blossomHandler` method to manage incoming requests to the Blossom API, ensuring proper URL handling and context management.
- Enhanced the `PaymentProcessor` to support Blossom service levels, allowing for subscription extensions based on payment metadata.
- Added methods for parsing and validating Blossom service levels, including storage quota management and subscription extension logic.
- Updated the configuration to include Blossom service level settings, facilitating dynamic service level management.
- Integrated storage quota checks in the blob upload process to prevent exceeding allocated limits.
- Refactored existing code to improve organization and maintainability, including the removal of unused blob directory configurations.
- Added tests to ensure the robustness of new functionalities and maintain existing behavior across blob operations.
2025-11-02 22:23:01 +00:00

211 lines
5.1 KiB
Go

package blossom
import (
"net/http"
"strings"
"next.orly.dev/pkg/acl"
"next.orly.dev/pkg/database"
)
// Server provides a Blossom server implementation
type Server struct {
db *database.D
storage *Storage
acl *acl.S
baseURL string
// Configuration
maxBlobSize int64
allowedMimeTypes map[string]bool
requireAuth bool
}
// Config holds configuration for the Blossom server
type Config struct {
BaseURL string
MaxBlobSize int64
AllowedMimeTypes []string
RequireAuth bool
}
// NewServer creates a new Blossom server instance
func NewServer(db *database.D, aclRegistry *acl.S, cfg *Config) *Server {
if cfg == nil {
cfg = &Config{
MaxBlobSize: 100 * 1024 * 1024, // 100MB default
RequireAuth: false,
}
}
storage := NewStorage(db)
// Build allowed MIME types map
allowedMap := make(map[string]bool)
if len(cfg.AllowedMimeTypes) > 0 {
for _, mime := range cfg.AllowedMimeTypes {
allowedMap[mime] = true
}
}
return &Server{
db: db,
storage: storage,
acl: aclRegistry,
baseURL: cfg.BaseURL,
maxBlobSize: cfg.MaxBlobSize,
allowedMimeTypes: allowedMap,
requireAuth: cfg.RequireAuth,
}
}
// Handler returns an http.Handler that can be attached to a router
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Set CORS headers (BUD-01 requirement)
s.setCORSHeaders(w, r)
// Handle preflight OPTIONS requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
// Route based on path and method
path := r.URL.Path
// Remove leading slash
path = strings.TrimPrefix(path, "/")
// Handle specific endpoints
switch {
case r.Method == http.MethodGet && path == "upload":
// This shouldn't happen, but handle gracefully
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case r.Method == http.MethodHead && path == "upload":
s.handleUploadRequirements(w, r)
return
case r.Method == http.MethodPut && path == "upload":
s.handleUpload(w, r)
return
case r.Method == http.MethodHead && path == "media":
s.handleMediaHead(w, r)
return
case r.Method == http.MethodPut && path == "media":
s.handleMediaUpload(w, r)
return
case r.Method == http.MethodPut && path == "mirror":
s.handleMirror(w, r)
return
case r.Method == http.MethodPut && path == "report":
s.handleReport(w, r)
return
case strings.HasPrefix(path, "list/"):
if r.Method == http.MethodGet {
s.handleListBlobs(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case r.Method == http.MethodGet:
// Handle GET /<sha256>
s.handleGetBlob(w, r)
return
case r.Method == http.MethodHead:
// Handle HEAD /<sha256>
s.handleHeadBlob(w, r)
return
case r.Method == http.MethodDelete:
// Handle DELETE /<sha256>
s.handleDeleteBlob(w, r)
return
default:
http.Error(w, "Not found", http.StatusNotFound)
return
}
})
}
// setCORSHeaders sets CORS headers as required by BUD-01
func (s *Server) setCORSHeaders(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, *")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
}
// setErrorResponse sets an error response with X-Reason header (BUD-01)
func (s *Server) setErrorResponse(w http.ResponseWriter, status int, reason string) {
w.Header().Set("X-Reason", reason)
http.Error(w, reason, status)
}
// getRemoteAddr extracts the remote address from the request
func (s *Server) getRemoteAddr(r *http.Request) string {
// Check X-Forwarded-For header
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
return strings.TrimSpace(parts[0])
}
}
// Check X-Real-IP header
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}
// Fall back to RemoteAddr
return r.RemoteAddr
}
// checkACL checks if the user has the required access level
func (s *Server) checkACL(
pubkey []byte, remoteAddr string, requiredLevel string,
) bool {
if s.acl == nil {
return true // No ACL configured, allow all
}
level := s.acl.GetAccessLevel(pubkey, remoteAddr)
// Map ACL levels to permissions
levelMap := map[string]int{
"none": 0,
"read": 1,
"write": 2,
"admin": 3,
"owner": 4,
}
required := levelMap[requiredLevel]
actual := levelMap[level]
return actual >= required
}
// getBaseURL returns the base URL, preferring request context if available
func (s *Server) getBaseURL(r *http.Request) string {
type baseURLKey struct{}
if baseURL := r.Context().Value(baseURLKey{}); baseURL != nil {
if url, ok := baseURL.(string); ok && url != "" {
return url
}
}
return s.baseURL
}