Files
subconverter-go/internal/handler/conversion.go
Rogee 7fcabe0225
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
first commit
2025-09-28 10:05:07 +08:00

940 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}