Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
940 lines
27 KiB
Go
940 lines
27 KiB
Go
package handler
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
netURL "net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/subconverter-go/internal/cache"
|
||
"github.com/subconverter-go/internal/config"
|
||
"github.com/subconverter-go/internal/conversion"
|
||
"github.com/subconverter-go/internal/logging"
|
||
)
|
||
|
||
// ConversionHandler 代理订阅转换处理器
|
||
// 处理各种代理订阅格式的转换请求
|
||
type ConversionHandler struct {
|
||
logger *logging.Logger
|
||
configMgr *config.ConfigManager
|
||
engine *conversion.ConversionEngine
|
||
cacheMgr *cache.CacheManager
|
||
}
|
||
|
||
// NewConversionHandler 创建新的转换处理器
|
||
// 返回初始化好的ConversionHandler实例
|
||
func NewConversionHandler(engine *conversion.ConversionEngine, cacheMgr *cache.CacheManager, configMgr *config.ConfigManager, logger *logging.Logger) *ConversionHandler {
|
||
return &ConversionHandler{
|
||
logger: logger,
|
||
configMgr: configMgr,
|
||
engine: engine,
|
||
cacheMgr: cacheMgr,
|
||
}
|
||
}
|
||
|
||
// HandleConversion 处理转换请求
|
||
// 支持GET和POST方法,接收订阅URL和转换参数
|
||
func (h *ConversionHandler) HandleConversion(c *fiber.Ctx) error {
|
||
h.logger.Info("Conversion request received")
|
||
|
||
// 解析请求参数
|
||
req, err := h.parseConversionRequest(c)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Failed to parse conversion request")
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"success": false,
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
|
||
// 检查缓存
|
||
if h.cacheMgr.IsEnabled() {
|
||
cacheKey := h.cacheMgr.GenerateKey(req)
|
||
if cachedResponse, exists := h.cacheMgr.Get(cacheKey); exists {
|
||
h.logger.Debugf("Cache hit for conversion request")
|
||
return h.handleResponse(c, cachedResponse, req)
|
||
}
|
||
}
|
||
|
||
// 执行转换
|
||
resp, err := h.engine.Convert(req)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Conversion failed")
|
||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||
"success": false,
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
|
||
// 缓存响应
|
||
if h.cacheMgr.IsEnabled() && resp.Success {
|
||
cacheKey := h.cacheMgr.GenerateKey(req)
|
||
if err := h.cacheMgr.Set(cacheKey, resp); err != nil {
|
||
h.logger.WithError(err).Warn("Failed to cache conversion result")
|
||
}
|
||
}
|
||
|
||
return h.handleResponse(c, resp, req)
|
||
}
|
||
|
||
// handleResponse 处理响应输出
|
||
func (h *ConversionHandler) handleResponse(c *fiber.Ctx, resp *conversion.ConversionResponse, req *conversion.ConversionRequest) error {
|
||
if !resp.Success {
|
||
status := fiber.StatusInternalServerError
|
||
if resp.ErrorCode == "VALIDATION_ERROR" {
|
||
status = fiber.StatusBadRequest
|
||
}
|
||
return c.Status(status).JSON(fiber.Map{
|
||
"success": false,
|
||
"error": resp.Error,
|
||
"message": resp.ErrorDetail,
|
||
})
|
||
}
|
||
|
||
// 根据请求类型返回响应
|
||
if req.Filename != "" {
|
||
c.Set("Content-Type", resp.ContentType)
|
||
c.Set("Content-Disposition", `attachment; filename="`+req.Filename+`"`)
|
||
return c.Status(http.StatusOK).SendString(resp.Content)
|
||
}
|
||
|
||
// 如果是API请求,返回JSON
|
||
if strings.HasPrefix(c.Path(), "/api/") {
|
||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||
"success": resp.Success,
|
||
"content": resp.Content,
|
||
"target_format": resp.TargetFormat,
|
||
"source_url": resp.SourceURL,
|
||
"node_count": resp.NodeCount,
|
||
"processing_time": resp.ProcessingTime,
|
||
"timestamp": resp.Timestamp,
|
||
"headers": resp.Headers,
|
||
"debug_info": resp.DebugInfo,
|
||
"request_options": buildRequestOptions(req),
|
||
})
|
||
}
|
||
|
||
// 默认返回纯文本
|
||
c.Set("Content-Type", resp.ContentType)
|
||
return c.Status(http.StatusOK).SendString(resp.Content)
|
||
}
|
||
|
||
// parseConversionRequest 解析转换请求
|
||
// 从GET查询参数或POST body中解析转换参数
|
||
func (h *ConversionHandler) parseConversionRequest(c *fiber.Ctx) (*conversion.ConversionRequest, error) {
|
||
req := conversion.NewConversionRequest()
|
||
req.UserAgent = c.Get("User-Agent")
|
||
config := h.configMgr.GetConfig()
|
||
req.BasePath = h.configMgr.GetBasePath()
|
||
req.Target = strings.ToLower(config.Conversion.DefaultTarget)
|
||
req.Emoji = config.Conversion.DefaultEmoji
|
||
req.UDP = config.Conversion.DefaultUDP
|
||
|
||
queryBytes := c.Request().URI().QueryString()
|
||
if err := req.FromQuery(string(queryBytes)); err != nil {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||
}
|
||
|
||
req.Target = strings.ToLower(req.Target)
|
||
|
||
if req.Target == "auto" {
|
||
resolved := resolveAutoTarget(c.Get("User-Agent"))
|
||
if resolved == "" {
|
||
resolved = strings.ToLower(config.Conversion.DefaultTarget)
|
||
if resolved == "" {
|
||
resolved = "clash"
|
||
}
|
||
}
|
||
req.Target = resolved
|
||
}
|
||
|
||
if len(req.GetSources()) == 0 {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, "subscription URL is required")
|
||
}
|
||
|
||
for _, src := range req.GetSources() {
|
||
if _, err := netURL.Parse(src); err != nil {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid subscription URL")
|
||
}
|
||
}
|
||
|
||
if c.Query("emoji") == "" {
|
||
req.Emoji = config.Conversion.DefaultEmoji
|
||
}
|
||
if c.Query("udp") == "" {
|
||
req.UDP = config.Conversion.DefaultUDP
|
||
}
|
||
|
||
// 如果是POST请求,尝试从body获取参数
|
||
if c.Method() == "POST" {
|
||
var body struct {
|
||
Target string `json:"target"`
|
||
URL string `json:"url"`
|
||
Emoji *bool `json:"emoji"`
|
||
UDP *bool `json:"udp"`
|
||
IPv6 *bool `json:"ipv6"`
|
||
Include []string `json:"include"`
|
||
Exclude []string `json:"exclude"`
|
||
Location string `json:"location"`
|
||
ConfigURL string `json:"config"`
|
||
Group string `json:"group"`
|
||
Insert *bool `json:"insert"`
|
||
Strict *bool `json:"strict"`
|
||
Compatible *bool `json:"compatible"`
|
||
Test *bool `json:"test"`
|
||
List *bool `json:"list"`
|
||
Filename string `json:"filename"`
|
||
}
|
||
|
||
if err := c.BodyParser(&body); err != nil {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, "invalid request body")
|
||
}
|
||
|
||
// 用body中的值覆盖查询参数
|
||
if body.Target != "" {
|
||
req.Target = body.Target
|
||
}
|
||
if body.URL != "" {
|
||
if err := req.SetURL(body.URL); err != nil {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||
}
|
||
}
|
||
if body.Emoji != nil {
|
||
req.Emoji = *body.Emoji
|
||
}
|
||
if body.UDP != nil {
|
||
req.UDP = *body.UDP
|
||
}
|
||
if body.IPv6 != nil {
|
||
req.IPv6 = *body.IPv6
|
||
}
|
||
if len(body.Include) > 0 {
|
||
req.Include = body.Include
|
||
}
|
||
if len(body.Exclude) > 0 {
|
||
req.Exclude = body.Exclude
|
||
}
|
||
if body.Location != "" {
|
||
req.Location = body.Location
|
||
}
|
||
if body.ConfigURL != "" {
|
||
req.ConfigURL = body.ConfigURL
|
||
}
|
||
if body.Group != "" {
|
||
req.Group = body.Group
|
||
}
|
||
if body.Insert != nil {
|
||
req.Insert = *body.Insert
|
||
}
|
||
if body.Strict != nil {
|
||
req.Strict = *body.Strict
|
||
}
|
||
if body.Compatible != nil {
|
||
req.Compatible = *body.Compatible
|
||
}
|
||
if body.Test != nil {
|
||
req.Test = *body.Test
|
||
}
|
||
if body.List != nil {
|
||
req.List = *body.List
|
||
}
|
||
if body.Filename != "" {
|
||
req.Filename = body.Filename
|
||
}
|
||
}
|
||
|
||
if err := req.Validate(); err != nil {
|
||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
||
}
|
||
|
||
return req, nil
|
||
}
|
||
|
||
// HandleConvertLegacy 处理传统格式的转换请求
|
||
// 为了与原C++版本兼容
|
||
func (h *ConversionHandler) HandleConvertLegacy(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy conversion request received")
|
||
|
||
// 转发到新的处理器
|
||
return h.HandleConversion(c)
|
||
}
|
||
|
||
// HandleSub 处理订阅转换请求
|
||
// 简化的订阅转换接口
|
||
func (h *ConversionHandler) HandleSub(c *fiber.Ctx) error {
|
||
h.logger.Info("Subscription conversion request received")
|
||
|
||
// 解析查询参数
|
||
target := c.Query("target")
|
||
if target == "" {
|
||
target = "clash" // 默认目标格式
|
||
}
|
||
|
||
url := c.Query("url")
|
||
if url == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": "subscription URL is required",
|
||
})
|
||
}
|
||
|
||
// 创建简单的转换请求
|
||
req := conversion.NewConversionRequest()
|
||
req.Target = target
|
||
if err := req.SetURL(url); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
req.Emoji = false
|
||
req.UDP = false
|
||
|
||
if err := req.Validate(); err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
|
||
// 执行转换
|
||
resp, err := h.engine.Convert(req)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Subscription conversion failed")
|
||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||
"error": err.Error(),
|
||
})
|
||
}
|
||
|
||
// 直接返回配置内容
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.SendString(resp.Content)
|
||
}
|
||
|
||
// GetSupportedTargets 获取支持的目标格式列表
|
||
func (h *ConversionHandler) GetSupportedTargets(c *fiber.Ctx) error {
|
||
h.logger.Info("Get supported targets request received")
|
||
|
||
config := h.configMgr.GetConfig()
|
||
targets := config.Conversion.SupportedTargets
|
||
|
||
return c.JSON(fiber.Map{
|
||
"supported_targets": targets,
|
||
"default_target": config.Conversion.DefaultTarget,
|
||
})
|
||
}
|
||
|
||
// ValidateURL 验证订阅URL的有效性
|
||
func (h *ConversionHandler) ValidateURL(c *fiber.Ctx) error {
|
||
h.logger.Info("Validate URL request received")
|
||
|
||
urlStr := c.Query("url")
|
||
if urlStr == "" {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": "URL is required",
|
||
})
|
||
}
|
||
|
||
// 验证URL格式
|
||
parsedURL, err := netURL.Parse(urlStr)
|
||
if err != nil {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": "invalid URL format",
|
||
})
|
||
}
|
||
|
||
// 检查协议是否支持
|
||
supportedSchemes := map[string]bool{
|
||
"http": true,
|
||
"https": true,
|
||
"ss": true,
|
||
"ssr": true,
|
||
"vmess": true,
|
||
"trojan": true,
|
||
}
|
||
|
||
if !supportedSchemes[parsedURL.Scheme] {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"error": "unsupported URL scheme",
|
||
})
|
||
}
|
||
|
||
return c.JSON(fiber.Map{
|
||
"valid": true,
|
||
"url": urlStr,
|
||
"scheme": parsedURL.Scheme,
|
||
"host": parsedURL.Host,
|
||
})
|
||
}
|
||
|
||
// HandleCacheStats 获取缓存统计信息
|
||
func (h *ConversionHandler) HandleCacheStats(c *fiber.Ctx) error {
|
||
h.logger.Info("Cache stats request received")
|
||
|
||
if !h.cacheMgr.IsEnabled() {
|
||
return c.JSON(fiber.Map{
|
||
"enabled": false,
|
||
"message": "Cache is disabled",
|
||
})
|
||
}
|
||
|
||
stats := h.cacheMgr.GetStats()
|
||
return c.JSON(fiber.Map{
|
||
"enabled": true,
|
||
"hits": stats.Hits,
|
||
"misses": stats.Misses,
|
||
"evictions": stats.Evictions,
|
||
"size": stats.Size,
|
||
"total_size": stats.TotalSize,
|
||
"hit_rate": stats.HitRate,
|
||
})
|
||
}
|
||
|
||
// HandleCacheClear 清除缓存
|
||
func (h *ConversionHandler) HandleCacheClear(c *fiber.Ctx) error {
|
||
h.logger.Info("Cache clear request received")
|
||
|
||
if !h.cacheMgr.IsEnabled() {
|
||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||
"success": false,
|
||
"error": "Cache is disabled",
|
||
})
|
||
}
|
||
|
||
h.cacheMgr.Clear()
|
||
return c.JSON(fiber.Map{
|
||
"success": true,
|
||
"message": "Cache cleared successfully",
|
||
})
|
||
}
|
||
|
||
// HandleGetConfig 获取配置信息
|
||
func (h *ConversionHandler) HandleGetConfig(c *fiber.Ctx) error {
|
||
h.logger.Info("Get config request received")
|
||
|
||
config := h.configMgr.GetConfig()
|
||
return c.JSON(fiber.Map{
|
||
"success": true,
|
||
"config": config,
|
||
})
|
||
}
|
||
|
||
// HandleUpdateConfig 更新配置
|
||
func (h *ConversionHandler) HandleUpdateConfig(c *fiber.Ctx) error {
|
||
h.logger.Info("Update config request received")
|
||
|
||
mode := strings.ToLower(c.Query("type"))
|
||
if mode != "form" && mode != "direct" {
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusNotImplemented).SendString("Not Implemented\n")
|
||
}
|
||
|
||
content := c.Body()
|
||
if len(content) == 0 {
|
||
h.logger.Warn("Empty configuration payload received")
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusBadRequest).SendString("Invalid request\n")
|
||
}
|
||
|
||
configPath := h.configMgr.GetFilePath()
|
||
if configPath == "" {
|
||
h.logger.Error("Configuration path is not set; cannot update config")
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusInternalServerError).SendString("Config path not configured\n")
|
||
}
|
||
|
||
if err := os.WriteFile(configPath, content, 0o644); err != nil {
|
||
h.logger.WithError(err).Error("Failed to write configuration file")
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to update configuration\n")
|
||
}
|
||
|
||
if err := h.configMgr.ReloadConfig(); err != nil {
|
||
h.logger.WithError(err).Error("Failed to reload configuration after update")
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to reload configuration\n")
|
||
}
|
||
|
||
// 清除缓存以避免使用旧配置
|
||
if h.cacheMgr != nil {
|
||
h.cacheMgr.Clear()
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(http.StatusOK).SendString("done\n")
|
||
}
|
||
|
||
// HandleReadConf 重新加载配置文件
|
||
func (h *ConversionHandler) HandleReadConf(c *fiber.Ctx) error {
|
||
h.logger.Info("Read config request received")
|
||
|
||
if err := h.configMgr.ReloadConfig(); err != nil {
|
||
h.logger.WithError(err).Error("Failed to reload configuration")
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(fiber.StatusInternalServerError).SendString("Failed to reload configuration\n")
|
||
}
|
||
|
||
if h.cacheMgr != nil {
|
||
// 配置可能改变缓存策略,清空缓存以保持一致
|
||
h.cacheMgr.Clear()
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(http.StatusOK).SendString("done\n")
|
||
}
|
||
|
||
// HandleRefreshRules 重新加载模板或规则资源
|
||
func (h *ConversionHandler) HandleRefreshRules(c *fiber.Ctx) error {
|
||
h.logger.Info("Refresh rules request received")
|
||
|
||
if h.configMgr.HasTemplateManager() {
|
||
if err := h.configMgr.ReloadTemplates(); err != nil {
|
||
h.logger.WithError(err).Warn("Failed to reload templates during refresh rules")
|
||
}
|
||
} else {
|
||
h.logger.Warn("Template manager not available, skipping refresh")
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(http.StatusOK).SendString("done\n")
|
||
}
|
||
|
||
// HandleFlushCache 清空订阅缓存
|
||
func (h *ConversionHandler) HandleFlushCache(c *fiber.Ctx) error {
|
||
h.logger.Info("Flush cache request received")
|
||
|
||
if h.cacheMgr != nil {
|
||
h.cacheMgr.Clear()
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain")
|
||
return c.Status(http.StatusOK).SendString("done")
|
||
}
|
||
|
||
// HandleSubscription 处理订阅转换请求(简化版)
|
||
func (h *ConversionHandler) HandleSubscription(c *fiber.Ctx) error {
|
||
h.logger.Info("Subscription request received")
|
||
|
||
req, err := h.parseConversionRequest(c)
|
||
if err != nil {
|
||
var fiberErr *fiber.Error
|
||
if errors.As(err, &fiberErr) {
|
||
h.logger.WithError(err).Warn("Invalid subscription request")
|
||
return sendPlainText(c, fiberErr.Code, fiberErr.Message)
|
||
}
|
||
h.logger.WithError(err).Warn("Invalid subscription request")
|
||
return sendPlainText(c, fiber.StatusBadRequest, err.Error())
|
||
}
|
||
|
||
resp, convErr := h.engine.Convert(req)
|
||
if convErr != nil {
|
||
h.logger.WithError(convErr).Error("Subscription conversion failed")
|
||
return sendPlainText(c, fiber.StatusInternalServerError, convErr.Error())
|
||
}
|
||
if !resp.Success {
|
||
h.logger.WithField("error", resp.Error).Warn("Subscription conversion unsuccessful")
|
||
return sendPlainText(c, fiber.StatusInternalServerError, resp.Error)
|
||
}
|
||
|
||
contentType := resp.ContentType
|
||
if contentType == "" {
|
||
contentType = "text/plain; charset=utf-8"
|
||
}
|
||
c.Set("Content-Type", contentType)
|
||
|
||
if req.Filename != "" {
|
||
disposition := fmt.Sprintf("attachment; filename=\"%s\"; filename*=utf-8''%s", req.Filename, netURL.QueryEscape(req.Filename))
|
||
c.Set("Content-Disposition", disposition)
|
||
}
|
||
|
||
if c.Method() == fiber.MethodHead {
|
||
c.Status(http.StatusOK)
|
||
c.Response().SetBodyRaw(nil)
|
||
return nil
|
||
}
|
||
|
||
return c.Status(http.StatusOK).SendString(resp.Content)
|
||
}
|
||
|
||
// HandleSubToClashR 支持 /sub2clashr 兼容端点
|
||
func (h *ConversionHandler) HandleSubToClashR(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy sub2clashr request received")
|
||
|
||
sublink := c.Query("sublink")
|
||
if sublink == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
if strings.EqualFold(sublink, "sublink") {
|
||
return sendPlainText(c, http.StatusBadRequest, "Please insert your subscription link instead of clicking the default link.")
|
||
}
|
||
|
||
req := conversion.NewConversionRequest()
|
||
req.Target = "clashr"
|
||
if err := req.SetURL(sublink); err != nil {
|
||
return sendPlainText(c, http.StatusBadRequest, err.Error())
|
||
}
|
||
config := h.configMgr.GetConfig()
|
||
req.Emoji = config.Conversion.DefaultEmoji
|
||
req.UDP = config.Conversion.DefaultUDP
|
||
|
||
if err := req.Validate(); err != nil {
|
||
return sendPlainText(c, http.StatusBadRequest, err.Error())
|
||
}
|
||
|
||
resp, err := h.engine.Convert(req)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Sub2ClashR conversion failed")
|
||
return sendPlainText(c, http.StatusInternalServerError, err.Error())
|
||
}
|
||
if !resp.Success {
|
||
h.logger.WithField("error", resp.Error).Error("Sub2ClashR conversion unsuccessful")
|
||
return sendPlainText(c, http.StatusInternalServerError, resp.Error)
|
||
}
|
||
|
||
contentType := resp.ContentType
|
||
if contentType == "" {
|
||
contentType = "text/plain; charset=utf-8"
|
||
}
|
||
c.Set("Content-Type", contentType)
|
||
return c.Status(http.StatusOK).SendString(resp.Content)
|
||
}
|
||
|
||
// HandleSurgeToClash 支持 /surge2clash 兼容端点
|
||
func (h *ConversionHandler) HandleSurgeToClash(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy surge2clash request received")
|
||
|
||
link := c.Query("link")
|
||
if link == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
if strings.EqualFold(link, "link") {
|
||
return sendPlainText(c, http.StatusBadRequest, "Please insert your subscription link instead of clicking the default link.")
|
||
}
|
||
|
||
req := conversion.NewConversionRequest()
|
||
req.Target = "clash"
|
||
if err := req.SetURL(link); err != nil {
|
||
return sendPlainText(c, http.StatusBadRequest, err.Error())
|
||
}
|
||
config := h.configMgr.GetConfig()
|
||
req.Emoji = config.Conversion.DefaultEmoji
|
||
req.UDP = config.Conversion.DefaultUDP
|
||
|
||
if err := req.Validate(); err != nil {
|
||
return sendPlainText(c, http.StatusBadRequest, err.Error())
|
||
}
|
||
|
||
resp, err := h.engine.Convert(req)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Surge2Clash conversion failed")
|
||
return sendPlainText(c, http.StatusInternalServerError, err.Error())
|
||
}
|
||
if !resp.Success {
|
||
h.logger.WithField("error", resp.Error).Error("Surge2Clash conversion unsuccessful")
|
||
return sendPlainText(c, http.StatusInternalServerError, resp.Error)
|
||
}
|
||
|
||
contentType := resp.ContentType
|
||
if contentType == "" {
|
||
contentType = "text/plain; charset=utf-8"
|
||
}
|
||
c.Set("Content-Type", contentType)
|
||
return c.Status(http.StatusOK).SendString(resp.Content)
|
||
}
|
||
|
||
// HandleGetRemote 获取远程内容,兼容 /get 端点
|
||
func (h *ConversionHandler) HandleGetRemote(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy get request received")
|
||
|
||
rawURL := c.Query("url")
|
||
if rawURL == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
parsed, err := netURL.Parse(rawURL)
|
||
if err != nil || parsed.Scheme == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
scheme := strings.ToLower(parsed.Scheme)
|
||
if scheme != "http" && scheme != "https" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Failed to build remote request")
|
||
return sendPlainText(c, http.StatusInternalServerError, "Failed to fetch remote content")
|
||
}
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Failed to fetch remote content")
|
||
return sendPlainText(c, http.StatusBadGateway, "Failed to fetch remote content")
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
h.logger.WithError(err).Error("Failed to read remote response")
|
||
return sendPlainText(c, http.StatusInternalServerError, "Failed to fetch remote content")
|
||
}
|
||
|
||
contentType := resp.Header.Get("Content-Type")
|
||
if contentType == "" {
|
||
contentType = "text/plain; charset=utf-8"
|
||
}
|
||
c.Set("Content-Type", contentType)
|
||
return c.Status(resp.StatusCode).Send(body)
|
||
}
|
||
|
||
// HandleGetLocal 读取本地文件,兼容 /getlocal 端点
|
||
func (h *ConversionHandler) HandleGetLocal(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy getlocal request received")
|
||
|
||
pathParam := c.Query("path")
|
||
if pathParam == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
resolved := h.resolvePath(pathParam)
|
||
data, err := os.ReadFile(resolved)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return sendPlainText(c, http.StatusNotFound, "Not Found")
|
||
}
|
||
h.logger.WithError(err).Errorf("Failed to read local file: %s", resolved)
|
||
return sendPlainText(c, http.StatusInternalServerError, "Failed to read file")
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||
return c.Status(http.StatusOK).Send(data)
|
||
}
|
||
|
||
func (h *ConversionHandler) resolvePath(p string) string {
|
||
if filepath.IsAbs(p) {
|
||
return p
|
||
}
|
||
base := h.configMgr.GetBasePath()
|
||
if base == "" {
|
||
return p
|
||
}
|
||
return filepath.Join(base, p)
|
||
}
|
||
|
||
func sendPlainText(c *fiber.Ctx, status int, body string) error {
|
||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||
return c.Status(status).SendString(body)
|
||
}
|
||
|
||
var errProfileSectionMissing = errors.New("profile section missing")
|
||
|
||
func extractProfileToken(data []byte) (string, error) {
|
||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||
inProfile := false
|
||
foundProfile := false
|
||
token := ""
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||
inProfile = strings.EqualFold(line, "[profile]")
|
||
if inProfile {
|
||
foundProfile = true
|
||
}
|
||
continue
|
||
}
|
||
if !inProfile {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(parts[0])
|
||
value := strings.TrimSpace(parts[1])
|
||
if strings.EqualFold(key, "profile_token") {
|
||
token = value
|
||
}
|
||
}
|
||
if err := scanner.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
if !foundProfile {
|
||
return "", errProfileSectionMissing
|
||
}
|
||
return token, nil
|
||
}
|
||
|
||
func buildRequestOptions(req *conversion.ConversionRequest) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"url": req.SourceSummary(),
|
||
"emoji": req.Emoji,
|
||
"udp": req.UDP,
|
||
"ipv6": req.IPv6,
|
||
"insert": req.Insert,
|
||
"strict": req.Strict,
|
||
"list": req.List,
|
||
"group": req.Group,
|
||
"filename": req.Filename,
|
||
"config": req.ConfigURL,
|
||
"include": strings.Join(req.Include, ","),
|
||
"exclude": strings.Join(req.Exclude, ","),
|
||
"compatible": req.Compatible,
|
||
"append_type": req.AppendType,
|
||
"tfo": req.TFO,
|
||
"script": req.Script,
|
||
"scv": req.SkipCert,
|
||
"fdn": req.FilterDeprecated,
|
||
"expand": req.ExpandRules,
|
||
"append_info": req.AppendInfo,
|
||
"prepend": req.Prepend,
|
||
"classic": req.Classic,
|
||
"tls13": req.TLS13,
|
||
"add_emoji": req.AddEmoji,
|
||
"remove_emoji": req.RemoveEmoji,
|
||
"upload": req.Upload,
|
||
"upload_path": req.UploadPath,
|
||
"rename": strings.Join(req.RenameRules, "`"),
|
||
"filter_script": req.FilterScript,
|
||
"dev_id": req.DeviceID,
|
||
"interval": req.Interval,
|
||
"groups": strings.Join(req.Groups, "@"),
|
||
"ruleset": strings.Join(req.Rulesets, "@"),
|
||
"providers": strings.Join(req.Providers, "@"),
|
||
"emoji_rule": strings.Join(req.EmojiRules, "`"),
|
||
}
|
||
}
|
||
|
||
func resolveAutoTarget(userAgent string) string {
|
||
ua := strings.ToLower(strings.TrimSpace(userAgent))
|
||
if ua == "" {
|
||
return ""
|
||
}
|
||
|
||
switch {
|
||
case strings.Contains(ua, "surge"):
|
||
return "surge"
|
||
case strings.Contains(ua, "quantumult x"), strings.Contains(ua, "quanx"):
|
||
return "quanx"
|
||
case strings.Contains(ua, "loon"):
|
||
return "loon"
|
||
case strings.Contains(ua, "surfboard"):
|
||
return "surfboard"
|
||
case strings.Contains(ua, "clash"), strings.Contains(ua, "stash"), strings.Contains(ua, "shadowrocket"):
|
||
return "clash"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// HandleGetRulesetLegacy 获取规则集内容,兼容 /getruleset 端点
|
||
func (h *ConversionHandler) HandleGetRulesetLegacy(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy getruleset request received")
|
||
|
||
encoded := c.Query("url")
|
||
if encoded == "" {
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
h.logger.WithError(err).Warn("Failed to decode ruleset url parameter")
|
||
return sendPlainText(c, http.StatusBadRequest, "Invalid request!")
|
||
}
|
||
|
||
entries := strings.Split(string(decoded), "|")
|
||
var builder strings.Builder
|
||
for _, entry := range entries {
|
||
entry = strings.TrimSpace(entry)
|
||
if entry == "" {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(entry, ",", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
path := strings.TrimSpace(parts[1])
|
||
resolved := h.resolvePath(path)
|
||
content, err := os.ReadFile(resolved)
|
||
if err != nil {
|
||
h.logger.WithError(err).Warnf("Failed to read ruleset file: %s", resolved)
|
||
continue
|
||
}
|
||
builder.Write(content)
|
||
if len(content) > 0 && content[len(content)-1] != '\n' {
|
||
builder.WriteByte('\n')
|
||
}
|
||
}
|
||
|
||
result := builder.String()
|
||
if strings.TrimSpace(result) == "" {
|
||
return sendPlainText(c, http.StatusNotFound, "Ruleset not found")
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||
return c.Status(http.StatusOK).SendString(result)
|
||
}
|
||
|
||
// HandleGetProfile 获取配置文件,兼容 /getprofile 端点
|
||
func (h *ConversionHandler) HandleGetProfile(c *fiber.Ctx) error {
|
||
h.logger.Info("Legacy getprofile request received")
|
||
|
||
nameParam := c.Query("name")
|
||
token := c.Query("token")
|
||
if token == "" || nameParam == "" {
|
||
return sendPlainText(c, http.StatusForbidden, "Forbidden")
|
||
}
|
||
|
||
profiles := strings.Split(nameParam, "|")
|
||
if len(profiles) == 0 || strings.TrimSpace(profiles[0]) == "" {
|
||
return sendPlainText(c, http.StatusForbidden, "Forbidden")
|
||
}
|
||
|
||
primaryPath := h.resolvePath(strings.TrimSpace(profiles[0]))
|
||
data, err := os.ReadFile(primaryPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return sendPlainText(c, http.StatusNotFound, "Profile not found")
|
||
}
|
||
h.logger.WithError(err).Errorf("Failed to read profile %s", primaryPath)
|
||
return sendPlainText(c, http.StatusInternalServerError, "Broken profile!")
|
||
}
|
||
|
||
profileToken, err := extractProfileToken(data)
|
||
if err != nil {
|
||
if errors.Is(err, errProfileSectionMissing) {
|
||
h.logger.Warnf("Profile %s missing [Profile] section", primaryPath)
|
||
return sendPlainText(c, http.StatusForbidden, "Forbidden")
|
||
}
|
||
h.logger.WithError(err).Errorf("Failed to parse profile %s", primaryPath)
|
||
return sendPlainText(c, http.StatusInternalServerError, "Broken profile!")
|
||
}
|
||
|
||
if profileToken != "" {
|
||
if token != profileToken {
|
||
return sendPlainText(c, http.StatusForbidden, "Forbidden")
|
||
}
|
||
} else {
|
||
security := h.configMgr.GetSecurityConfig()
|
||
if len(security.AccessTokens) > 0 && token != security.AccessTokens[0] {
|
||
return sendPlainText(c, http.StatusForbidden, "Forbidden")
|
||
}
|
||
}
|
||
|
||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||
return c.Status(http.StatusOK).Send(data)
|
||
}
|