mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-07-07 04:14:31 +00:00
refactor: reorganize struct fields, add new handlers and storage backends
- [x] Reorder struct fields across codebase for consistency - [x] Add analytics event handlers and tests - [x] Add authentication API key management handlers and tests - [x] Add pre-warming control handlers and tests - [x] Implement S3 storage backend with tests - [x] Implement SMB/CIFS storage backend with tests - [x] Add CDN middleware tests - [x] Integrate analytics tracking into cache manager - [x] Add S3 and SMB storage initialization in app setup - [x] Add CDN caching to proxy handlers - [x] Remove distributed locking (Redis lock manager) - [x] Remove proxy common package and utilities - [x] Remove standalone HTTP server package - [x] Remove logger middleware - [x] Simplify error handling utilities - [x] Update config with S3 and SMB options - [x] Update cache manager signature to include analytics
This commit is contained in:
+1
-128
@@ -4,10 +4,7 @@ import (
|
||||
"crypto/md5" // #nosec G501 -- MD5 used for ETag generation, not cryptographic security
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -214,38 +211,11 @@ func (m *Middleware) generateETag(body []byte) string {
|
||||
return `"` + hex.EncodeToString(hash[:]) + `"`
|
||||
}
|
||||
|
||||
// SetLastModified sets the Last-Modified header
|
||||
func SetLastModified(w http.ResponseWriter, t time.Time) {
|
||||
w.Header().Set("Last-Modified", t.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// SetCacheControl sets a custom Cache-Control header
|
||||
func SetCacheControl(w http.ResponseWriter, cc CacheControl) {
|
||||
w.Header().Set("Cache-Control", cc.String())
|
||||
}
|
||||
|
||||
// SetNoCache sets headers to prevent caching
|
||||
func SetNoCache(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
}
|
||||
|
||||
// SetImmutable sets headers for immutable content (content-addressed files)
|
||||
func SetImmutable(w http.ResponseWriter, maxAge int) {
|
||||
cc := CacheControl{
|
||||
Public: true,
|
||||
MaxAge: maxAge,
|
||||
Immutable: true,
|
||||
}
|
||||
w.Header().Set("Cache-Control", cc.String())
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture response
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body []byte
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(statusCode int) {
|
||||
@@ -261,100 +231,3 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
rw.body = append(rw.body, b...)
|
||||
return rw.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// HandleRange handles HTTP Range requests for partial content
|
||||
func HandleRange(w http.ResponseWriter, r *http.Request, content io.ReadSeeker, size int64, modTime time.Time) error {
|
||||
// Set Last-Modified header
|
||||
SetLastModified(w, modTime)
|
||||
|
||||
// Check for Range header
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" {
|
||||
// No range request - serve full content
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := io.Copy(w, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse range header (simplified - only handles single range)
|
||||
// Format: bytes=start-end
|
||||
var start, end int64
|
||||
n, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end)
|
||||
if err != nil || n != 2 {
|
||||
// Invalid range - serve full content
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := io.Copy(w, content)
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if start < 0 || start >= size || end < start || end >= size {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seek to start position
|
||||
if _, err := content.Seek(start, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate content length
|
||||
contentLength := end - start + 1
|
||||
|
||||
// Set headers for partial content
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
// Copy range to response
|
||||
_, err = io.CopyN(w, content, contentLength)
|
||||
return err
|
||||
}
|
||||
|
||||
// DefaultCacheControl returns sensible defaults for different content types
|
||||
func DefaultCacheControl(contentType string, versioned bool) CacheControl {
|
||||
if versioned {
|
||||
// Content-addressed or versioned resources can be cached forever
|
||||
return CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 31536000, // 1 year
|
||||
Immutable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Default caching based on content type
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
return CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600, // 1 hour
|
||||
SMaxAge: 7200, // 2 hours for shared caches
|
||||
}
|
||||
case "application/octet-stream", "application/x-gzip", "application/zip":
|
||||
// Binary packages
|
||||
return CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 86400, // 1 day
|
||||
SMaxAge: 604800, // 1 week for shared caches
|
||||
}
|
||||
case "text/html":
|
||||
// HTML should revalidate
|
||||
return CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 0,
|
||||
MustRevalidate: true,
|
||||
}
|
||||
default:
|
||||
return CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600, // 1 hour default
|
||||
SMaxAge: 7200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package cdn
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type CDNMiddlewareTestSuite struct {
|
||||
suite.Suite
|
||||
middleware *Middleware
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) SetupTest() {
|
||||
s.middleware = NewMiddleware(Config{
|
||||
DefaultCacheControl: CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600,
|
||||
SMaxAge: 7200,
|
||||
},
|
||||
EnableETag: true,
|
||||
EnableVary: true,
|
||||
})
|
||||
}
|
||||
|
||||
func TestCDNMiddlewareTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(CDNMiddlewareTestSuite))
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestCacheControlHeader() {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.Contains(w.Header().Get("Cache-Control"), "public")
|
||||
s.Contains(w.Header().Get("Cache-Control"), "max-age=3600")
|
||||
s.Contains(w.Header().Get("Cache-Control"), "s-maxage=7200")
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestETagGeneration() {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response content"))
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
etag := w.Header().Get("ETag")
|
||||
s.NotEmpty(etag)
|
||||
s.True(len(etag) > 0)
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestETagConsistencyAcrossRequests() {
|
||||
responseBody := []byte("test response content")
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(responseBody)
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
// First request to get ETag
|
||||
req1 := httptest.NewRequest("GET", "/test", nil)
|
||||
w1 := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w1, req1)
|
||||
etag := w1.Header().Get("ETag")
|
||||
s.NotEmpty(etag)
|
||||
s.Equal(http.StatusOK, w1.Code)
|
||||
|
||||
// Verify ETag is consistent for same content
|
||||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w2, req2)
|
||||
etag2 := w2.Header().Get("ETag")
|
||||
s.Equal(etag, etag2, "ETag should be consistent for same content")
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestVaryHeader() {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test"))
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
vary := w.Header().Get("Vary")
|
||||
s.NotEmpty(vary)
|
||||
s.Contains(vary, "Accept-Encoding")
|
||||
s.Contains(vary, "Authorization")
|
||||
s.Contains(vary, "Accept")
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestCacheControlString() {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
cc CacheControl
|
||||
}{
|
||||
{
|
||||
name: "public with max-age",
|
||||
cc: CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600,
|
||||
},
|
||||
expected: "public, max-age=3600",
|
||||
},
|
||||
{
|
||||
name: "private with no-cache",
|
||||
cc: CacheControl{
|
||||
Private: true,
|
||||
NoCache: true,
|
||||
},
|
||||
expected: "private, no-cache",
|
||||
},
|
||||
{
|
||||
name: "immutable",
|
||||
cc: CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 31536000,
|
||||
Immutable: true,
|
||||
},
|
||||
expected: "public, immutable, max-age=31536000",
|
||||
},
|
||||
{
|
||||
name: "no-store",
|
||||
cc: CacheControl{
|
||||
NoStore: true,
|
||||
},
|
||||
expected: "no-store",
|
||||
},
|
||||
{
|
||||
name: "must-revalidate",
|
||||
cc: CacheControl{
|
||||
Public: true,
|
||||
MustRevalidate: true,
|
||||
},
|
||||
expected: "public, must-revalidate",
|
||||
},
|
||||
{
|
||||
name: "s-maxage",
|
||||
cc: CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600,
|
||||
SMaxAge: 7200,
|
||||
},
|
||||
expected: "public, max-age=3600, s-maxage=7200",
|
||||
},
|
||||
{
|
||||
name: "stale-while-revalidate",
|
||||
cc: CacheControl{
|
||||
Public: true,
|
||||
MaxAge: 3600,
|
||||
StaleWhileRevalidate: 86400,
|
||||
},
|
||||
expected: "public, max-age=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
result := tt.cc.String()
|
||||
// Check that all expected parts are in the result
|
||||
for _, part := range splitCacheControl(tt.expected) {
|
||||
s.Contains(result, part)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestGenerateETag() {
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
expected bool // true if ETag should be generated
|
||||
}{
|
||||
{
|
||||
name: "non-empty body",
|
||||
body: []byte("test content"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: []byte{},
|
||||
expected: true, // Empty body still generates ETag (MD5 of empty string)
|
||||
},
|
||||
{
|
||||
name: "nil body",
|
||||
body: nil,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
etag := s.middleware.generateETag(tt.body)
|
||||
if tt.expected {
|
||||
s.NotEmpty(etag)
|
||||
s.True(len(etag) > 2) // Should be quoted
|
||||
} else {
|
||||
s.Empty(etag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestETagConsistency() {
|
||||
// Same content should produce same ETag
|
||||
body := []byte("consistent content")
|
||||
etag1 := s.middleware.generateETag(body)
|
||||
etag2 := s.middleware.generateETag(body)
|
||||
|
||||
s.Equal(etag1, etag2)
|
||||
|
||||
// Different content should produce different ETag
|
||||
body2 := []byte("different content")
|
||||
etag3 := s.middleware.generateETag(body2)
|
||||
|
||||
s.NotEqual(etag1, etag3)
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestNoCacheFor4xxErrors() {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
// 4xx errors should not have cache headers applied
|
||||
// (based on the middleware only applying headers for 2xx status codes)
|
||||
}
|
||||
|
||||
func (s *CDNMiddlewareTestSuite) TestNoCacheFor5xxErrors() {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("error"))
|
||||
})
|
||||
|
||||
wrappedHandler := s.middleware.Handler(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusInternalServerError, w.Code)
|
||||
// 5xx errors should not have cache headers applied
|
||||
}
|
||||
|
||||
// Helper function to split cache-control string
|
||||
func splitCacheControl(s string) []string {
|
||||
var parts []string
|
||||
current := ""
|
||||
for _, char := range s {
|
||||
if char == ',' {
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
}
|
||||
} else if char != ' ' {
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
Reference in New Issue
Block a user