diff --git a/src/mod/dynamicproxy/dpcore/dpcore.go b/src/mod/dynamicproxy/dpcore/dpcore.go index dcdfbf2..62cf1bf 100644 --- a/src/mod/dynamicproxy/dpcore/dpcore.go +++ b/src/mod/dynamicproxy/dpcore/dpcore.go @@ -1,6 +1,7 @@ package dpcore import ( + "bytes" "context" "crypto/tls" "errors" @@ -78,6 +79,9 @@ type ResponseRewriteRuleSet struct { /* System Information Payload */ DevelopmentMode bool //Inject dev mode information to requests Version string //Version number of Zoraxy, use for X-Proxy-By + + /* Caching Support */ + CacheResponse func(requestPath string, contentType string, content []byte) //Callback to cache response, can be nil } type requestCanceler interface { @@ -410,7 +414,29 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr //Get flush interval in real time and start copying the request flushInterval := p.getFlushInterval(req, res) - p.copyResponse(rw, res.Body, flushInterval) + + // Check if we need to cache this response + if rrr.CacheResponse != nil && res.StatusCode == 200 { + // Read the entire response body into memory for caching + bodyBytes, err := io.ReadAll(res.Body) + if err != nil { + if p.Verbal { + p.logf("dpcore read error during caching: %v", err) + } + // Fall back to normal copying without caching + p.copyResponse(rw, res.Body, flushInterval) + } else { + // Cache the response + contentType := res.Header.Get("Content-Type") + rrr.CacheResponse(req.URL.Path, contentType, bodyBytes) + + // Send cached content to client + p.copyResponse(rw, bytes.NewReader(bodyBytes), flushInterval) + } + } else { + // Normal response copying without caching + p.copyResponse(rw, res.Body, flushInterval) + } // close now, instead of defer, to populate res.Trailer res.Body.Close() diff --git a/src/mod/dynamicproxy/proxyRequestHandler.go b/src/mod/dynamicproxy/proxyRequestHandler.go index dba00bd..9d52e59 100644 --- a/src/mod/dynamicproxy/proxyRequestHandler.go +++ b/src/mod/dynamicproxy/proxyRequestHandler.go @@ -204,6 +204,20 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe return } + /* Static Cache Check */ + if r.Method == "GET" && target.staticCacheResourcesPool != nil && target.staticCacheResourcesPool.IsEnabled() { + // Check if this request should be cached and if we have a cached version + if cachedFile, exists := target.staticCacheResourcesPool.GetCachedFile(r.URL.Path); exists { + // Serve from cache + err := target.staticCacheResourcesPool.ServeCachedFile(w, cachedFile) + if err == nil { + h.Parent.logRequest(r, true, 200, "host-cache", reqHostname, "cache", target) + return + } + // If serving from cache failed, continue with normal proxy flow + } + } + if r.URL != nil { r.Host = r.URL.Host } else { @@ -227,6 +241,20 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe PermissionPolicy: headerRewriteOptions.PermissionPolicy, }) + // Prepare cache callback if static caching is enabled + var cacheCallback func(requestPath string, contentType string, content []byte) + if target.staticCacheResourcesPool != nil && target.staticCacheResourcesPool.IsEnabled() { + // Check if this response should be cached + if target.staticCacheResourcesPool.ShouldCacheRequest(r.URL.Path) { + cacheCallback = func(requestPath string, contentType string, content []byte) { + // Only cache if content length is within limits + if target.staticCacheResourcesPool.CheckFileSizeShouldBeCached(int64(len(content))) { + target.staticCacheResourcesPool.StoreCachedFile(requestPath, contentType, content) + } + } + } + } + //Handle the request reverse proxy statusCode, err := selectedUpstream.ServeHTTP(w, r, &dpcore.ResponseRewriteRuleSet{ ProxyDomain: selectedUpstream.OriginIpOrDomain, @@ -241,6 +269,7 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe NoRemoveHopByHop: headerRewriteOptions.DisableHopByHopHeaderRemoval, Version: target.parent.Option.HostVersion, DevelopmentMode: target.parent.Option.DevelopmentMode, + CacheResponse: cacheCallback, }) //validate the error diff --git a/src/mod/dynamicproxy/router.go b/src/mod/dynamicproxy/router.go index 2e484d2..b4904d9 100644 --- a/src/mod/dynamicproxy/router.go +++ b/src/mod/dynamicproxy/router.go @@ -8,6 +8,7 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/dynamicproxy/staticcache" "imuslab.com/zoraxy/mod/utils" ) @@ -31,6 +32,11 @@ func (router *Router) PrepareProxyRoute(endpoint *ProxyEndpoint) (*ProxyEndpoint endpoint.parent = router + //Initialize static cache resources pool if static cache is configured + if endpoint.StaticCacheConfig != nil && endpoint.StaticCacheConfig.Enabled { + endpoint.staticCacheResourcesPool = staticcache.NewStaticCacheResourcesPool(endpoint.StaticCacheConfig) + } + //Prepare proxy routing handler for each of the virtual directories for _, vdir := range endpoint.VirtualDirectories { domain := vdir.Domain diff --git a/src/mod/dynamicproxy/staticcache/staticcache.go b/src/mod/dynamicproxy/staticcache/staticcache.go new file mode 100644 index 0000000..82974ae --- /dev/null +++ b/src/mod/dynamicproxy/staticcache/staticcache.go @@ -0,0 +1,217 @@ +package staticcache + +import ( + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type StaticCachedFile struct { + FilePath string // The file path of the cached file + ContentType string // The MIME type of the cached file + ExpiryTime int64 // The Unix timestamp when the cache expires +} + +type StaticCacheConfig struct { + Enabled bool // Whether static caching is enabled on this proxy rule + Timeout int64 // How long to cache static files in seconds + MaxFileSize int64 // Maximum file size to cache in bytes + FileExtensions []string // File extensions to cache, e.g. []string{".css", ".js", ".png"} + SkipSubpaths []string // Subpaths to skip caching, e.g. []string{"/api/", "/admin/"} + CacheFileDir string // Directory to store cached files +} + +type StaticCacheResourcesPool struct { + config *StaticCacheConfig + cachedFiles sync.Map // in the type of map[string]*StaticCachedFile +} + +func NewStaticCacheResourcesPool(config *StaticCacheConfig) *StaticCacheResourcesPool { + //Check if the config dir exists, if not create it + if config.CacheFileDir != "" { + if _, err := os.Stat(config.CacheFileDir); os.IsNotExist(err) { + os.MkdirAll(config.CacheFileDir, 0755) + } + } + return &StaticCacheResourcesPool{ + config: config, + cachedFiles: sync.Map{}, + } +} + +// GetDefaultStaticCacheConfig returns a default static cache configuration +func GetDefaultStaticCacheConfig(cacheFolderDir string) *StaticCacheConfig { + return &StaticCacheConfig{ + Enabled: false, + Timeout: 3600, // 1 hourt + MaxFileSize: 25 * 1024 * 1024, // 25 MB + FileExtensions: []string{".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".woff", ".woff2", ".ttf", ".eot"}, + SkipSubpaths: []string{}, + CacheFileDir: cacheFolderDir, + } +} + +func (pool *StaticCacheResourcesPool) IsEnabled() bool { + return pool.config.Enabled +} + +func (pool *StaticCacheResourcesPool) GetConfig() *StaticCacheConfig { + return pool.config +} + +// CheckFileSizeShouldBeCached checks if the file size is within the limit to be cached +func (pool *StaticCacheResourcesPool) CheckFileSizeShouldBeCached(contentLength int64) bool { + return pool.config.MaxFileSize <= 0 || contentLength <= pool.config.MaxFileSize +} + +// ShouldCacheRequest checks if a request should be cached based on the configuration +func (pool *StaticCacheResourcesPool) ShouldCacheRequest(requestPath string) bool { + if !pool.config.Enabled { + return false + } + + // Check if path should be skipped + for _, skipPath := range pool.config.SkipSubpaths { + if strings.Contains(requestPath, skipPath) { + return false + } + } + + ext := strings.ToLower(filepath.Ext(requestPath)) + found := false + for _, allowedExt := range pool.config.FileExtensions { + if ext == strings.ToLower(allowedExt) { + found = true + break + } + } + return found +} + +// GetCachedFile retrieves a cached file if it exists and is not expired +func (pool *StaticCacheResourcesPool) GetCachedFile(requestPath string) (*StaticCachedFile, bool) { + cacheKey := pool.generateCacheKey(requestPath) + + value, exists := pool.cachedFiles.Load(cacheKey) + + if !exists { + return nil, false + } + + cachedFile, ok := value.(*StaticCachedFile) + + if !ok { + return nil, false + } + + // Check if cache is expired + if time.Now().Unix() > cachedFile.ExpiryTime { + // Remove expired cache + pool.cachedFiles.Delete(cacheKey) + pool.removeFileFromDisk(cachedFile.FilePath) + return nil, false + } + + return cachedFile, true +} + +// generateCacheKey creates a unique key for caching based on the request path +func (pool *StaticCacheResourcesPool) generateCacheKey(requestPath string) string { + // Use the request path as the key, normalized + return strings.TrimPrefix(requestPath, "/") +} + +// removeFileFromDisk removes a cached file from disk +func (pool *StaticCacheResourcesPool) removeFileFromDisk(filePath string) { + os.Remove(filePath) +} + +// StoreCachedFile stores a file in the cache with the given content and expiry time +func (pool *StaticCacheResourcesPool) StoreCachedFile(requestPath, contentType string, content []byte) error { + cacheKey := pool.generateCacheKey(requestPath) + + // Create cache directory if it doesn't exist + cacheDir := pool.config.CacheFileDir + if cacheDir == "" { + cacheDir = "./cache" // Default cache directory + } + + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return err + } + + // Generate file path + fileName := strings.ReplaceAll(cacheKey, "/", "_") + filePath := filepath.Join(cacheDir, fileName) + + // Write content to file + if err := os.WriteFile(filePath, content, 0644); err != nil { + return err + } + + // Calculate expiry time + expiryTime := time.Now().Add(time.Duration(pool.config.Timeout) * time.Second).Unix() + + // Store in memory cache + cachedFile := &StaticCachedFile{ + FilePath: filePath, + ContentType: contentType, + ExpiryTime: expiryTime, + } + + pool.cachedFiles.Store(cacheKey, cachedFile) + return nil +} + +// ServeCachedFile serves a cached file to the HTTP response writer +func (pool *StaticCacheResourcesPool) ServeCachedFile(w http.ResponseWriter, cachedFile *StaticCachedFile) error { + file, err := os.Open(cachedFile.FilePath) + if err != nil { + return err + } + defer file.Close() + + // Set content type + if cachedFile.ContentType != "" { + w.Header().Set("Content-Type", cachedFile.ContentType) + } else { + // Try to detect content type from file extension + ext := filepath.Ext(cachedFile.FilePath) + if mimeType := mime.TypeByExtension(ext); mimeType != "" { + w.Header().Set("Content-Type", mimeType) + } + } + + // Set cache headers + w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour in browser + + // Copy file content to response + _, err = io.Copy(w, file) + return err +} + +// RemoveExpiredCache removes all expired cached files from memory and disk +func (pool *StaticCacheResourcesPool) RemoveExpiredCache() { + currentTime := time.Now().Unix() + + pool.cachedFiles.Range(func(key, value interface{}) bool { + cachedFile, ok := value.(*StaticCachedFile) + if !ok { + return true + } + + if currentTime > cachedFile.ExpiryTime { + // Remove from memory + pool.cachedFiles.Delete(key) + // Remove from disk + pool.removeFileFromDisk(cachedFile.FilePath) + } + + return true + }) +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 414b6d7..770a889 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -22,6 +22,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" "imuslab.com/zoraxy/mod/dynamicproxy/redirection" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" + "imuslab.com/zoraxy/mod/dynamicproxy/staticcache" "imuslab.com/zoraxy/mod/geodb" "imuslab.com/zoraxy/mod/info/logger" "imuslab.com/zoraxy/mod/plugins" @@ -220,9 +221,13 @@ type ProxyEndpoint struct { DefaultSiteOption int //Fallback routing logic options DefaultSiteValue string //Fallback routing target, optional + //Static Resources Caching + StaticCacheConfig *staticcache.StaticCacheConfig //Static cache configuration for this endpoint + //Internal Logic Elements - parent *Router `json:"-"` - Tags []string // Tags for the proxy endpoint + parent *Router `json:"-"` //Parent router + staticCacheResourcesPool *staticcache.StaticCacheResourcesPool `json:"-"` //Static cache resources pool + Tags []string // Tags for the proxy endpoint } /* diff --git a/src/reverseproxy.go b/src/reverseproxy.go index 652ae66..4bbc716 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -10,11 +10,13 @@ import ( "strings" "time" + "github.com/google/uuid" "imuslab.com/zoraxy/mod/auth" "imuslab.com/zoraxy/mod/dynamicproxy" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" + "imuslab.com/zoraxy/mod/dynamicproxy/staticcache" "imuslab.com/zoraxy/mod/netutils" "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/uptime" @@ -343,6 +345,10 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { } tags = filteredTags + // Get a default static cache config with default cache dir + cacheDir := filepath.Join(TMP_FOLDER, "static_cache", uuid.New().String()) + staticCacheConfig := staticcache.GetDefaultStaticCacheConfig(cacheDir) + var proxyEndpointCreated *dynamicproxy.ProxyEndpoint switch eptype { case "host": @@ -413,10 +419,15 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { //Default Site DefaultSiteOption: 0, DefaultSiteValue: "", + + // Static Cache + StaticCacheConfig: staticCacheConfig, + // Rate Limit RequireRateLimit: requireRateLimit, RateLimit: int64(proxyRateLimit), + // Others Tags: tags, DisableUptimeMonitor: !enableUtm, DisableLogging: disableLog,