mirror of
https://github.com/woodchen-ink/proxy-go.git
synced 2025-07-18 16:41:54 +08:00
feat(cache): Implement comprehensive caching system for proxy requests
- Add CacheManager to handle caching for proxy and mirror handlers - Introduce cache-related API endpoints for stats, enabling, and clearing - Modify proxy and mirror handlers to support caching GET requests - Remove rate limiting and simplify request handling - Update go.mod dependencies and remove unused imports - Add cache hit/miss headers to responses - Enhance metrics collection for cached requests
This commit is contained in:
parent
fc2e324d82
commit
c4cd99a827
7
go.mod
7
go.mod
@ -4,10 +4,7 @@ go 1.23.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.1
|
github.com/andybalholm/brotli v1.1.1
|
||||||
golang.org/x/time v0.9.0
|
golang.org/x/net v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/net v0.35.0 // indirect
|
|
||||||
golang.org/x/text v0.22.0 // indirect
|
|
||||||
)
|
|
||||||
|
2
go.sum
2
go.sum
@ -6,5 +6,3 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
|||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
|
433
internal/cache/manager.go
vendored
Normal file
433
internal/cache/manager.go
vendored
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheKey 用于标识缓存项的唯一键
|
||||||
|
type CacheKey struct {
|
||||||
|
URL string
|
||||||
|
AcceptHeaders string
|
||||||
|
UserAgent string
|
||||||
|
VaryHeadersMap map[string]string // 存储 Vary 头部的值
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheItem 表示一个缓存项
|
||||||
|
type CacheItem struct {
|
||||||
|
FilePath string
|
||||||
|
ContentType string
|
||||||
|
Size int64
|
||||||
|
LastAccess time.Time
|
||||||
|
Hash string
|
||||||
|
ETag string
|
||||||
|
LastModified time.Time
|
||||||
|
CacheControl string
|
||||||
|
VaryHeaders []string
|
||||||
|
// 新增防穿透字段
|
||||||
|
NegativeCache bool // 标记是否为空结果缓存
|
||||||
|
AccessCount int64 // 访问计数
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheStats 缓存统计信息
|
||||||
|
type CacheStats struct {
|
||||||
|
TotalItems int `json:"total_items"` // 缓存项数量
|
||||||
|
TotalSize int64 `json:"total_size"` // 总大小
|
||||||
|
HitCount int64 `json:"hit_count"` // 命中次数
|
||||||
|
MissCount int64 `json:"miss_count"` // 未命中次数
|
||||||
|
HitRate float64 `json:"hit_rate"` // 命中率
|
||||||
|
BytesSaved int64 `json:"bytes_saved"` // 节省的带宽
|
||||||
|
Enabled bool `json:"enabled"` // 缓存开关状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheManager 缓存管理器
|
||||||
|
type CacheManager struct {
|
||||||
|
cacheDir string
|
||||||
|
items sync.Map
|
||||||
|
maxAge time.Duration
|
||||||
|
cleanupTick time.Duration
|
||||||
|
maxCacheSize int64
|
||||||
|
enabled atomic.Bool // 缓存开关
|
||||||
|
hitCount atomic.Int64 // 命中计数
|
||||||
|
missCount atomic.Int64 // 未命中计数
|
||||||
|
bytesSaved atomic.Int64 // 节省的带宽
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCacheManager 创建新的缓存管理器
|
||||||
|
func NewCacheManager(cacheDir string) (*CacheManager, error) {
|
||||||
|
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create cache directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := &CacheManager{
|
||||||
|
cacheDir: cacheDir,
|
||||||
|
maxAge: 30 * time.Minute,
|
||||||
|
cleanupTick: 5 * time.Minute,
|
||||||
|
maxCacheSize: 10 * 1024 * 1024 * 1024, // 10GB
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.enabled.Store(true) // 默认启用缓存
|
||||||
|
|
||||||
|
// 启动清理协程
|
||||||
|
go cm.cleanup()
|
||||||
|
|
||||||
|
return cm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCacheKey 生成缓存键
|
||||||
|
func (cm *CacheManager) GenerateCacheKey(r *http.Request) CacheKey {
|
||||||
|
return CacheKey{
|
||||||
|
URL: r.URL.String(),
|
||||||
|
AcceptHeaders: r.Header.Get("Accept"),
|
||||||
|
UserAgent: r.Header.Get("User-Agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取缓存项
|
||||||
|
func (cm *CacheManager) Get(key CacheKey, r *http.Request) (*CacheItem, bool, bool) {
|
||||||
|
// 如果缓存被禁用,直接返回未命中
|
||||||
|
if !cm.enabled.Load() {
|
||||||
|
cm.missCount.Add(1)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否存在缓存项
|
||||||
|
if value, ok := cm.items.Load(key); ok {
|
||||||
|
item := value.(*CacheItem)
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if _, err := os.Stat(item.FilePath); err != nil {
|
||||||
|
cm.items.Delete(key)
|
||||||
|
cm.missCount.Add(1)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为负缓存(防止缓存穿透)
|
||||||
|
if item.NegativeCache {
|
||||||
|
// 如果访问次数较少且是负缓存,允许重新验证
|
||||||
|
if item.AccessCount < 10 {
|
||||||
|
item.AccessCount++
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
// 返回空结果,但标记为命中
|
||||||
|
cm.hitCount.Add(1)
|
||||||
|
return nil, true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Vary 头部
|
||||||
|
for _, varyHeader := range item.VaryHeaders {
|
||||||
|
if r.Header.Get(varyHeader) != key.VaryHeadersMap[varyHeader] {
|
||||||
|
cm.missCount.Add(1)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理条件请求
|
||||||
|
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||||
|
ifModifiedSince := r.Header.Get("If-Modified-Since")
|
||||||
|
|
||||||
|
// ETag 匹配
|
||||||
|
if ifNoneMatch != "" && item.ETag != "" {
|
||||||
|
if ifNoneMatch == item.ETag {
|
||||||
|
cm.hitCount.Add(1)
|
||||||
|
return item, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-Modified 匹配
|
||||||
|
if ifModifiedSince != "" && !item.LastModified.IsZero() {
|
||||||
|
if modifiedSince, err := time.Parse(time.RFC1123, ifModifiedSince); err == nil {
|
||||||
|
if !item.LastModified.After(modifiedSince) {
|
||||||
|
cm.hitCount.Add(1)
|
||||||
|
return item, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 Cache-Control
|
||||||
|
if item.CacheControl != "" {
|
||||||
|
if cm.isCacheExpired(item) {
|
||||||
|
cm.items.Delete(key)
|
||||||
|
cm.missCount.Add(1)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新访问统计
|
||||||
|
item.LastAccess = time.Now()
|
||||||
|
item.AccessCount++
|
||||||
|
cm.hitCount.Add(1)
|
||||||
|
cm.bytesSaved.Add(item.Size)
|
||||||
|
return item, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.missCount.Add(1)
|
||||||
|
return nil, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCacheExpired 检查缓存是否过期
|
||||||
|
func (cm *CacheManager) isCacheExpired(item *CacheItem) bool {
|
||||||
|
if item.CacheControl == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 max-age
|
||||||
|
if strings.Contains(item.CacheControl, "max-age=") {
|
||||||
|
parts := strings.Split(item.CacheControl, "max-age=")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
maxAge := strings.Split(parts[1], ",")[0]
|
||||||
|
if seconds, err := strconv.Atoi(maxAge); err == nil {
|
||||||
|
return time.Since(item.CreatedAt) > time.Duration(seconds)*time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put 添加缓存项
|
||||||
|
func (cm *CacheManager) Put(key CacheKey, resp *http.Response, body []byte) (*CacheItem, error) {
|
||||||
|
// 检查缓存控制头
|
||||||
|
if !cm.shouldCache(resp) {
|
||||||
|
return nil, fmt.Errorf("response should not be cached")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
hash := sha256.Sum256([]byte(fmt.Sprintf("%v-%v-%v-%v", key.URL, key.AcceptHeaders, key.UserAgent, time.Now().UnixNano())))
|
||||||
|
fileName := hex.EncodeToString(hash[:])
|
||||||
|
filePath := filepath.Join(cm.cacheDir, fileName)
|
||||||
|
|
||||||
|
// 使用更安全的文件权限
|
||||||
|
if err := os.WriteFile(filePath, body, 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write cache file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算内容哈希
|
||||||
|
contentHash := sha256.Sum256(body)
|
||||||
|
|
||||||
|
// 解析缓存控制头
|
||||||
|
cacheControl := resp.Header.Get("Cache-Control")
|
||||||
|
lastModified := resp.Header.Get("Last-Modified")
|
||||||
|
etag := resp.Header.Get("ETag")
|
||||||
|
|
||||||
|
var lastModifiedTime time.Time
|
||||||
|
if lastModified != "" {
|
||||||
|
if t, err := time.Parse(time.RFC1123, lastModified); err == nil {
|
||||||
|
lastModifiedTime = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Vary 头部
|
||||||
|
varyHeaders := strings.Split(resp.Header.Get("Vary"), ",")
|
||||||
|
for i, h := range varyHeaders {
|
||||||
|
varyHeaders[i] = strings.TrimSpace(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &CacheItem{
|
||||||
|
FilePath: filePath,
|
||||||
|
ContentType: resp.Header.Get("Content-Type"),
|
||||||
|
Size: int64(len(body)),
|
||||||
|
LastAccess: time.Now(),
|
||||||
|
Hash: hex.EncodeToString(contentHash[:]),
|
||||||
|
ETag: etag,
|
||||||
|
LastModified: lastModifiedTime,
|
||||||
|
CacheControl: cacheControl,
|
||||||
|
VaryHeaders: varyHeaders,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
AccessCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有相同内容的缓存
|
||||||
|
var existingItem *CacheItem
|
||||||
|
cm.items.Range(func(k, v interface{}) bool {
|
||||||
|
if i := v.(*CacheItem); i.Hash == item.Hash {
|
||||||
|
existingItem = i
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if existingItem != nil {
|
||||||
|
// 如果找到相同内容的缓存,删除新文件,复用现有缓存
|
||||||
|
os.Remove(filePath)
|
||||||
|
cm.items.Store(key, existingItem)
|
||||||
|
log.Printf("[Cache] Found duplicate content for %s, reusing existing cache", key.URL)
|
||||||
|
return existingItem, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储新的缓存项
|
||||||
|
cm.items.Store(key, item)
|
||||||
|
log.Printf("[Cache] Cached %s (%s)", key.URL, formatBytes(item.Size))
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldCache 检查响应是否应该被缓存
|
||||||
|
func (cm *CacheManager) shouldCache(resp *http.Response) bool {
|
||||||
|
// 检查状态码
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotModified {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Cache-Control 头
|
||||||
|
cacheControl := resp.Header.Get("Cache-Control")
|
||||||
|
if strings.Contains(cacheControl, "no-store") ||
|
||||||
|
strings.Contains(cacheControl, "no-cache") ||
|
||||||
|
strings.Contains(cacheControl, "private") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup 定期清理过期的缓存项
|
||||||
|
func (cm *CacheManager) cleanup() {
|
||||||
|
ticker := time.NewTicker(cm.cleanupTick)
|
||||||
|
for range ticker.C {
|
||||||
|
var totalSize int64
|
||||||
|
var keysToDelete []CacheKey
|
||||||
|
|
||||||
|
// 收集需要删除的键和计算总大小
|
||||||
|
cm.items.Range(func(k, v interface{}) bool {
|
||||||
|
key := k.(CacheKey)
|
||||||
|
item := v.(*CacheItem)
|
||||||
|
totalSize += item.Size
|
||||||
|
|
||||||
|
if time.Since(item.LastAccess) > cm.maxAge {
|
||||||
|
keysToDelete = append(keysToDelete, key)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果总大小超过限制,按最后访问时间排序删除
|
||||||
|
if totalSize > cm.maxCacheSize {
|
||||||
|
var items []*CacheItem
|
||||||
|
cm.items.Range(func(k, v interface{}) bool {
|
||||||
|
items = append(items, v.(*CacheItem))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按最后访问时间排序
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].LastAccess.Before(items[j].LastAccess)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除最旧的项直到总大小小于限制
|
||||||
|
for _, item := range items {
|
||||||
|
if totalSize <= cm.maxCacheSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cm.items.Range(func(k, v interface{}) bool {
|
||||||
|
if v.(*CacheItem) == item {
|
||||||
|
keysToDelete = append(keysToDelete, k.(CacheKey))
|
||||||
|
totalSize -= item.Size
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除过期和超出大小限制的缓存项
|
||||||
|
for _, key := range keysToDelete {
|
||||||
|
if item, ok := cm.items.Load(key); ok {
|
||||||
|
cacheItem := item.(*CacheItem)
|
||||||
|
os.Remove(cacheItem.FilePath)
|
||||||
|
cm.items.Delete(key)
|
||||||
|
log.Printf("[Cache] Removed expired item: %s", key.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes 格式化字节大小
|
||||||
|
func formatBytes(bytes int64) string {
|
||||||
|
const (
|
||||||
|
KB = 1024
|
||||||
|
MB = 1024 * KB
|
||||||
|
GB = 1024 * MB
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes >= GB:
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
||||||
|
case bytes >= MB:
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
|
||||||
|
case bytes >= KB:
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats 获取缓存统计信息
|
||||||
|
func (cm *CacheManager) GetStats() CacheStats {
|
||||||
|
var totalItems int
|
||||||
|
var totalSize int64
|
||||||
|
|
||||||
|
cm.items.Range(func(_, value interface{}) bool {
|
||||||
|
item := value.(*CacheItem)
|
||||||
|
totalItems++
|
||||||
|
totalSize += item.Size
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
hitCount := cm.hitCount.Load()
|
||||||
|
missCount := cm.missCount.Load()
|
||||||
|
totalRequests := hitCount + missCount
|
||||||
|
hitRate := float64(0)
|
||||||
|
if totalRequests > 0 {
|
||||||
|
hitRate = float64(hitCount) / float64(totalRequests) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return CacheStats{
|
||||||
|
TotalItems: totalItems,
|
||||||
|
TotalSize: totalSize,
|
||||||
|
HitCount: hitCount,
|
||||||
|
MissCount: missCount,
|
||||||
|
HitRate: hitRate,
|
||||||
|
BytesSaved: cm.bytesSaved.Load(),
|
||||||
|
Enabled: cm.enabled.Load(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled 设置缓存开关状态
|
||||||
|
func (cm *CacheManager) SetEnabled(enabled bool) {
|
||||||
|
cm.enabled.Store(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCache 清空缓存
|
||||||
|
func (cm *CacheManager) ClearCache() error {
|
||||||
|
// 删除所有缓存文件
|
||||||
|
var keysToDelete []CacheKey
|
||||||
|
cm.items.Range(func(key, value interface{}) bool {
|
||||||
|
cacheKey := key.(CacheKey)
|
||||||
|
item := value.(*CacheItem)
|
||||||
|
os.Remove(item.FilePath)
|
||||||
|
keysToDelete = append(keysToDelete, cacheKey)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清除缓存项
|
||||||
|
for _, key := range keysToDelete {
|
||||||
|
cm.items.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置统计信息
|
||||||
|
cm.hitCount.Store(0)
|
||||||
|
cm.missCount.Store(0)
|
||||||
|
cm.bytesSaved.Store(0)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
105
internal/handler/cache_admin.go
Normal file
105
internal/handler/cache_admin.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"proxy-go/internal/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CacheAdminHandler struct {
|
||||||
|
proxyCache *cache.CacheManager
|
||||||
|
mirrorCache *cache.CacheManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCacheAdminHandler(proxyCache, mirrorCache *cache.CacheManager) *CacheAdminHandler {
|
||||||
|
return &CacheAdminHandler{
|
||||||
|
proxyCache: proxyCache,
|
||||||
|
mirrorCache: mirrorCache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCacheStats 获取缓存统计信息
|
||||||
|
func (h *CacheAdminHandler) GetCacheStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := map[string]cache.CacheStats{
|
||||||
|
"proxy": h.proxyCache.GetStats(),
|
||||||
|
"mirror": h.mirrorCache.GetStats(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCacheEnabled 设置缓存开关状态
|
||||||
|
func (h *CacheAdminHandler) SetCacheEnabled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"` // "proxy" 或 "mirror"
|
||||||
|
Enabled bool `json:"enabled"` // true 或 false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Type {
|
||||||
|
case "proxy":
|
||||||
|
h.proxyCache.SetEnabled(req.Enabled)
|
||||||
|
case "mirror":
|
||||||
|
h.mirrorCache.SetEnabled(req.Enabled)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCache 清空缓存
|
||||||
|
func (h *CacheAdminHandler) ClearCache(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Type string `json:"type"` // "proxy", "mirror" 或 "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch req.Type {
|
||||||
|
case "proxy":
|
||||||
|
err = h.proxyCache.ClearCache()
|
||||||
|
case "mirror":
|
||||||
|
err = h.mirrorCache.ClearCache()
|
||||||
|
case "all":
|
||||||
|
err = h.proxyCache.ClearCache()
|
||||||
|
if err == nil {
|
||||||
|
err = h.mirrorCache.ClearCache()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.Error(w, "Invalid cache type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to clear cache: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
@ -33,11 +33,6 @@ func (h *ConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConfigPage 处理配置页面请求
|
|
||||||
func (h *ConfigHandler) handleConfigPage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.ServeFile(w, r, "web/templates/config.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetConfig 处理获取配置请求
|
// handleGetConfig 处理获取配置请求
|
||||||
func (h *ConfigHandler) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
|
func (h *ConfigHandler) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"proxy-go/internal/cache"
|
||||||
"proxy-go/internal/metrics"
|
"proxy-go/internal/metrics"
|
||||||
"proxy-go/internal/utils"
|
"proxy-go/internal/utils"
|
||||||
"strings"
|
"strings"
|
||||||
@ -14,6 +15,7 @@ import (
|
|||||||
|
|
||||||
type MirrorProxyHandler struct {
|
type MirrorProxyHandler struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
Cache *cache.CacheManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMirrorProxyHandler() *MirrorProxyHandler {
|
func NewMirrorProxyHandler() *MirrorProxyHandler {
|
||||||
@ -23,11 +25,18 @@ func NewMirrorProxyHandler() *MirrorProxyHandler {
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化缓存管理器
|
||||||
|
cacheManager, err := cache.NewCacheManager("data/mirror_cache")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Cache] Failed to initialize mirror cache manager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &MirrorProxyHandler{
|
return &MirrorProxyHandler{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
|
Cache: cacheManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +116,23 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
proxyReq.Header.Set("Host", parsedURL.Host)
|
proxyReq.Header.Set("Host", parsedURL.Host)
|
||||||
proxyReq.Host = parsedURL.Host
|
proxyReq.Host = parsedURL.Host
|
||||||
|
|
||||||
|
// 检查是否可以使用缓存
|
||||||
|
if r.Method == http.MethodGet && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
||||||
|
// 从缓存提供响应
|
||||||
|
w.Header().Set("Content-Type", item.ContentType)
|
||||||
|
w.Header().Set("Proxy-Go-Cache", "HIT")
|
||||||
|
if notModified {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, item.FilePath)
|
||||||
|
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(startTime), item.Size, utils.GetClientIP(r), r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
resp, err := h.client.Do(proxyReq)
|
resp, err := h.client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -118,25 +144,42 @@ func (h *MirrorProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 读取响应体
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error reading response", http.StatusInternalServerError)
|
||||||
|
log.Printf("Error reading response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是GET请求且响应成功,尝试缓存
|
||||||
|
if r.Method == http.MethodGet && resp.StatusCode == http.StatusOK && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if _, err := h.Cache.Put(cacheKey, resp, body); err != nil {
|
||||||
|
log.Printf("[Cache] Failed to cache %s: %v", actualURL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 复制响应头
|
// 复制响应头
|
||||||
copyHeader(w.Header(), resp.Header)
|
copyHeader(w.Header(), resp.Header)
|
||||||
|
w.Header().Set("Proxy-Go-Cache", "MISS")
|
||||||
|
|
||||||
// 设置状态码
|
// 设置状态码
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
// 复制响应体
|
// 写入响应体
|
||||||
bytesCopied, err := io.Copy(w, resp.Body)
|
written, err := w.Write(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error copying response: %v", err)
|
log.Printf("Error writing response: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录访问日志
|
// 记录访问日志
|
||||||
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %s",
|
log.Printf("| %-6s | %3d | %12s | %15s | %10s | %-30s | %s",
|
||||||
r.Method, resp.StatusCode, time.Since(startTime),
|
r.Method, resp.StatusCode, time.Since(startTime),
|
||||||
utils.GetClientIP(r), utils.FormatBytes(bytesCopied),
|
utils.GetClientIP(r), utils.FormatBytes(int64(written)),
|
||||||
utils.GetRequestSource(r), actualURL)
|
utils.GetRequestSource(r), actualURL)
|
||||||
|
|
||||||
// 记录统计信息
|
// 记录统计信息
|
||||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), bytesCopied, utils.GetClientIP(r), r)
|
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(startTime), int64(written), utils.GetClientIP(r), r)
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"proxy-go/internal/cache"
|
||||||
"proxy-go/internal/config"
|
"proxy-go/internal/config"
|
||||||
"proxy-go/internal/metrics"
|
"proxy-go/internal/metrics"
|
||||||
"proxy-go/internal/utils"
|
"proxy-go/internal/utils"
|
||||||
@ -17,7 +18,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -30,15 +30,6 @@ const (
|
|||||||
backendServTimeout = 40 * time.Second
|
backendServTimeout = 40 * time.Second
|
||||||
idleConnTimeout = 120 * time.Second
|
idleConnTimeout = 120 * time.Second
|
||||||
tlsHandshakeTimeout = 10 * time.Second
|
tlsHandshakeTimeout = 10 * time.Second
|
||||||
|
|
||||||
// 限流相关常量
|
|
||||||
globalRateLimit = 2000
|
|
||||||
globalBurstLimit = 500
|
|
||||||
perHostRateLimit = 200
|
|
||||||
perHostBurstLimit = 100
|
|
||||||
perIPRateLimit = 50
|
|
||||||
perIPBurstLimit = 20
|
|
||||||
cleanupInterval = 10 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 统一的缓冲池
|
// 统一的缓冲池
|
||||||
@ -48,17 +39,6 @@ var bufferPool = sync.Pool{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// getBuffer 获取缓冲区
|
|
||||||
func getBuffer() (*bytes.Buffer, func()) {
|
|
||||||
buf := bufferPool.Get().(*bytes.Buffer)
|
|
||||||
buf.Reset()
|
|
||||||
return buf, func() {
|
|
||||||
if buf != nil {
|
|
||||||
bufferPool.Put(buf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加 hop-by-hop 头部映射
|
// 添加 hop-by-hop 头部映射
|
||||||
var hopHeadersMap = make(map[string]bool)
|
var hopHeadersMap = make(map[string]bool)
|
||||||
|
|
||||||
@ -82,115 +62,14 @@ func init() {
|
|||||||
// ErrorHandler 定义错误处理函数类型
|
// ErrorHandler 定义错误处理函数类型
|
||||||
type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)
|
type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)
|
||||||
|
|
||||||
// RateLimiter 定义限流器接口
|
|
||||||
type RateLimiter interface {
|
|
||||||
Allow() bool
|
|
||||||
Clean(now time.Time)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限流管理器
|
|
||||||
type rateLimitManager struct {
|
|
||||||
globalLimiter *rate.Limiter
|
|
||||||
hostLimiters *sync.Map // host -> *rate.Limiter
|
|
||||||
ipLimiters *sync.Map // IP -> *rate.Limiter
|
|
||||||
lastCleanup time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新的限流管理器
|
|
||||||
func newRateLimitManager() *rateLimitManager {
|
|
||||||
manager := &rateLimitManager{
|
|
||||||
globalLimiter: rate.NewLimiter(rate.Limit(globalRateLimit), globalBurstLimit),
|
|
||||||
hostLimiters: &sync.Map{},
|
|
||||||
ipLimiters: &sync.Map{},
|
|
||||||
lastCleanup: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动清理协程
|
|
||||||
go manager.cleanupLoop()
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *rateLimitManager) cleanupLoop() {
|
|
||||||
ticker := time.NewTicker(cleanupInterval)
|
|
||||||
for range ticker.C {
|
|
||||||
now := time.Now()
|
|
||||||
m.cleanup(now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *rateLimitManager) cleanup(now time.Time) {
|
|
||||||
m.hostLimiters.Range(func(key, value interface{}) bool {
|
|
||||||
if now.Sub(m.lastCleanup) > cleanupInterval {
|
|
||||||
m.hostLimiters.Delete(key)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
m.ipLimiters.Range(func(key, value interface{}) bool {
|
|
||||||
if now.Sub(m.lastCleanup) > cleanupInterval {
|
|
||||||
m.ipLimiters.Delete(key)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
m.lastCleanup = now
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *rateLimitManager) getHostLimiter(host string) *rate.Limiter {
|
|
||||||
if limiter, exists := m.hostLimiters.Load(host); exists {
|
|
||||||
return limiter.(*rate.Limiter)
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter := rate.NewLimiter(rate.Limit(perHostRateLimit), perHostBurstLimit)
|
|
||||||
m.hostLimiters.Store(host, limiter)
|
|
||||||
return limiter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *rateLimitManager) getIPLimiter(ip string) *rate.Limiter {
|
|
||||||
if limiter, exists := m.ipLimiters.Load(ip); exists {
|
|
||||||
return limiter.(*rate.Limiter)
|
|
||||||
}
|
|
||||||
|
|
||||||
limiter := rate.NewLimiter(rate.Limit(perIPRateLimit), perIPBurstLimit)
|
|
||||||
m.ipLimiters.Store(ip, limiter)
|
|
||||||
return limiter
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否允许请求
|
|
||||||
func (m *rateLimitManager) allowRequest(r *http.Request) error {
|
|
||||||
// 全局限流检查
|
|
||||||
if !m.globalLimiter.Allow() {
|
|
||||||
return fmt.Errorf("global rate limit exceeded")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Host限流检查
|
|
||||||
host := r.Host
|
|
||||||
if host != "" {
|
|
||||||
if !m.getHostLimiter(host).Allow() {
|
|
||||||
return fmt.Errorf("host rate limit exceeded for %s", host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IP限流检查
|
|
||||||
ip := utils.GetClientIP(r)
|
|
||||||
if ip != "" {
|
|
||||||
if !m.getIPLimiter(ip).Allow() {
|
|
||||||
return fmt.Errorf("ip rate limit exceeded for %s", ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProxyHandler struct {
|
type ProxyHandler struct {
|
||||||
pathMap map[string]config.PathConfig
|
pathMap map[string]config.PathConfig
|
||||||
client *http.Client
|
client *http.Client
|
||||||
limiter *rate.Limiter
|
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
config *config.Config
|
config *config.Config
|
||||||
auth *authManager
|
auth *authManager
|
||||||
errorHandler ErrorHandler // 添加错误处理器
|
errorHandler ErrorHandler
|
||||||
rateLimiter *rateLimitManager
|
Cache *cache.CacheManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProxyHandler 创建新的代理处理器
|
// NewProxyHandler 创建新的代理处理器
|
||||||
@ -202,12 +81,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
|||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: dialer.DialContext,
|
DialContext: dialer.DialContext,
|
||||||
MaxIdleConns: 1000, // 增加最大空闲连接数
|
MaxIdleConns: 1000,
|
||||||
MaxIdleConnsPerHost: 100, // 增加每个主机的最大空闲连接数
|
MaxIdleConnsPerHost: 100,
|
||||||
IdleConnTimeout: idleConnTimeout,
|
IdleConnTimeout: idleConnTimeout,
|
||||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
MaxConnsPerHost: 200, // 增加每个主机的最大连接数
|
MaxConnsPerHost: 200,
|
||||||
DisableKeepAlives: false,
|
DisableKeepAlives: false,
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
@ -227,6 +106,12 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
|||||||
http2Transport.StrictMaxConcurrentStreams = true
|
http2Transport.StrictMaxConcurrentStreams = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 初始化缓存管理器
|
||||||
|
cacheManager, err := cache.NewCacheManager("data/cache")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Cache] Failed to initialize cache manager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
handler := &ProxyHandler{
|
handler := &ProxyHandler{
|
||||||
pathMap: cfg.MAP,
|
pathMap: cfg.MAP,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@ -239,20 +124,14 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
limiter: rate.NewLimiter(rate.Limit(5000), 10000),
|
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
auth: newAuthManager(),
|
auth: newAuthManager(),
|
||||||
rateLimiter: newRateLimitManager(),
|
Cache: cacheManager,
|
||||||
errorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
errorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
log.Printf("[Error] %s %s -> %v", r.Method, r.URL.Path, err)
|
log.Printf("[Error] %s %s -> %v", r.Method, r.URL.Path, err)
|
||||||
if strings.Contains(err.Error(), "rate limit exceeded") {
|
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
|
||||||
w.Write([]byte("Too Many Requests"))
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("Internal Server Error"))
|
w.Write([]byte("Internal Server Error"))
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,13 +145,6 @@ func NewProxyHandler(cfg *config.Config) *ProxyHandler {
|
|||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetErrorHandler 允许自定义错误处理函数
|
|
||||||
func (h *ProxyHandler) SetErrorHandler(handler ErrorHandler) {
|
|
||||||
if handler != nil {
|
|
||||||
h.errorHandler = handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyResponse 使用缓冲方式传输数据
|
// copyResponse 使用缓冲方式传输数据
|
||||||
func copyResponse(dst io.Writer, src io.Reader, flusher http.Flusher) (int64, error) {
|
func copyResponse(dst io.Writer, src io.Reader, flusher http.Flusher) (int64, error) {
|
||||||
buf := bufferPool.Get().(*bytes.Buffer)
|
buf := bufferPool.Get().(*bytes.Buffer)
|
||||||
@ -329,12 +201,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
collector.BeginRequest()
|
collector.BeginRequest()
|
||||||
defer collector.EndRequest()
|
defer collector.EndRequest()
|
||||||
|
|
||||||
// 限流检查
|
|
||||||
if err := h.rateLimiter.allowRequest(r); err != nil {
|
|
||||||
h.errorHandler(w, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// 创建带超时的上下文
|
// 创建带超时的上下文
|
||||||
@ -457,6 +323,23 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否可以使用缓存
|
||||||
|
if r.Method == http.MethodGet && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if item, hit, notModified := h.Cache.Get(cacheKey, r); hit {
|
||||||
|
// 从缓存提供响应
|
||||||
|
w.Header().Set("Content-Type", item.ContentType)
|
||||||
|
w.Header().Set("Proxy-Go-Cache", "HIT")
|
||||||
|
if notModified {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, item.FilePath)
|
||||||
|
collector.RecordRequest(r.URL.Path, http.StatusOK, time.Since(start), item.Size, utils.GetClientIP(r), r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送代理请求
|
// 发送代理请求
|
||||||
resp, err := h.client.Do(proxyReq)
|
resp, err := h.client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -473,55 +356,37 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
copyHeader(w.Header(), resp.Header)
|
// 读取响应体到缓冲区
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
// 删除严格的 CSP
|
_, err = copyResponse(buf, resp.Body, nil)
|
||||||
w.Header().Del("Content-Security-Policy")
|
|
||||||
|
|
||||||
// 根据响应大小选择不同的处理策略
|
|
||||||
contentLength := resp.ContentLength
|
|
||||||
if contentLength > 0 && contentLength < 1<<20 { // 1MB 以下的小响应
|
|
||||||
// 获取合适大小的缓冲区
|
|
||||||
buf, putBuffer := getBuffer()
|
|
||||||
defer putBuffer()
|
|
||||||
|
|
||||||
// 使用缓冲区读取响应
|
|
||||||
_, err := io.Copy(buf, resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !isConnectionClosed(err) {
|
|
||||||
h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err))
|
h.errorHandler(w, r, fmt.Errorf("error reading response: %v", err))
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
body := buf.Bytes()
|
||||||
|
|
||||||
// 设置响应状态码并一次性写入响应
|
// 如果是GET请求且响应成功,尝试缓存
|
||||||
|
if r.Method == http.MethodGet && resp.StatusCode == http.StatusOK && h.Cache != nil {
|
||||||
|
cacheKey := h.Cache.GenerateCacheKey(r)
|
||||||
|
if _, err := h.Cache.Put(cacheKey, resp, body); err != nil {
|
||||||
|
log.Printf("[Cache] Failed to cache %s: %v", r.URL.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
copyHeader(w.Header(), resp.Header)
|
||||||
|
w.Header().Set("Proxy-Go-Cache", "MISS")
|
||||||
|
w.Header().Del("Content-Security-Policy")
|
||||||
|
|
||||||
|
// 写入响应
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
written, err := w.Write(buf.Bytes())
|
n, err := w.Write(body)
|
||||||
if err != nil {
|
|
||||||
if !isConnectionClosed(err) {
|
|
||||||
log.Printf("Error writing response: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), int64(written), utils.GetClientIP(r), r)
|
|
||||||
} else {
|
|
||||||
// 大响应使用零拷贝传输
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
var bytesCopied int64
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if f, ok := w.(http.Flusher); ok {
|
|
||||||
bytesCopied, err = copyResponse(w, resp.Body, f)
|
|
||||||
} else {
|
|
||||||
bytesCopied, err = copyResponse(w, resp.Body, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil && !isConnectionClosed(err) {
|
if err != nil && !isConnectionClosed(err) {
|
||||||
log.Printf("Error copying response: %v", err)
|
log.Printf("Error writing response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录访问日志
|
// 记录访问日志
|
||||||
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), bytesCopied, utils.GetClientIP(r), r)
|
collector.RecordRequest(r.URL.Path, resp.StatusCode, time.Since(start), int64(n), utils.GetClientIP(r), r)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyHeader(dst, src http.Header) {
|
func copyHeader(dst, src http.Header) {
|
||||||
|
@ -186,12 +186,12 @@ func (c *Collector) GetStats() map[string]interface{} {
|
|||||||
// 收集路径统计
|
// 收集路径统计
|
||||||
var pathMetrics []models.PathMetrics
|
var pathMetrics []models.PathMetrics
|
||||||
c.pathStats.Range(func(key, value interface{}) bool {
|
c.pathStats.Range(func(key, value interface{}) bool {
|
||||||
stats := value.(models.PathMetrics)
|
stats := value.(*models.PathMetrics)
|
||||||
if stats.RequestCount > 0 {
|
if stats.RequestCount > 0 {
|
||||||
avgLatencyMs := float64(stats.TotalLatency) / float64(stats.RequestCount) / float64(time.Millisecond)
|
avgLatencyMs := float64(stats.TotalLatency) / float64(stats.RequestCount) / float64(time.Millisecond)
|
||||||
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
|
stats.AvgLatency = fmt.Sprintf("%.2fms", avgLatencyMs)
|
||||||
}
|
}
|
||||||
pathMetrics = append(pathMetrics, stats)
|
pathMetrics = append(pathMetrics, *stats)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
6
main.go
6
main.go
@ -80,6 +80,12 @@ func main() {
|
|||||||
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||||
case "/admin/api/config/save":
|
case "/admin/api/config/save":
|
||||||
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
proxyHandler.AuthMiddleware(handler.NewConfigHandler(cfg).ServeHTTP)(w, r)
|
||||||
|
case "/admin/api/cache/stats":
|
||||||
|
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).GetCacheStats)(w, r)
|
||||||
|
case "/admin/api/cache/enable":
|
||||||
|
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).SetCacheEnabled)(w, r)
|
||||||
|
case "/admin/api/cache/clear":
|
||||||
|
proxyHandler.AuthMiddleware(handler.NewCacheAdminHandler(proxyHandler.Cache, mirrorHandler.Cache).ClearCache)(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
236
web/app/dashboard/cache/page.tsx
vendored
Normal file
236
web/app/dashboard/cache/page.tsx
vendored
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
|
||||||
|
interface CacheStats {
|
||||||
|
total_items: number
|
||||||
|
total_size: number
|
||||||
|
hit_count: number
|
||||||
|
miss_count: number
|
||||||
|
hit_rate: number
|
||||||
|
bytes_saved: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheData {
|
||||||
|
proxy: CacheStats
|
||||||
|
mirror: CacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number) {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let size = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CachePage() {
|
||||||
|
const [stats, setStats] = useState<CacheData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/api/cache/stats")
|
||||||
|
if (!response.ok) throw new Error("获取缓存统计失败")
|
||||||
|
const data = await response.json()
|
||||||
|
setStats(data)
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "获取缓存统计失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 立即获取一次数据
|
||||||
|
fetchStats()
|
||||||
|
|
||||||
|
// 设置定时刷新
|
||||||
|
const interval = setInterval(fetchStats, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchStats])
|
||||||
|
|
||||||
|
const handleToggleCache = async (type: "proxy" | "mirror", enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/api/cache/enable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, enabled }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("切换缓存状态失败")
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "成功",
|
||||||
|
description: `${type === "proxy" ? "代理" : "镜像"}缓存已${enabled ? "启用" : "禁用"}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "切换缓存状态失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearCache = async (type: "proxy" | "mirror" | "all") => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/api/cache/clear", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("清理缓存失败")
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "成功",
|
||||||
|
description: "缓存已清理",
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "错误",
|
||||||
|
description: error instanceof Error ? error.message : "清理缓存失败",
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium">加载中...</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">正在获取缓存统计信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button variant="outline" onClick={() => handleClearCache("all")}>
|
||||||
|
清理所有缓存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* 代理缓存 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle>代理缓存</CardTitle>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={stats?.proxy.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => handleToggleCache("proxy", checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleClearCache("proxy")}
|
||||||
|
>
|
||||||
|
清理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">缓存项数量</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.proxy.total_items ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">总大小</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.total_size ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">命中次数</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.proxy.hit_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">未命中次数</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.proxy.miss_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">命中率</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{(stats?.proxy.hit_rate ?? 0).toFixed(2)}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">节省带宽</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.proxy.bytes_saved ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 镜像缓存 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle>镜像缓存</CardTitle>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={stats?.mirror.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => handleToggleCache("mirror", checked)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleClearCache("mirror")}
|
||||||
|
>
|
||||||
|
清理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">缓存项数量</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.mirror.total_items ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">总大小</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.total_size ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">命中次数</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.mirror.hit_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">未命中次数</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{stats?.mirror.miss_count ?? 0}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">命中率</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{(stats?.mirror.hit_rate ?? 0).toFixed(2)}%</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">节省带宽</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{formatBytes(stats?.mirror.bytes_saved ?? 0)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -49,6 +49,12 @@ export function Nav() {
|
|||||||
>
|
>
|
||||||
配置
|
配置
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/cache"
|
||||||
|
className={pathname === "/dashboard/cache" ? "text-primary" : "text-muted-foreground"}
|
||||||
|
>
|
||||||
|
缓存
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" onClick={handleLogout}>
|
<Button variant="ghost" onClick={handleLogout}>
|
||||||
退出登录
|
退出登录
|
||||||
|
29
web/components/ui/switch.tsx
Normal file
29
web/components/ui/switch.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
63
web/package-lock.json
generated
63
web/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -1076,6 +1077,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.0.2",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.0",
|
||||||
|
"@radix-ui/react-use-size": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-toast": {
|
"node_modules/@radix-ui/react-toast": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
|
||||||
@ -1176,6 +1206,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-visually-hidden": {
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user