blumenjs 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,546 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "log"
6
+ "net/http"
7
+ "strings"
8
+ "sync"
9
+ "time"
10
+ )
11
+
12
+ // ─── Middleware Engine ─────────────────────────────────────────
13
+ // Go-native middleware chain that wraps the HTTP mux.
14
+ // Each middleware runs in the request's goroutine — zero overhead,
15
+ // full access to Go ecosystem (databases, Redis, gRPC, etc.)
16
+
17
+ // MiddlewareFunc is the signature for all Blumen middleware.
18
+ // Call next(w, r) to pass control to the next middleware or the final handler.
19
+ type MiddlewareFunc func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc)
20
+
21
+ // middlewareEntry pairs a middleware with an optional path pattern.
22
+ type middlewareEntry struct {
23
+ fn MiddlewareFunc
24
+ pattern string // Empty = global, otherwise glob pattern like "/api/*"
25
+ }
26
+
27
+ // MiddlewareChain holds an ordered list of middleware functions.
28
+ type MiddlewareChain struct {
29
+ entries []middlewareEntry
30
+ }
31
+
32
+ // NewMiddlewareChain creates an empty middleware chain.
33
+ func NewMiddlewareChain() *MiddlewareChain {
34
+ return &MiddlewareChain{}
35
+ }
36
+
37
+ // Use adds a global middleware that runs on every request.
38
+ func (c *MiddlewareChain) Use(fn MiddlewareFunc) {
39
+ c.entries = append(c.entries, middlewareEntry{fn: fn})
40
+ }
41
+
42
+ // UseOn adds a scoped middleware that only runs on matching paths.
43
+ // Patterns support:
44
+ // - Exact match: "/dashboard/settings"
45
+ // - Prefix match: "/api/*" matches "/api/users", "/api/users/123", etc.
46
+ // - Wildcard: "/*" matches everything
47
+ func (c *MiddlewareChain) UseOn(pattern string, fn MiddlewareFunc) {
48
+ c.entries = append(c.entries, middlewareEntry{fn: fn, pattern: pattern})
49
+ }
50
+
51
+ // Then wraps a final http.Handler with the middleware chain.
52
+ // Returns a new http.Handler that runs middleware → handler.
53
+ func (c *MiddlewareChain) Then(handler http.Handler) http.Handler {
54
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55
+ // Filter entries to only those matching this request path
56
+ matching := make([]MiddlewareFunc, 0, len(c.entries))
57
+ for _, entry := range c.entries {
58
+ if entry.pattern == "" || matchPattern(entry.pattern, r.URL.Path) {
59
+ matching = append(matching, entry.fn)
60
+ }
61
+ }
62
+
63
+ // Build the chain: last middleware calls the handler, each prior calls the next
64
+ final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65
+ handler.ServeHTTP(w, r)
66
+ })
67
+
68
+ chain := final
69
+ for i := len(matching) - 1; i >= 0; i-- {
70
+ fn := matching[i]
71
+ next := chain
72
+ chain = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73
+ fn(w, r, next)
74
+ })
75
+ }
76
+
77
+ chain.ServeHTTP(w, r)
78
+ })
79
+ }
80
+
81
+ // matchPattern checks if a URL path matches a middleware pattern.
82
+ func matchPattern(pattern, path string) bool {
83
+ // Exact match
84
+ if pattern == path {
85
+ return true
86
+ }
87
+
88
+ // Prefix wildcard: "/api/*" matches "/api/anything"
89
+ if strings.HasSuffix(pattern, "/*") {
90
+ prefix := strings.TrimSuffix(pattern, "/*")
91
+ return path == prefix || strings.HasPrefix(path, prefix+"/")
92
+ }
93
+
94
+ return false
95
+ }
96
+
97
+ // ─── Response Writer Wrapper ───────────────────────────────────
98
+ // Captures the status code for logging middleware.
99
+
100
+ type responseWriter struct {
101
+ http.ResponseWriter
102
+ statusCode int
103
+ written bool
104
+ }
105
+
106
+ func newResponseWriter(w http.ResponseWriter) *responseWriter {
107
+ return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
108
+ }
109
+
110
+ func (rw *responseWriter) WriteHeader(code int) {
111
+ if !rw.written {
112
+ rw.statusCode = code
113
+ rw.written = true
114
+ }
115
+ rw.ResponseWriter.WriteHeader(code)
116
+ }
117
+
118
+ func (rw *responseWriter) Write(b []byte) (int, error) {
119
+ if !rw.written {
120
+ rw.written = true
121
+ }
122
+ return rw.ResponseWriter.Write(b)
123
+ }
124
+
125
+ // ─── Built-in: Logger Middleware ───────────────────────────────
126
+ // Logs every request with method, path, status code, and duration.
127
+
128
+ func LoggerMiddleware() MiddlewareFunc {
129
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
130
+ start := time.Now()
131
+ rw := newResponseWriter(w)
132
+
133
+ next(rw, r)
134
+
135
+ duration := time.Since(start)
136
+ status := rw.statusCode
137
+
138
+ // Color-code the status
139
+ var statusColor string
140
+ switch {
141
+ case status >= 500:
142
+ statusColor = "\033[31m" // Red
143
+ case status >= 400:
144
+ statusColor = "\033[33m" // Yellow
145
+ case status >= 300:
146
+ statusColor = "\033[36m" // Cyan
147
+ default:
148
+ statusColor = "\033[32m" // Green
149
+ }
150
+ reset := "\033[0m"
151
+
152
+ log.Printf("%s %s %s%d%s %v",
153
+ r.Method,
154
+ r.URL.Path,
155
+ statusColor, status, reset,
156
+ duration,
157
+ )
158
+ }
159
+ }
160
+
161
+ // ─── Built-in: CORS Middleware ─────────────────────────────────
162
+ // Sets Cross-Origin Resource Sharing headers.
163
+
164
+ // CORSConfig configures the CORS middleware.
165
+ type CORSConfig struct {
166
+ AllowOrigins []string // Allowed origins. ["*"] = all.
167
+ AllowMethods []string // Allowed HTTP methods.
168
+ AllowHeaders []string // Allowed request headers.
169
+ ExposeHeaders []string // Headers the browser can access.
170
+ AllowCredentials bool // Whether to allow credentials (cookies).
171
+ MaxAge int // Preflight cache duration in seconds.
172
+ }
173
+
174
+ // DefaultCORSConfig returns a permissive CORS configuration for development.
175
+ func DefaultCORSConfig() CORSConfig {
176
+ return CORSConfig{
177
+ AllowOrigins: []string{"*"},
178
+ AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
179
+ AllowHeaders: []string{"Content-Type", "Authorization", "X-Blumen-Data"},
180
+ ExposeHeaders: []string{"X-Blumen-Cache"},
181
+ AllowCredentials: false,
182
+ MaxAge: 86400,
183
+ }
184
+ }
185
+
186
+ func CORSMiddleware(config CORSConfig) MiddlewareFunc {
187
+ allowOrigin := strings.Join(config.AllowOrigins, ", ")
188
+ allowMethods := strings.Join(config.AllowMethods, ", ")
189
+ allowHeaders := strings.Join(config.AllowHeaders, ", ")
190
+ exposeHeaders := strings.Join(config.ExposeHeaders, ", ")
191
+ maxAge := fmt.Sprintf("%d", config.MaxAge)
192
+
193
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
194
+ origin := r.Header.Get("Origin")
195
+
196
+ // Determine the Access-Control-Allow-Origin value
197
+ if allowOrigin == "*" {
198
+ w.Header().Set("Access-Control-Allow-Origin", "*")
199
+ } else if origin != "" {
200
+ for _, allowed := range config.AllowOrigins {
201
+ if allowed == origin {
202
+ w.Header().Set("Access-Control-Allow-Origin", origin)
203
+ break
204
+ }
205
+ }
206
+ }
207
+
208
+ w.Header().Set("Access-Control-Allow-Methods", allowMethods)
209
+ w.Header().Set("Access-Control-Allow-Headers", allowHeaders)
210
+ if exposeHeaders != "" {
211
+ w.Header().Set("Access-Control-Expose-Headers", exposeHeaders)
212
+ }
213
+ if config.AllowCredentials {
214
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
215
+ }
216
+
217
+ // Handle preflight requests
218
+ if r.Method == http.MethodOptions {
219
+ w.Header().Set("Access-Control-Max-Age", maxAge)
220
+ w.WriteHeader(http.StatusNoContent)
221
+ return
222
+ }
223
+
224
+ next(w, r)
225
+ }
226
+ }
227
+
228
+ // ─── Built-in: Rate Limit Middleware ───────────────────────────
229
+ // In-memory token bucket rate limiter per client IP.
230
+
231
+ type rateLimitEntry struct {
232
+ tokens float64
233
+ lastCheck time.Time
234
+ }
235
+
236
+ type rateLimiter struct {
237
+ mu sync.Mutex
238
+ entries map[string]*rateLimitEntry
239
+ limit float64 // Max tokens (requests) per window
240
+ window time.Duration // Refill window
241
+ }
242
+
243
+ func newRateLimiter(limit int, window time.Duration) *rateLimiter {
244
+ rl := &rateLimiter{
245
+ entries: make(map[string]*rateLimitEntry),
246
+ limit: float64(limit),
247
+ window: window,
248
+ }
249
+
250
+ // Cleanup expired entries every minute
251
+ go func() {
252
+ for {
253
+ time.Sleep(time.Minute)
254
+ rl.mu.Lock()
255
+ cutoff := time.Now().Add(-rl.window * 2)
256
+ for ip, entry := range rl.entries {
257
+ if entry.lastCheck.Before(cutoff) {
258
+ delete(rl.entries, ip)
259
+ }
260
+ }
261
+ rl.mu.Unlock()
262
+ }
263
+ }()
264
+
265
+ return rl
266
+ }
267
+
268
+ func (rl *rateLimiter) allow(ip string) bool {
269
+ rl.mu.Lock()
270
+ defer rl.mu.Unlock()
271
+
272
+ now := time.Now()
273
+ entry, exists := rl.entries[ip]
274
+ if !exists {
275
+ rl.entries[ip] = &rateLimitEntry{
276
+ tokens: rl.limit - 1,
277
+ lastCheck: now,
278
+ }
279
+ return true
280
+ }
281
+
282
+ // Refill tokens based on elapsed time
283
+ elapsed := now.Sub(entry.lastCheck)
284
+ rate := rl.limit / rl.window.Seconds()
285
+ entry.tokens += elapsed.Seconds() * rate
286
+ if entry.tokens > rl.limit {
287
+ entry.tokens = rl.limit
288
+ }
289
+ entry.lastCheck = now
290
+
291
+ if entry.tokens < 1 {
292
+ return false
293
+ }
294
+
295
+ entry.tokens--
296
+ return true
297
+ }
298
+
299
+ // RateLimitMiddleware creates a rate limiter that allows `limit` requests
300
+ // per `window` duration per client IP.
301
+ func RateLimitMiddleware(limit int, window time.Duration) MiddlewareFunc {
302
+ limiter := newRateLimiter(limit, window)
303
+
304
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
305
+ ip := r.RemoteAddr
306
+ // Strip port from IP
307
+ if idx := strings.LastIndex(ip, ":"); idx != -1 {
308
+ ip = ip[:idx]
309
+ }
310
+ // Check X-Forwarded-For for proxied requests
311
+ if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
312
+ ip = strings.TrimSpace(strings.Split(forwarded, ",")[0])
313
+ }
314
+
315
+ if !limiter.allow(ip) {
316
+ w.Header().Set("Retry-After", fmt.Sprintf("%.0f", window.Seconds()))
317
+ w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limit))
318
+ http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
319
+ return
320
+ }
321
+
322
+ next(w, r)
323
+ }
324
+ }
325
+
326
+ // ─── Built-in: Auth Middleware ─────────────────────────────────
327
+ // Checks for an auth token in cookies or the Authorization header.
328
+ // Rejects requests without a valid token.
329
+
330
+ // AuthConfig configures the auth middleware.
331
+ type AuthConfig struct {
332
+ CookieName string // Cookie name to check (e.g. "session")
333
+ HeaderName string // Header name to check (e.g. "Authorization")
334
+ RedirectURL string // Redirect URL for unauthorized HTML requests (empty = 401 JSON)
335
+ ValidateFunc func(token string) bool // Custom token validation function
336
+ }
337
+
338
+ func AuthMiddleware(config AuthConfig) MiddlewareFunc {
339
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
340
+ var token string
341
+
342
+ // Check cookie first
343
+ if config.CookieName != "" {
344
+ if cookie, err := r.Cookie(config.CookieName); err == nil {
345
+ token = cookie.Value
346
+ }
347
+ }
348
+
349
+ // Fall back to header
350
+ if token == "" && config.HeaderName != "" {
351
+ header := r.Header.Get(config.HeaderName)
352
+ // Strip "Bearer " prefix if present
353
+ if strings.HasPrefix(header, "Bearer ") {
354
+ token = strings.TrimPrefix(header, "Bearer ")
355
+ } else {
356
+ token = header
357
+ }
358
+ }
359
+
360
+ // No token found
361
+ if token == "" {
362
+ handleUnauthorized(w, r, config.RedirectURL)
363
+ return
364
+ }
365
+
366
+ // Validate the token
367
+ if config.ValidateFunc != nil && !config.ValidateFunc(token) {
368
+ handleUnauthorized(w, r, config.RedirectURL)
369
+ return
370
+ }
371
+
372
+ next(w, r)
373
+ }
374
+ }
375
+
376
+ func handleUnauthorized(w http.ResponseWriter, r *http.Request, redirectURL string) {
377
+ // For API requests, return 401 JSON
378
+ if strings.HasPrefix(r.URL.Path, "/api/") ||
379
+ r.Header.Get("Accept") == "application/json" ||
380
+ r.Header.Get("X-Blumen-Data") == "1" {
381
+ w.Header().Set("Content-Type", "application/json")
382
+ w.WriteHeader(http.StatusUnauthorized)
383
+ w.Write([]byte(`{"error":"Unauthorized","message":"Authentication required"}`))
384
+ return
385
+ }
386
+
387
+ // For page requests, redirect to login page
388
+ if redirectURL != "" {
389
+ http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
390
+ return
391
+ }
392
+
393
+ // Default: 401
394
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
395
+ }
396
+
397
+ // ─── Built-in: Security Headers Middleware ─────────────────────
398
+ // Injects secure HTTP headers on every response.
399
+ // Prevents clickjacking, MIME sniffing, XSS, and enforces HTTPS.
400
+
401
+ // SecurityConfig configures security headers.
402
+ type SecurityConfig struct {
403
+ // Content-Security-Policy header value.
404
+ // Empty string = use default policy.
405
+ CSP string
406
+
407
+ // Strict-Transport-Security max-age in seconds. 0 = disabled.
408
+ HSTSMaxAge int
409
+
410
+ // Whether to include subdomains in HSTS.
411
+ HSTSIncludeSubdomains bool
412
+
413
+ // X-Frame-Options: DENY, SAMEORIGIN, or empty to skip.
414
+ FrameOptions string
415
+
416
+ // Referrer-Policy value.
417
+ ReferrerPolicy string
418
+
419
+ // Permissions-Policy value.
420
+ PermissionsPolicy string
421
+ }
422
+
423
+ // DefaultSecurityConfig returns production-ready security header defaults.
424
+ func DefaultSecurityConfig() SecurityConfig {
425
+ return SecurityConfig{
426
+ CSP: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:;",
427
+ HSTSMaxAge: 31536000, // 1 year
428
+ HSTSIncludeSubdomains: true,
429
+ FrameOptions: "DENY",
430
+ ReferrerPolicy: "strict-origin-when-cross-origin",
431
+ PermissionsPolicy: "camera=(), microphone=(), geolocation=()",
432
+ }
433
+ }
434
+
435
+ func SecurityHeadersMiddleware(config SecurityConfig) MiddlewareFunc {
436
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
437
+ // Prevent MIME type sniffing
438
+ w.Header().Set("X-Content-Type-Options", "nosniff")
439
+
440
+ // XSS protection (legacy, but still useful for older browsers)
441
+ w.Header().Set("X-XSS-Protection", "1; mode=block")
442
+
443
+ // Clickjacking protection
444
+ if config.FrameOptions != "" {
445
+ w.Header().Set("X-Frame-Options", config.FrameOptions)
446
+ }
447
+
448
+ // Content Security Policy
449
+ if config.CSP != "" {
450
+ w.Header().Set("Content-Security-Policy", config.CSP)
451
+ }
452
+
453
+ // HSTS (only on HTTPS or behind a proxy)
454
+ if config.HSTSMaxAge > 0 {
455
+ hsts := fmt.Sprintf("max-age=%d", config.HSTSMaxAge)
456
+ if config.HSTSIncludeSubdomains {
457
+ hsts += "; includeSubDomains"
458
+ }
459
+ w.Header().Set("Strict-Transport-Security", hsts)
460
+ }
461
+
462
+ // Referrer policy
463
+ if config.ReferrerPolicy != "" {
464
+ w.Header().Set("Referrer-Policy", config.ReferrerPolicy)
465
+ }
466
+
467
+ // Permissions policy
468
+ if config.PermissionsPolicy != "" {
469
+ w.Header().Set("Permissions-Policy", config.PermissionsPolicy)
470
+ }
471
+
472
+ next(w, r)
473
+ }
474
+ }
475
+
476
+ // ─── Built-in: Locale Detection Middleware ─────────────────────
477
+ // Detects the user's preferred locale and injects it as a header
478
+ // for downstream handlers (SSR, static pages, etc.)
479
+
480
+ // LocaleConfig configures locale detection.
481
+ type LocaleConfig struct {
482
+ Locales []string // Supported locale codes (e.g. ["en", "fr", "de"])
483
+ DefaultLocale string // Default locale
484
+ }
485
+
486
+ func LocaleMiddleware(config LocaleConfig) MiddlewareFunc {
487
+ localeSet := make(map[string]bool)
488
+ for _, l := range config.Locales {
489
+ localeSet[l] = true
490
+ }
491
+
492
+ return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
493
+ locale := config.DefaultLocale
494
+
495
+ // 1. Check cookie first
496
+ if cookie, err := r.Cookie("blumen_locale"); err == nil {
497
+ if localeSet[cookie.Value] {
498
+ locale = cookie.Value
499
+ }
500
+ }
501
+
502
+ // 2. Check Accept-Language header
503
+ if locale == config.DefaultLocale {
504
+ if accept := r.Header.Get("Accept-Language"); accept != "" {
505
+ detected := parseAcceptLanguage(accept, config.Locales)
506
+ if detected != "" {
507
+ locale = detected
508
+ }
509
+ }
510
+ }
511
+
512
+ // Inject locale into request headers for SSR
513
+ r.Header.Set("X-Blumen-Locale", locale)
514
+ w.Header().Set("Content-Language", locale)
515
+
516
+ next(w, r)
517
+ }
518
+ }
519
+
520
+ // parseAcceptLanguage extracts the best matching locale from the header.
521
+ func parseAcceptLanguage(header string, supported []string) string {
522
+ supportedSet := make(map[string]bool)
523
+ for _, l := range supported {
524
+ supportedSet[l] = true
525
+ }
526
+
527
+ // Parse quality values and sort
528
+ for _, part := range strings.Split(header, ",") {
529
+ part = strings.TrimSpace(part)
530
+ lang := strings.Split(part, ";")[0]
531
+ lang = strings.TrimSpace(lang)
532
+
533
+ // Exact match
534
+ if supportedSet[lang] {
535
+ return lang
536
+ }
537
+
538
+ // Prefix match (en-US → en)
539
+ prefix := strings.Split(lang, "-")[0]
540
+ if supportedSet[prefix] {
541
+ return prefix
542
+ }
543
+ }
544
+
545
+ return ""
546
+ }