feat: implement CAS module, middleware, utils, and templates

- CAS: GET /files/{hash} with immutable cache headers, launcher asset
  serving, hash validation, StoreFile/VerifyAndStore helpers
- Middleware: CORS, request logging, per-IP token bucket rate limiter
- Utils: SHA1Bytes, SHA256Bytes, SHA1File, Unzip with zip-slip protection
- Templates: placeholder handler with html/template discovery
- Wire CAS routes and middleware chain (Logging → CORS) in main.go
This commit is contained in:
2026-05-26 15:11:41 +03:00
parent 2f07fbf379
commit e4fea937aa
5 changed files with 517 additions and 6 deletions

View File

@@ -1,2 +1,159 @@
// package middleware provides HTTP middleware (JWT, CORS, rate limiting, logging).
// package middleware provides HTTP middleware (CORS, logging, rate limiting).
package middleware
import (
"log"
"net/http"
"strings"
"sync"
"time"
)
// CORS adds permissive CORS headers for API endpoints.
// In production, restrict AllowOrigins to your actual domains.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CI-Token")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// Logging logs each request with method, path, status code, and duration.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r)
log.Printf("%s %s %d %s %s",
r.Method, r.RequestURI, ww.status,
time.Since(start), r.RemoteAddr,
)
})
}
// statusWriter wraps http.ResponseWriter to capture the status code.
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
// RateLimiter implements a simple per-IP token bucket rate limiter.
// Not suitable for production behind a proxy (use a real rate limiter then),
// but sufficient for development and single-instance deployments.
type RateLimiter struct {
mu sync.Mutex
clients map[string]*bucket
rate int // tokens per interval
interval time.Duration
burst int // max bucket size
}
type bucket struct {
tokens int
last time.Time
}
// NewRateLimiter creates a rate limiter allowing `rate` requests per `interval`,
// with a maximum burst of `burst`.
func NewRateLimiter(rate int, interval time.Duration, burst int) *RateLimiter {
rl := &RateLimiter{
clients: make(map[string]*bucket),
rate: rate,
interval: interval,
burst: burst,
}
// Periodically clean up stale entries.
go rl.cleanup()
return rl
}
// Limit returns an HTTP middleware that rate-limits requests by client IP.
func (rl *RateLimiter) Limit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !rl.allow(ip) {
w.Header().Set("Retry-After", "60")
http.Error(w, `{"error":"Rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
b, ok := rl.clients[ip]
if !ok {
rl.clients[ip] = &bucket{tokens: rl.burst - 1, last: time.Now()}
return true
}
// Refill tokens based on elapsed time.
elapsed := time.Since(b.last)
refill := int(elapsed / rl.interval * time.Duration(rl.rate))
if refill > 0 {
b.tokens = min(b.tokens+refill, rl.burst)
b.last = time.Now()
}
if b.tokens > 0 {
b.tokens--
return true
}
return false
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, b := range rl.clients {
if now.Sub(b.last) > 10*time.Minute {
delete(rl.clients, ip)
}
}
rl.mu.Unlock()
}
}
func clientIP(r *http.Request) string {
// Check X-Forwarded-For first (if behind a proxy).
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.IndexByte(xff, ','); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
// Fall back to RemoteAddr (strip port).
host, _, ok := strings.Cut(r.RemoteAddr, ":")
if !ok {
return r.RemoteAddr
}
return host
}
func min(a, b int) int {
if a < b {
return a
}
return b
}