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