From 96a6b5a4cfdd34998992a385c6cb49ea8223279f Mon Sep 17 00:00:00 2001 From: "othman.suseno" Date: Mon, 15 Dec 2025 00:53:35 +0700 Subject: [PATCH] p14 --- internal/db/db.go | 9 +- internal/httpapp/app.go | 44 ++-- internal/httpapp/cache_middleware.go | 252 +++++++++++++++++++++ internal/httpapp/compression_middleware.go | 54 +++++ 4 files changed, 339 insertions(+), 20 deletions(-) create mode 100644 internal/httpapp/cache_middleware.go create mode 100644 internal/httpapp/compression_middleware.go diff --git a/internal/db/db.go b/internal/db/db.go index 986eabd..119e6ef 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 diff --git a/internal/httpapp/app.go b/internal/httpapp/app.go index 2dce8f4..7c788c3 100644 --- a/internal/httpapp/app.go +++ b/internal/httpapp/app.go @@ -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), + ), + ), ), ), ), diff --git a/internal/httpapp/cache_middleware.go b/internal/httpapp/cache_middleware.go new file mode 100644 index 0000000..9acb903 --- /dev/null +++ b/internal/httpapp/cache_middleware.go @@ -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 +} diff --git a/internal/httpapp/compression_middleware.go b/internal/httpapp/compression_middleware.go new file mode 100644 index 0000000..5f45e91 --- /dev/null +++ b/internal/httpapp/compression_middleware.go @@ -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) +}