You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
200 lines
4.8 KiB
200 lines
4.8 KiB
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 |
|
} |
|
|
|
|