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.
- package/dist/cli/blumen.js +270 -15
- package/dist/cli/commands/build.js +25 -6
- package/dist/cli/commands/create.js +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/fonts.js +232 -0
- package/dist/cli/commands/start.js +1 -1
- package/dist/templates/app/client/entry.tsx +3 -1
- package/dist/templates/app/pages/BlumenStarter.tsx +1 -1
- package/dist/templates/app/shared/DefaultDocument.tsx +49 -4
- package/dist/templates/app/shared/Link.tsx +60 -6
- package/dist/templates/app/shared/RouterContext.tsx +81 -18
- package/dist/templates/app/shared/router.ts +2 -1
- package/dist/templates/go-server/cache.go +147 -0
- package/dist/templates/go-server/image.go +200 -0
- package/dist/templates/go-server/main.go +394 -39
- package/dist/templates/go-server/middleware.go +546 -0
- package/dist/templates/go-server/websocket.go +430 -0
- package/dist/templates/node-ssr/server.ts +364 -8
- package/dist/templates/scripts/generate-routes.ts +355 -7
- package/package.json +12 -6
|
@@ -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
|
+
}
|