p14
Some checks failed
CI / test-build (push) Failing after 1m11s

This commit is contained in:
2025-12-15 00:53:35 +07:00
parent df475bc85e
commit 96a6b5a4cf
4 changed files with 339 additions and 20 deletions

View File

@@ -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

View File

@@ -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),
),
),
),
),
),

View 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
}

View 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)
}