@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
@@ -22,11 +23,17 @@ func New(dbPath string) (*DB, error) {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_foreign_keys=1")
|
||||
// Configure connection pool
|
||||
conn, err := sql.Open("sqlite", dbPath+"?_foreign_keys=1&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings for better performance
|
||||
conn.SetMaxOpenConns(25) // Maximum number of open connections
|
||||
conn.SetMaxIdleConns(5) // Maximum number of idle connections
|
||||
conn.SetConnMaxLifetime(5 * time.Minute) // Maximum connection lifetime
|
||||
|
||||
db := &DB{DB: conn}
|
||||
|
||||
// Test connection
|
||||
|
||||
@@ -143,26 +143,32 @@ func New(cfg Config) (*App, error) {
|
||||
func (a *App) Router() http.Handler {
|
||||
// Middleware chain order (outer to inner):
|
||||
// 1. CORS (handles preflight)
|
||||
// 2. Security headers
|
||||
// 3. Request size limit (10MB)
|
||||
// 4. Content-Type validation
|
||||
// 5. Rate limiting
|
||||
// 6. Error recovery
|
||||
// 7. Request ID
|
||||
// 8. Logging
|
||||
// 9. Audit
|
||||
// 10. Authentication
|
||||
// 11. Routes
|
||||
// 2. Compression (gzip)
|
||||
// 3. Security headers
|
||||
// 4. Request size limit (10MB)
|
||||
// 5. Content-Type validation
|
||||
// 6. Rate limiting
|
||||
// 7. Caching (for GET requests)
|
||||
// 8. Error recovery
|
||||
// 9. Request ID
|
||||
// 10. Logging
|
||||
// 11. Audit
|
||||
// 12. Authentication
|
||||
// 13. Routes
|
||||
return a.corsMiddleware(
|
||||
a.securityHeadersMiddleware(
|
||||
a.requestSizeMiddleware(10 * 1024 * 1024)(
|
||||
a.validateContentTypeMiddleware(
|
||||
a.rateLimitMiddleware(
|
||||
a.errorMiddleware(
|
||||
requestID(
|
||||
logging(
|
||||
a.auditMiddleware(
|
||||
a.authMiddleware(a.mux),
|
||||
a.compressionMiddleware(
|
||||
a.securityHeadersMiddleware(
|
||||
a.requestSizeMiddleware(10 * 1024 * 1024)(
|
||||
a.validateContentTypeMiddleware(
|
||||
a.rateLimitMiddleware(
|
||||
a.cacheMiddleware(
|
||||
a.errorMiddleware(
|
||||
requestID(
|
||||
logging(
|
||||
a.auditMiddleware(
|
||||
a.authMiddleware(a.mux),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
252
internal/httpapp/cache_middleware.go
Normal file
252
internal/httpapp/cache_middleware.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CacheEntry represents a cached response
|
||||
type CacheEntry struct {
|
||||
Body []byte
|
||||
Headers map[string]string
|
||||
StatusCode int
|
||||
ExpiresAt time.Time
|
||||
ETag string
|
||||
}
|
||||
|
||||
// ResponseCache provides HTTP response caching
|
||||
type ResponseCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*CacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewResponseCache creates a new response cache
|
||||
func NewResponseCache(ttl time.Duration) *ResponseCache {
|
||||
c := &ResponseCache{
|
||||
cache: make(map[string]*CacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go c.cleanup()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// cleanup periodically removes expired entries
|
||||
func (c *ResponseCache) cleanup() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a cached entry
|
||||
func (c *ResponseCache) Get(key string) (*CacheEntry, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, exists := c.cache[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry, true
|
||||
}
|
||||
|
||||
// Set stores a cached entry
|
||||
func (c *ResponseCache) Set(key string, entry *CacheEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry.ExpiresAt = time.Now().Add(c.ttl)
|
||||
c.cache[key] = entry
|
||||
}
|
||||
|
||||
// Invalidate removes a cached entry
|
||||
func (c *ResponseCache) Invalidate(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.cache, key)
|
||||
}
|
||||
|
||||
// InvalidatePattern removes entries matching a pattern
|
||||
func (c *ResponseCache) InvalidatePattern(pattern string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for key := range c.cache {
|
||||
if containsPattern(key, pattern) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsPattern checks if a string contains a pattern (simple prefix/suffix matching)
|
||||
func containsPattern(s, pattern string) bool {
|
||||
// Simple pattern matching - can be enhanced
|
||||
return len(s) >= len(pattern) && (s[:len(pattern)] == pattern || s[len(s)-len(pattern):] == pattern)
|
||||
}
|
||||
|
||||
// generateCacheKey creates a cache key from request
|
||||
func generateCacheKey(r *http.Request) string {
|
||||
// Include method, path, and query string
|
||||
key := r.Method + ":" + r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
key += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
||||
// Hash the key for consistent length
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generateETag generates an ETag from content
|
||||
func generateETag(content []byte) string {
|
||||
hash := sha256.Sum256(content)
|
||||
return `"` + hex.EncodeToString(hash[:16]) + `"`
|
||||
}
|
||||
|
||||
// cacheMiddleware provides response caching for GET requests
|
||||
func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
||||
// Default TTL: 5 minutes for GET requests
|
||||
cache := NewResponseCache(5 * time.Minute)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only cache GET requests
|
||||
if r.Method != http.MethodGet {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip caching for authenticated endpoints that may have user-specific data
|
||||
if !a.isPublicEndpoint(r.URL.Path) {
|
||||
// Check if user is authenticated - if so, include user ID in cache key
|
||||
user, ok := getUserFromContext(r)
|
||||
if ok {
|
||||
// For authenticated requests, we could cache per-user, but for simplicity, skip caching
|
||||
// In production, you might want per-user caching
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip caching for certain endpoints
|
||||
if a.shouldSkipCache(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check cache
|
||||
cacheKey := generateCacheKey(r)
|
||||
if entry, found := cache.Get(cacheKey); found {
|
||||
// Check If-None-Match header for ETag validation
|
||||
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||
if ifNoneMatch == entry.ETag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve from cache
|
||||
for k, v := range entry.Headers {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
w.Header().Set("ETag", entry.ETag)
|
||||
w.Header().Set("X-Cache", "HIT")
|
||||
w.WriteHeader(entry.StatusCode)
|
||||
w.Write(entry.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// Create response writer to capture response
|
||||
rw := &cacheResponseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
body: make([]byte, 0),
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// Only cache successful responses
|
||||
if rw.statusCode >= 200 && rw.statusCode < 300 {
|
||||
// Generate ETag
|
||||
etag := generateETag(rw.body)
|
||||
|
||||
// Store in cache
|
||||
headers := make(map[string]string)
|
||||
for k, v := range rw.Header() {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
entry := &CacheEntry{
|
||||
Body: rw.body,
|
||||
Headers: headers,
|
||||
StatusCode: rw.statusCode,
|
||||
ETag: etag,
|
||||
}
|
||||
|
||||
cache.Set(cacheKey, entry)
|
||||
|
||||
// Add cache headers
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("X-Cache", "MISS")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// cacheResponseWriter captures response for caching
|
||||
type cacheResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (rw *cacheResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *cacheResponseWriter) Write(b []byte) (int, error) {
|
||||
rw.body = append(rw.body, b...)
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// shouldSkipCache determines if a path should skip caching
|
||||
func (a *App) shouldSkipCache(path string) bool {
|
||||
// Skip caching for dynamic endpoints
|
||||
skipPaths := []string{
|
||||
"/metrics",
|
||||
"/healthz",
|
||||
"/health",
|
||||
"/api/v1/system/info",
|
||||
"/api/v1/system/logs",
|
||||
"/api/v1/dashboard",
|
||||
}
|
||||
|
||||
for _, skipPath := range skipPaths {
|
||||
if path == skipPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
54
internal/httpapp/compression_middleware.go
Normal file
54
internal/httpapp/compression_middleware.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package httpapp
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// compressionMiddleware provides gzip compression for responses
|
||||
func (a *App) compressionMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if client accepts gzip
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip compression for certain content types
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "image/") ||
|
||||
strings.HasPrefix(contentType, "video/") ||
|
||||
strings.HasPrefix(contentType, "application/zip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Create gzip writer
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
// Set headers
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
|
||||
// Wrap response writer
|
||||
gzw := &gzipResponseWriter{
|
||||
ResponseWriter: w,
|
||||
Writer: gz,
|
||||
}
|
||||
|
||||
next.ServeHTTP(gzw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// gzipResponseWriter wraps http.ResponseWriter with gzip compression
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
func (gzw *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return gzw.Writer.Write(b)
|
||||
}
|
||||
Reference in New Issue
Block a user