├── deploy ├── README.md └── docker-compose.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── workflows │ ├── changelog.yml │ └── release.yml ├── internal ├── models │ ├── auth │ │ ├── default.go │ │ └── auth.go │ ├── notify │ │ ├── default.go │ │ └── notify.go │ ├── config │ │ ├── default.go │ │ └── config.go │ ├── common │ │ └── base.go │ ├── setting │ │ ├── setting.go │ │ └── default.go │ ├── system │ │ └── info.go │ ├── storage │ │ └── storage.go │ ├── node │ │ └── node.go │ ├── share │ │ └── share.go │ ├── check │ │ └── check.go │ └── sub │ │ └── sub.go ├── database │ ├── client │ │ └── sqlite │ │ │ ├── migration │ │ │ ├── migration.go │ │ │ ├── 002_add_sub_tags.go │ │ │ └── 001_table.go │ │ │ ├── sqlite.go │ │ │ ├── migrator.go │ │ │ ├── auth.go │ │ │ ├── storage.go │ │ │ ├── check.go │ │ │ └── setting.go │ ├── op │ │ ├── repo.go │ │ ├── auth.go │ │ ├── storage.go │ │ ├── setting.go │ │ ├── check.go │ │ ├── notify.go │ │ ├── sub.go │ │ └── share.go │ ├── interfaces │ │ ├── repository.go │ │ ├── check.go │ │ ├── setting.go │ │ ├── auth.go │ │ ├── storage.go │ │ ├── sub.go │ │ ├── share.go │ │ └── notify.go │ ├── migration │ │ └── migration.go │ ├── database.go │ └── init.go ├── modules │ ├── country │ │ ├── channel │ │ │ ├── commen.go │ │ │ ├── register.go │ │ │ ├── ipapi.go │ │ │ ├── myip.go │ │ │ ├── ip_sb.go │ │ │ ├── ipwho.go │ │ │ ├── freeip.go │ │ │ ├── reallyfreegeoip.go │ │ │ └── cloudflare.go │ │ └── country.go │ ├── notify │ │ ├── channel │ │ │ ├── webhook.go │ │ │ └── email.go │ │ └── notify.go │ ├── register │ │ ├── category.go │ │ └── register.go │ ├── storage │ │ ├── channel │ │ │ └── webdav.go │ │ └── storage.go │ ├── subcer │ │ ├── config.go │ │ └── subcer.go │ └── share │ │ └── share.go ├── utils │ ├── color │ │ └── color.go │ ├── shutdown │ │ └── shutdown.go │ ├── cache │ │ ├── shard.go │ │ └── cache.go │ ├── generic │ │ └── queue.go │ ├── desc │ │ └── desc.go │ ├── utils.go │ ├── info │ │ └── info.go │ └── ua │ │ └── ua.go ├── core │ ├── task │ │ └── task.go │ ├── cron │ │ ├── cron.go │ │ ├── check.go │ │ └── fetch.go │ ├── node │ │ ├── exist.go │ │ ├── var.go │ │ └── info.go │ ├── check │ │ ├── check.go │ │ └── checker │ │ │ ├── country.go │ │ │ ├── tiktok.go │ │ │ └── alive.go │ ├── update │ │ ├── webui.go │ │ ├── core.go │ │ └── subcer.go │ ├── system │ │ └── monitor.go │ └── mihomo │ │ └── mihomo.go └── server │ ├── middleware │ ├── cors.go │ ├── logging.go │ ├── recovery.go │ ├── static.go │ └── auth.go │ ├── resp │ └── resp.go │ ├── handlers │ ├── scalar.go │ ├── setting.go │ ├── pprof.go │ ├── update.go │ └── log.go │ ├── auth │ ├── auth.go │ └── session.go │ ├── server │ └── server.go │ └── router │ └── router.go ├── .gitignore ├── scripts └── dockerfiles │ ├── Dockerfile.alpine │ ├── Dockerfile.debian │ └── entrypoint.sh ├── docs └── database │ └── README.md ├── .all-contributorsrc └── cmd └── bestsub └── main.go /deploy/README.md: -------------------------------------------------------------------------------- 1 | # Depoly 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /internal/models/auth/default.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | func Default() Data { 4 | return Data{0, "admin", "admin"} 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # web 2 | static/out/* 3 | !static/out/README.md 4 | 5 | # api 6 | api/* 7 | !api/README.md 8 | 9 | # build 10 | dist/* 11 | build 12 | 13 | # vscode 14 | .vscode -------------------------------------------------------------------------------- /internal/database/client/sqlite/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import "github.com/bestruirui/bestsub/internal/database/migration" 4 | 5 | const ClientName = "sqlite" 6 | 7 | func Get() []*migration.Info { 8 | return migration.Get(ClientName) 9 | } 10 | -------------------------------------------------------------------------------- /deploy/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | bestsub: 3 | image: ghcr.io/bestruirui/bestsub:latest 4 | container_name: bestsub 5 | restart: always 6 | ports: 7 | - '8080:8080' 8 | volumes: 9 | - './bestsub:/app/data' 10 | 11 | -------------------------------------------------------------------------------- /internal/models/notify/default.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | func DefaultTemplates() []Template { 4 | return []Template{ 5 | // {"login_success", "登录成功", "{{.Username}}{{.Time}}{{.IP}}{{.UserAgent}}"}, 6 | // {"login_failed", "登录失败", "{{.Username}}{{.Time}}{{.IP}}{{.UserAgent}}"}, 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /internal/modules/country/channel/commen.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bestruirui/bestsub/internal/utils/ua" 7 | ) 8 | 9 | type Common struct { 10 | CountryCode string `json:"country_code"` 11 | } 12 | 13 | func UserAgent(req *http.Request) { 14 | ua.SetHeader(req) 15 | } 16 | -------------------------------------------------------------------------------- /internal/database/op/repo.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "github.com/bestruirui/bestsub/internal/database/interfaces" 5 | ) 6 | 7 | var repo interfaces.Repository 8 | 9 | func SetRepo(repository interfaces.Repository) { 10 | repo = repository 11 | } 12 | func Close() error { 13 | updateAccessCount() 14 | return repo.Close() 15 | } 16 | -------------------------------------------------------------------------------- /internal/models/config/default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | func DefaultBase() Base { 4 | return Base{ 5 | Server: ServerConfig{ 6 | Port: 8080, 7 | Host: "0.0.0.0", 8 | }, 9 | Database: DatabaseConfig{ 10 | Type: "sqlite", 11 | }, 12 | Log: LogConfig{ 13 | Level: "debug", 14 | Output: "console", 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /internal/modules/country/channel/register.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type Channel interface { 8 | Url() string 9 | Header(req *http.Request) 10 | CountryCode(body []byte) string 11 | } 12 | 13 | var Channels = make([]Channel, 0) 14 | 15 | func register(channel Channel) { 16 | Channels = append(Channels, channel) 17 | } 18 | -------------------------------------------------------------------------------- /internal/utils/color/color.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | // 定义颜色和格式化字符串常量 4 | const ( 5 | Reset string = "\033[0m" 6 | Red string = "\033[31m" 7 | Green string = "\033[32m" 8 | Yellow string = "\033[33m" 9 | Blue string = "\033[34m" 10 | Purple string = "\033[35m" 11 | Cyan string = "\033[36m" 12 | White string = "\033[37m" 13 | Bold string = "\033[1m" 14 | Dim string = "\033[2m" 15 | ) 16 | -------------------------------------------------------------------------------- /internal/core/task/task.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import "github.com/panjf2000/ants/v2" 4 | 5 | var pool *ants.Pool 6 | var thread int 7 | 8 | func Init(maxThread int) { 9 | pool, _ = ants.NewPool(maxThread) 10 | thread = maxThread 11 | } 12 | 13 | func Submit(fn func()) { 14 | pool.Submit(fn) 15 | } 16 | 17 | func Release() { 18 | pool.Release() 19 | } 20 | 21 | func MaxThread() int { 22 | return thread 23 | } 24 | -------------------------------------------------------------------------------- /internal/server/middleware/cors.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func Cors() gin.HandlerFunc { 9 | config := cors.DefaultConfig() 10 | config.AllowAllOrigins = true 11 | config.AllowCredentials = true 12 | config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} 13 | config.AllowHeaders = []string{"*"} 14 | return cors.New(config) 15 | } 16 | -------------------------------------------------------------------------------- /internal/database/interfaces/repository.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | // Repository 统一的仓库接口 4 | type Repository interface { 5 | Auth() AuthRepository 6 | 7 | Setting() SettingRepository 8 | 9 | Notify() NotifyRepository 10 | NotifyTemplate() NotifyTemplateRepository 11 | 12 | Check() CheckRepository 13 | 14 | Sub() SubRepository 15 | Share() ShareRepository 16 | 17 | Storage() StorageRepository 18 | 19 | Close() error 20 | Migrate() error 21 | } 22 | -------------------------------------------------------------------------------- /internal/modules/notify/channel/webhook.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/bestruirui/bestsub/internal/modules/register" 7 | ) 8 | 9 | type WebHook struct { 10 | Url string `json:"url" name:"WebHook地址"` 11 | } 12 | 13 | func (e *WebHook) Init() error { 14 | return nil 15 | } 16 | 17 | func (e *WebHook) Send(title string, body *bytes.Buffer) error { 18 | return nil 19 | } 20 | 21 | func init() { 22 | register.Notify(&WebHook{}) 23 | } 24 | -------------------------------------------------------------------------------- /internal/modules/register/category.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "github.com/bestruirui/bestsub/internal/models/check" 5 | "github.com/bestruirui/bestsub/internal/models/notify" 6 | "github.com/bestruirui/bestsub/internal/models/storage" 7 | ) 8 | 9 | func Notify(i notify.Instance) { 10 | register("notify", i) 11 | } 12 | func Check(i check.Instance) { 13 | register("check", i) 14 | } 15 | func Storage(i storage.Instance) { 16 | register("storage", i) 17 | } 18 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/migration/002_add_sub_tags.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import "github.com/bestruirui/bestsub/internal/database/migration" 4 | 5 | // Migration002AddSubTags 为sub表添加列tags 6 | func Migration002AddSubTags() string { 7 | return ` 8 | ALTER TABLE "sub" 9 | ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; 10 | ` 11 | } 12 | 13 | // init 自动注册迁移 14 | func init() { 15 | migration.Register(ClientName, 202511082145, "dev", "Add Sub Tags", Migration002AddSubTags) 16 | } 17 | -------------------------------------------------------------------------------- /internal/database/interfaces/check.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/check" 7 | ) 8 | 9 | type CheckRepository interface { 10 | Create(ctx context.Context, check *check.Data) error 11 | Update(ctx context.Context, check *check.Data) error 12 | Delete(ctx context.Context, id uint16) error 13 | GetByID(ctx context.Context, id uint16) (*check.Data, error) 14 | List(ctx context.Context) (*[]check.Data, error) 15 | } 16 | -------------------------------------------------------------------------------- /internal/server/middleware/logging.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/bestruirui/bestsub/internal/utils/log" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | // 日志中间件 9 | func Logging() gin.HandlerFunc { 10 | return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { 11 | 12 | log.Debugf("%s %d %s %s %s", 13 | param.Method, 14 | param.StatusCode, 15 | param.ClientIP, 16 | param.Latency, 17 | param.Path, 18 | ) 19 | return "" 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /internal/database/interfaces/setting.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/setting" 7 | ) 8 | 9 | type SettingRepository interface { 10 | Create(ctx context.Context, setting *[]setting.Setting) error 11 | 12 | GetAll(ctx context.Context) (*[]setting.Setting, error) 13 | 14 | GetByKey(ctx context.Context, key []string) (*[]setting.Setting, error) 15 | 16 | Update(ctx context.Context, data *[]setting.Setting) error 17 | } 18 | -------------------------------------------------------------------------------- /scripts/dockerfiles/Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | ARG TARGETPLATFORM 4 | ENV TZ=Asia/Shanghai 5 | 6 | RUN apk add --no-cache alpine-conf ca-certificates su-exec && \ 7 | /usr/sbin/setup-timezone -z Asia/Shanghai && \ 8 | apk del alpine-conf && \ 9 | rm -rf /var/cache/apk/* && \ 10 | mkdir -p /app 11 | 12 | COPY build/docker/${TARGETPLATFORM}/bestsub /app/bestsub 13 | COPY scripts/dockerfiles/entrypoint.sh /entrypoint.sh 14 | 15 | RUN chmod +x /entrypoint.sh 16 | 17 | CMD ["/entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /docs/database/README.md: -------------------------------------------------------------------------------- 1 | # 数据库设计文档 2 | 3 | ## 概述 4 | 本文档介绍项目的数据库设计方案及相关工具使用说明。 5 | 6 | ## 设计图查看方法 7 | 1. 下载本目录中的设计文件:[BESTSUB.json](./BESTSUB.json) 8 | 2. 访问 [DrawDB](https://www.drawdb.app/) 在线工具 9 | 3. 将下载的JSON文件导入到DrawDB中 10 | 4. 即可查看完整的数据库设计图及表结构关系 11 | 12 | ## 推荐工具 13 | - **数据库设计工具**:[DrawDB](https://github.com/drawdb-io/drawdb) - 免费且功能强大的数据库设计工具 14 | - **SQLite可视化工具**:[sqlite3-editor](https://github.com/yy0931/sqlite3-editor) - 方便直观的SQLite数据库查看和编辑工具 15 | 16 | ## 使用说明 17 | 在开发过程中,请参照数据库设计图进行数据库操作,确保数据结构的一致性和完整性。如需修改数据库设计,请更新设计文件并同步到项目中。 -------------------------------------------------------------------------------- /internal/core/cron/cron.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/robfig/cron/v3" 7 | ) 8 | 9 | type cronFunc struct { 10 | fn func() 11 | cronExpr string 12 | } 13 | 14 | var scheduler = cron.New(cron.WithLocation(time.Local)) 15 | 16 | const ( 17 | RunningStatus = "running" 18 | ScheduledStatus = "scheduled" 19 | PendingStatus = "pending" 20 | DisabledStatus = "disabled" 21 | ) 22 | 23 | func Start() { 24 | scheduler.Start() 25 | } 26 | 27 | func Stop() { 28 | scheduler.Stop() 29 | } 30 | -------------------------------------------------------------------------------- /internal/server/middleware/recovery.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bestruirui/bestsub/internal/server/resp" 7 | "github.com/bestruirui/bestsub/internal/utils/log" 8 | "github.com/gin-gonic/gin" 9 | ) 10 | 11 | func Recovery() gin.HandlerFunc { 12 | return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { 13 | log.Warnf("Panic recovered: %v", recovered) 14 | resp.Error(c, http.StatusInternalServerError, "An unexpected error occurred") 15 | c.Abort() 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/modules/country/channel/ipapi.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type IPAPI struct{} 9 | 10 | func (c *IPAPI) Url() string { 11 | return "https://ipapi.co/json" 12 | } 13 | 14 | func (c *IPAPI) Header(req *http.Request) { 15 | 16 | } 17 | 18 | func (c *IPAPI) CountryCode(body []byte) string { 19 | var ipapi Common 20 | if err := json.Unmarshal(body, &ipapi); err != nil { 21 | return "" 22 | } 23 | return ipapi.CountryCode 24 | } 25 | 26 | func init() { 27 | register(&IPAPI{}) 28 | } 29 | -------------------------------------------------------------------------------- /internal/modules/country/channel/myip.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type MYIP struct { 9 | CC string `json:"cc"` 10 | } 11 | 12 | func (c *MYIP) Url() string { 13 | return "https://api.myip.com" 14 | } 15 | 16 | func (c *MYIP) Header(req *http.Request) { 17 | } 18 | 19 | func (c *MYIP) CountryCode(body []byte) string { 20 | var myip MYIP 21 | if err := json.Unmarshal(body, &myip); err != nil { 22 | return "" 23 | } 24 | return myip.CC 25 | } 26 | 27 | func init() { 28 | register(&MYIP{}) 29 | } 30 | -------------------------------------------------------------------------------- /scripts/dockerfiles/Dockerfile.debian: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm 2 | 3 | ARG TARGETPLATFORM 4 | ENV TZ=Asia/Shanghai 5 | 6 | RUN apt-get update && apt-get install -y ca-certificates tzdata gosu && \ 7 | ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ 8 | dpkg-reconfigure -f noninteractive tzdata && \ 9 | rm -rf /var/cache/apt/* && \ 10 | mkdir -p /app 11 | 12 | COPY build/docker/${TARGETPLATFORM}/bestsub /app/bestsub 13 | COPY scripts/dockerfiles/entrypoint.sh /entrypoint.sh 14 | 15 | RUN chmod +x /entrypoint.sh 16 | 17 | CMD ["/entrypoint.sh"] -------------------------------------------------------------------------------- /internal/modules/country/channel/ip_sb.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type IPSB struct{} 9 | 10 | func (c *IPSB) Url() string { 11 | return "https://api.ip.sb/geoip" 12 | } 13 | 14 | func (c *IPSB) Header(req *http.Request) { 15 | UserAgent(req) 16 | } 17 | 18 | func (c *IPSB) CountryCode(body []byte) string { 19 | var ip_sb Common 20 | if err := json.Unmarshal(body, &ip_sb); err != nil { 21 | return "" 22 | } 23 | return ip_sb.CountryCode 24 | } 25 | 26 | func init() { 27 | register(&IPSB{}) 28 | } 29 | -------------------------------------------------------------------------------- /internal/modules/country/channel/ipwho.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type IPWho struct{} 9 | 10 | func (c *IPWho) Url() string { 11 | return "https://api.ip.sb/geoip" 12 | } 13 | 14 | func (c *IPWho) Header(req *http.Request) { 15 | UserAgent(req) 16 | } 17 | 18 | func (c *IPWho) CountryCode(body []byte) string { 19 | var ipwho Common 20 | if err := json.Unmarshal(body, &ipwho); err != nil { 21 | return "" 22 | } 23 | return ipwho.CountryCode 24 | } 25 | 26 | func init() { 27 | register(&IPWho{}) 28 | } 29 | -------------------------------------------------------------------------------- /internal/database/interfaces/auth.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/auth" 7 | ) 8 | 9 | // 单用户认证数据访问接口 10 | type AuthRepository interface { 11 | // 获取认证信息 12 | Get(ctx context.Context) (*auth.Data, error) 13 | 14 | // 更新用户名 15 | UpdateName(ctx context.Context, name string) error 16 | 17 | // 更新密码 18 | UpdatePassword(ctx context.Context, hashPassword string) error 19 | 20 | // 初始化认证信息(首次创建密码) 21 | Initialize(ctx context.Context, auth *auth.Data) error 22 | 23 | // 验证是否已初始化 24 | IsInitialized(ctx context.Context) (bool, error) 25 | } 26 | -------------------------------------------------------------------------------- /internal/core/node/exist.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import "sync" 4 | 5 | type exist struct { 6 | mu sync.RWMutex 7 | data map[uint64]struct{} 8 | } 9 | 10 | func NewExist(size int) *exist { 11 | return &exist{data: make(map[uint64]struct{}, size)} 12 | } 13 | 14 | func (k *exist) Exist(key uint64) bool { 15 | k.mu.RLock() 16 | _, exists := k.data[key] 17 | k.mu.RUnlock() 18 | return exists 19 | } 20 | 21 | func (k *exist) Add(key uint64) { 22 | k.mu.Lock() 23 | k.data[key] = struct{}{} 24 | k.mu.Unlock() 25 | } 26 | func (k *exist) Remove(key uint64) { 27 | k.mu.Lock() 28 | delete(k.data, key) 29 | k.mu.Unlock() 30 | } 31 | -------------------------------------------------------------------------------- /internal/core/check/check.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | _ "github.com/bestruirui/bestsub/internal/core/check/checker" 5 | "github.com/bestruirui/bestsub/internal/models/check" 6 | "github.com/bestruirui/bestsub/internal/modules/register" 7 | "github.com/bestruirui/bestsub/internal/utils/desc" 8 | ) 9 | 10 | type Desc = desc.Data 11 | 12 | func Get(m string, c string) (check.Instance, error) { 13 | return register.Get[check.Instance]("check", m, c) 14 | } 15 | 16 | func GetTypes() []string { 17 | return register.GetList("check") 18 | } 19 | 20 | func GetInfoMap() map[string][]Desc { 21 | return register.GetInfoMap("check") 22 | } 23 | -------------------------------------------------------------------------------- /internal/modules/country/channel/freeip.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type FreeIP struct{} 9 | 10 | func (c *FreeIP) Url() string { 11 | return "https://free.freeipapi.com/api/json" 12 | } 13 | 14 | func (c *FreeIP) Header(req *http.Request) { 15 | UserAgent(req) 16 | } 17 | 18 | func (c *FreeIP) CountryCode(body []byte) string { 19 | var freeip struct { 20 | CountryCode string `json:"countryCode"` 21 | } 22 | if err := json.Unmarshal(body, &freeip); err != nil { 23 | return "" 24 | } 25 | return freeip.CountryCode 26 | } 27 | 28 | func init() { 29 | register(&FreeIP{}) 30 | } 31 | -------------------------------------------------------------------------------- /internal/database/interfaces/storage.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/storage" 7 | ) 8 | 9 | // 存储配置数据访问接口 10 | type StorageRepository interface { 11 | // Create 创建存储配置 12 | Create(ctx context.Context, config *storage.Data) error 13 | 14 | // GetByID 根据ID获取存储配置 15 | GetByID(ctx context.Context, id uint16) (*storage.Data, error) 16 | 17 | // Update 更新存储配置 18 | Update(ctx context.Context, config *storage.Data) error 19 | 20 | // Delete 删除存储配置 21 | Delete(ctx context.Context, id uint16) error 22 | 23 | // List 获取存储配置列表 24 | List(ctx context.Context) (*[]storage.Data, error) 25 | } 26 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "sgpublic", 12 | "name": "Haven Madray", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/37202870?v=4", 14 | "profile": "https://sgpublic.xyz/", 15 | "contributions": [ 16 | "code" 17 | ] 18 | } 19 | ], 20 | "contributorsPerLine": 7, 21 | "skipCi": true, 22 | "repoType": "github", 23 | "repoHost": "https://github.com", 24 | "projectName": "BestSub", 25 | "projectOwner": "bestruirui" 26 | } 27 | -------------------------------------------------------------------------------- /internal/modules/storage/channel/webdav.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/modules/register" 7 | ) 8 | 9 | func init() { 10 | register.Storage(&WebDAV{}) 11 | } 12 | 13 | type WebDAV struct { 14 | url string `json:"url" type:"string" required:"true" description:"WebDAV地址"` 15 | username string `json:"username" type:"string" required:"true" description:"WebDAV用户名"` 16 | password string `json:"password" type:"string" required:"true" description:"WebDAV密码"` 17 | } 18 | 19 | func (w *WebDAV) Init() error { 20 | return nil 21 | } 22 | 23 | func (w *WebDAV) Upload(ctx context.Context) error { 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/modules/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | storageModel "github.com/bestruirui/bestsub/internal/models/storage" 5 | "github.com/bestruirui/bestsub/internal/modules/register" 6 | _ "github.com/bestruirui/bestsub/internal/modules/storage/channel" 7 | "github.com/bestruirui/bestsub/internal/utils/desc" 8 | ) 9 | 10 | type Desc = desc.Data 11 | 12 | func Get(m string, c string) (storageModel.Instance, error) { 13 | return register.Get[storageModel.Instance]("storage", m, c) 14 | } 15 | 16 | func GetChannels() []string { 17 | return register.GetList("storage") 18 | } 19 | 20 | func GetInfoMap() map[string][]Desc { 21 | return register.GetInfoMap("storage") 22 | } 23 | -------------------------------------------------------------------------------- /scripts/dockerfiles/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | PUID="${PUID:-0}" 5 | PGID="${PGID:-0}" 6 | 7 | chmod +x /app/bestsub 8 | 9 | if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then 10 | chown -R "$PUID:$PGID" /app 11 | fi 12 | 13 | cd /app 14 | 15 | if command -v su-exec >/dev/null 2>&1; then 16 | exec su-exec "$PUID:$PGID" ./bestsub -c data/config.json 17 | elif command -v gosu >/dev/null 2>&1; then 18 | exec gosu "$PUID:$PGID" ./bestsub -c data/config.json 19 | else 20 | if [ "$PUID" != "0" ] || [ "$PGID" != "0" ]; then 21 | echo "Warning: neither su-exec nor gosu is available; running as root." >&2 22 | fi 23 | exec ./bestsub -c data/config.json 24 | fi 25 | -------------------------------------------------------------------------------- /internal/modules/country/channel/reallyfreegeoip.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | type ReallyFreeGeoIP struct{} 9 | 10 | func (c *ReallyFreeGeoIP) Url() string { 11 | return "https://reallyfreegeoip.org/json" 12 | } 13 | 14 | func (c *ReallyFreeGeoIP) Header(req *http.Request) { 15 | UserAgent(req) 16 | } 17 | 18 | func (c *ReallyFreeGeoIP) CountryCode(body []byte) string { 19 | var reallyfreegeoip struct { 20 | CountryCode string `json:"country_code"` 21 | } 22 | if err := json.Unmarshal(body, &reallyfreegeoip); err != nil { 23 | return "" 24 | } 25 | return reallyfreegeoip.CountryCode 26 | } 27 | 28 | func init() { 29 | register(&ReallyFreeGeoIP{}) 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 为该项目提出建议 3 | title: "[Feature] " 4 | labels: ["enhancement"] 5 | body: 6 | - type: checkboxes 7 | id: ensure 8 | attributes: 9 | label: 验证步骤 10 | description: 在提交之前,请勾选以下选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 11 | options: 12 | - label: 我已经阅读了 [README.md](https://github.com/bestruirui/BestSub/blob/master/README.md),确认了该功能没有实现 13 | required: true 14 | - label: 我已在 [Issue](https://github.com/bestruirui/BestSub/issues) 中寻找过我要提出的功能请求,并且没有找到 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: 描述 19 | description: 请提供对于该功能的详细描述,而不是莫名其妙的话术。 20 | validations: 21 | required: true -------------------------------------------------------------------------------- /internal/database/interfaces/sub.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/sub" 7 | ) 8 | 9 | // SubRepository 订阅链接数据访问接口 10 | type SubRepository interface { 11 | // Create 创建链接 12 | Create(ctx context.Context, link *sub.Data) error 13 | 14 | // GetByID 根据ID获取链接 15 | GetByID(ctx context.Context, id uint16) (*sub.Data, error) 16 | 17 | // Update 更新链接 18 | Update(ctx context.Context, link *sub.Data) error 19 | 20 | // Delete 删除链接 21 | Delete(ctx context.Context, id uint16) error 22 | 23 | // List 获取订阅链接列表 24 | List(ctx context.Context) (*[]sub.Data, error) 25 | 26 | // BatchCreate 批量创建订阅链接 27 | BatchCreate(ctx context.Context, links []*sub.Data) error 28 | } 29 | -------------------------------------------------------------------------------- /internal/core/node/var.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "sync" 5 | 6 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 7 | ) 8 | 9 | var ( 10 | poolMutex sync.RWMutex 11 | pool []nodeModel.Data 12 | nodeExist *exist 13 | nodeProcess *exist 14 | 15 | wgSync sync.WaitGroup 16 | wgStatus bool 17 | validNodes []nodeModel.Data 18 | validMutex sync.Mutex 19 | 20 | refreshMutex sync.Mutex 21 | subInfoMap = make(map[uint16]nodeModel.SimpleInfo) 22 | countryInfoMap = make(map[string]nodeModel.SimpleInfo) 23 | subAggBuf = make(map[uint16]*infoSums) 24 | countryAggBuf = make(map[string]*infoSums) 25 | ) 26 | 27 | type infoSums struct { 28 | sumSpeedUp uint64 29 | sumSpeedDown uint64 30 | sumDelay uint64 31 | sumRisk uint64 32 | count uint32 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 错误反馈 2 | description: "提交错误反馈" 3 | title: "[Bug] " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | id: ensure 8 | attributes: 9 | label: 验证步骤 10 | description: 在提交之前,请勾选以下选项以证明您已经阅读并理解了以下要求,否则该 issue 将被关闭。 11 | options: 12 | - label: 我已在 [Issue](https://github.com/bestruirui/BestSub/issues) 中寻找过我要提出的问题,并且没有找到 13 | required: true 14 | 15 | - type: textarea 16 | attributes: 17 | label: 描述 18 | description: 请提供错误的详细描述。 19 | validations: 20 | required: true 21 | - type: textarea 22 | attributes: 23 | label: 重现方式 24 | description: 请提供重现错误的步骤 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: 日志 30 | description: 在下方附上运行日志 31 | render: shell 32 | -------------------------------------------------------------------------------- /internal/models/common/base.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "time" 4 | 5 | // BaseDbModel 基础模型,包含所有实体的公共字段 6 | type BaseDbModel struct { 7 | ID uint16 `db:"id" json:"id"` 8 | Enable bool `db:"enable" json:"enable"` 9 | Name string `db:"name" json:"name"` 10 | Description string `db:"description" json:"description"` 11 | CreatedAt time.Time `db:"created_at" json:"created_at"` 12 | UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 13 | } 14 | 15 | type BaseRequestModel struct { 16 | Enable *bool `json:"enable"` 17 | Name string `json:"name"` 18 | Description string `json:"description"` 19 | } 20 | type BaseUpdateRequestModel struct { 21 | ID uint16 `json:"id"` 22 | Enable *bool `json:"enable"` 23 | Name string `json:"name"` 24 | Description string `json:"description"` 25 | } 26 | -------------------------------------------------------------------------------- /internal/database/interfaces/share.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/share" 7 | ) 8 | 9 | // 分享链接数据访问接口 10 | type ShareRepository interface { 11 | // Create 创建分享链接 12 | Create(ctx context.Context, shareLink *share.Data) error 13 | 14 | // GetByID 根据ID获取分享链接 15 | GetByID(ctx context.Context, id uint16) (*share.Data, error) 16 | 17 | // Update 更新分享链接 18 | Update(ctx context.Context, shareLink *share.Data) error 19 | 20 | // UpdateAccessCount 更新分享链接访问次数 21 | UpdateAccessCount(ctx context.Context, shareLink *[]share.UpdateAccessCountDB) error 22 | 23 | // GetConfigByToken 根据token获取分享链接配置 24 | GetGenByToken(ctx context.Context, token string) (string, error) 25 | 26 | // Delete 删除分享链接 27 | Delete(ctx context.Context, id uint16) error 28 | 29 | // List 获取分享链接列表 30 | List(ctx context.Context) (*[]share.Data, error) 31 | } 32 | -------------------------------------------------------------------------------- /internal/modules/country/country.go: -------------------------------------------------------------------------------- 1 | package country 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/bestruirui/bestsub/internal/modules/country/channel" 10 | ) 11 | 12 | func GetCode(ctx context.Context, client *http.Client) string { 13 | for _, channel := range channel.Channels { 14 | ctx, cancel := context.WithTimeout(ctx, time.Second*5) 15 | defer cancel() 16 | request, err := http.NewRequestWithContext(ctx, "GET", channel.Url(), nil) 17 | if err != nil { 18 | continue 19 | } 20 | channel.Header(request) 21 | response, err := client.Do(request) 22 | if err != nil { 23 | continue 24 | } 25 | defer response.Body.Close() 26 | body, err := io.ReadAll(response.Body) 27 | if err != nil { 28 | continue 29 | } 30 | country := channel.CountryCode(body) 31 | if country != "" { 32 | return country 33 | } 34 | body = nil 35 | } 36 | return "" 37 | } 38 | -------------------------------------------------------------------------------- /internal/models/setting/setting.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | type Setting struct { 4 | Key string `json:"key"` 5 | Value string `json:"value"` 6 | } 7 | 8 | const ( 9 | PROXY_ENABLE = "proxy_enable" 10 | PROXY_URL = "proxy_url" 11 | 12 | LOG_RETENTION_DAYS = "log_retention_days" 13 | 14 | FRONTEND_URL = "frontend_url" 15 | FRONTEND_URL_PROXY = "frontend_url_proxy" 16 | SUBCONVERTER_URL = "subconverter_url" 17 | SUBCONVERTER_URL_PROXY = "subconverter_url_proxy" 18 | 19 | SUB_DISABLE_AUTO = "sub_disable_auto" 20 | 21 | NODE_POOL_SIZE = "node_pool_size" 22 | NODE_TEST_URL = "node_test_url" 23 | NODE_TEST_TIMEOUT = "node_test_timeout" 24 | 25 | NODE_PROTOCOL_FILTER_ENABLE = "node_protocol_filter_enable" 26 | NODE_PROTOCOL_FILTER_MODE = "node_protocol_filter_mode" 27 | NODE_PROTOCOL_FILTER = "node_protocol_filter" 28 | 29 | TASK_MAX_THREAD = "task_max_thread" 30 | TASK_MAX_TIMEOUT = "task_max_timeout" 31 | TASK_MAX_RETRY = "task_max_retry" 32 | 33 | NOTIFY_OPERATION = "notify_operation" 34 | NOTIFY_ID = "notify_id" 35 | ) 36 | -------------------------------------------------------------------------------- /internal/database/migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | type Info struct { 8 | Date uint64 9 | Version string 10 | Description string 11 | Content func() string 12 | } 13 | 14 | var clientMigrations = make(map[string][]*Info) 15 | 16 | func Register(client string, date uint64, version, description string, contentFunc func() string) { 17 | info := &Info{ 18 | Date: date, 19 | Version: version, 20 | Description: description, 21 | Content: contentFunc, 22 | } 23 | 24 | migrations := clientMigrations[client] 25 | 26 | index := sort.Search(len(migrations), func(i int) bool { 27 | return migrations[i].Date > date 28 | }) 29 | 30 | migrations = append(migrations, nil) 31 | copy(migrations[index+1:], migrations[index:]) 32 | migrations[index] = info 33 | 34 | clientMigrations[client] = migrations 35 | } 36 | 37 | func Get(client string) []*Info { 38 | if migrations := clientMigrations[client]; migrations != nil { 39 | clientMigrations = nil 40 | return migrations 41 | } 42 | return make([]*Info, 0) 43 | } 44 | -------------------------------------------------------------------------------- /internal/models/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Base struct { 4 | Server ServerConfig `json:"server"` 5 | Database DatabaseConfig `json:"database"` 6 | Log LogConfig `json:"log"` 7 | JWT JWTConfig `json:"jwt"` 8 | Session SessionConfig `json:"-"` 9 | SubConverter SubConverterConfig `json:"subconverter"` 10 | } 11 | 12 | type ServerConfig struct { 13 | Port int `json:"port"` 14 | Host string `json:"host"` 15 | UIPath string `json:"-"` 16 | } 17 | 18 | type DatabaseConfig struct { 19 | Type string `json:"type"` 20 | Path string `json:"-"` 21 | } 22 | 23 | type LogConfig struct { 24 | Level string `json:"level"` 25 | Output string `json:"output"` 26 | Path string `json:"-"` 27 | } 28 | 29 | type JWTConfig struct { 30 | Secret string `json:"secret"` 31 | } 32 | 33 | type SessionConfig struct { 34 | AuthPath string `json:"-"` 35 | NodePath string `json:"-"` 36 | } 37 | 38 | type SubConverterConfig struct { 39 | Path string `json:"-"` 40 | Port int `json:"port"` 41 | Host string `json:"host"` 42 | } 43 | -------------------------------------------------------------------------------- /internal/utils/shutdown/shutdown.go: -------------------------------------------------------------------------------- 1 | package shutdown 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/bestruirui/bestsub/internal/utils/log" 9 | ) 10 | 11 | var funcs []func() error 12 | 13 | func Register(fn func() error) { 14 | funcs = append(funcs, fn) 15 | } 16 | 17 | func Listen() { 18 | quit := make(chan os.Signal, 1) 19 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 20 | log.Info("Program started, press Ctrl+C to exit") 21 | sig := <-quit 22 | log.Warnf("Received exit signal: %v, starting to close program", sig) 23 | if len(funcs) == 0 { 24 | return 25 | } 26 | for _, fn := range funcs { 27 | if err := fn(); err != nil { 28 | log.Errorf("Closing functions execution failed: %v", err) 29 | } 30 | } 31 | log.Info("=== Shutdown completed successfully ===") 32 | os.Exit(0) 33 | } 34 | func All() { 35 | if len(funcs) == 0 { 36 | return 37 | } 38 | for _, fn := range funcs { 39 | if err := fn(); err != nil { 40 | log.Errorf("Closing functions execution failed: %v", err) 41 | } 42 | } 43 | log.Info("Shutdown completed successfully") 44 | } 45 | -------------------------------------------------------------------------------- /internal/server/resp/resp.go: -------------------------------------------------------------------------------- 1 | package resp 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | ) 8 | 9 | type ResponseStruct struct { 10 | Code int `json:"code" example:"200"` 11 | Message string `json:"message" example:"success"` 12 | Data interface{} `json:"data,omitempty"` 13 | } 14 | 15 | type ResponsePaginationStruct struct { 16 | Page int `json:"page" example:"1"` 17 | PageSize int `json:"page_size" example:"10"` 18 | Total uint16 `json:"total" example:"100"` 19 | Data interface{} `json:"data"` 20 | } 21 | 22 | func Success(c *gin.Context, data any) { 23 | c.JSON(http.StatusOK, ResponseStruct{ 24 | Code: http.StatusOK, 25 | Message: "success", 26 | Data: data, 27 | }) 28 | } 29 | 30 | func Error(c *gin.Context, code int, err string) { 31 | c.JSON(code, ResponseStruct{ 32 | Code: code, 33 | Message: err, 34 | }) 35 | c.Abort() 36 | } 37 | func ErrorBadRequest(c *gin.Context) { 38 | c.JSON(http.StatusBadRequest, ResponseStruct{ 39 | Code: http.StatusBadRequest, 40 | Message: "bad request", 41 | }) 42 | c.Abort() 43 | } 44 | -------------------------------------------------------------------------------- /internal/models/system/info.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | // HealthResponse 健康检查响应 4 | type HealthResponse struct { 5 | Status string `json:"status" example:"ok"` // 服务状态 6 | Timestamp string `json:"timestamp" example:"2024-01-01T12:00:00"` // 检查时间 7 | Version string `json:"version" example:"1.0.0"` // 版本信息 8 | Database string `json:"database" example:"connected"` // 数据库状态 9 | } 10 | 11 | // 系统信息结构 12 | type Info struct { 13 | MemoryUsed uint64 `json:"memory_used"` // 已使用内存 (bytes) 14 | CPUPercent float64 `json:"cpu_percent"` // CPU 占用率 15 | StartTime string `json:"start_time"` // 启动时间 16 | UploadBytes uint64 `json:"upload_bytes"` // 上传流量 (bytes) 17 | DownloadBytes uint64 `json:"download_bytes"` // 下载流量 (bytes) 18 | } 19 | 20 | type Version struct { 21 | SubConverterVersion string `json:"subconverter_version"` 22 | Version string `json:"version"` 23 | BuildTime string `json:"build_time"` 24 | Commit string `json:"commit"` 25 | Author string `json:"author"` 26 | Repo string `json:"repo"` 27 | } 28 | -------------------------------------------------------------------------------- /internal/database/interfaces/notify.go: -------------------------------------------------------------------------------- 1 | package interfaces 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/models/notify" 7 | ) 8 | 9 | // NotificationChannelRepository 通知渠道数据访问接口 10 | type NotifyRepository interface { 11 | // Create 创建通知渠道 12 | Create(ctx context.Context, channel *notify.Data) error 13 | 14 | // GetByID 根据ID获取通知渠道 15 | GetByID(ctx context.Context, id uint16) (*notify.Data, error) 16 | 17 | // Update 更新通知渠道 18 | Update(ctx context.Context, channel *notify.Data) error 19 | 20 | // Delete 删除通知渠道 21 | Delete(ctx context.Context, id uint16) error 22 | 23 | // List 获取通知渠道列表 24 | List(ctx context.Context) (*[]notify.Data, error) 25 | } 26 | 27 | // NotificationTemplateRepository 通知模板数据访问接口 28 | type NotifyTemplateRepository interface { 29 | // Create 创建通知模板 30 | Create(ctx context.Context, template *notify.Template) error 31 | 32 | // GetByType 根据类型获取通知模板 33 | GetByType(ctx context.Context, t string) (*notify.Template, error) 34 | 35 | // Update 更新通知模板 36 | Update(ctx context.Context, template *notify.Template) error 37 | 38 | // List 获取通知模板列表 39 | List(ctx context.Context) (*[]notify.Template, error) 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: changelog 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | changelog: 13 | name: Create Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v5 18 | with: 19 | fetch-depth: 0 20 | token: ${{ secrets.ACTION_TOKEN }} 21 | 22 | - run: npx changelogithub 23 | env: 24 | GITHUB_TOKEN: ${{secrets.ACTION_TOKEN}} 25 | 26 | - name: Merge dev to master branch 27 | run: | 28 | git config --global user.name 'GitHub Actions' 29 | git config --global user.email 'github-actions@github.com' 30 | if ! git show-ref --verify --quiet refs/heads/master; then 31 | git checkout -b master 32 | else 33 | git checkout master 34 | fi 35 | git fetch origin dev 36 | git merge origin/dev 37 | git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }} 38 | git push origin master --force 39 | env: 40 | GITHUB_TOKEN: ${{secrets.ACTION_TOKEN}} -------------------------------------------------------------------------------- /internal/utils/cache/shard.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type shard[K comparable, V any] struct { 8 | hashmap map[K]V 9 | lock sync.RWMutex 10 | } 11 | 12 | func (c *shard[K, V]) set(k K, v V) { 13 | c.lock.Lock() 14 | c.hashmap[k] = v 15 | c.lock.Unlock() 16 | } 17 | 18 | func (c *shard[K, V]) get(k K) (V, bool) { 19 | c.lock.RLock() 20 | item, exist := c.hashmap[k] 21 | c.lock.RUnlock() 22 | if !exist { 23 | var zero V 24 | return zero, false 25 | } 26 | return item, true 27 | } 28 | 29 | func (c *shard[K, V]) del(k K) int { 30 | c.lock.Lock() 31 | defer c.lock.Unlock() 32 | if _, found := c.hashmap[k]; found { 33 | delete(c.hashmap, k) 34 | return 1 35 | } 36 | return 0 37 | } 38 | 39 | func (c *shard[K, V]) clear() { 40 | c.lock.Lock() 41 | defer c.lock.Unlock() 42 | c.hashmap = map[K]V{} 43 | } 44 | 45 | func (c *shard[K, V]) getAll() map[K]V { 46 | c.lock.RLock() 47 | defer c.lock.RUnlock() 48 | result := make(map[K]V, len(c.hashmap)) 49 | for k, v := range c.hashmap { 50 | result[k] = v 51 | } 52 | return result 53 | } 54 | 55 | func (c *shard[K, V]) len() int { 56 | c.lock.RLock() 57 | defer c.lock.RUnlock() 58 | return len(c.hashmap) 59 | } 60 | -------------------------------------------------------------------------------- /internal/models/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | ) 7 | 8 | type Data struct { 9 | ID uint16 `db:"id" json:"id"` 10 | Name string `db:"name" json:"name"` 11 | Type string `db:"type" json:"type"` 12 | Config string `db:"config" json:"config"` 13 | } 14 | 15 | type Request struct { 16 | Name string `json:"name" example:"webdav"` 17 | Type string `json:"type" example:"webdav"` 18 | Config any `json:"config"` 19 | } 20 | 21 | type Response struct { 22 | ID uint16 `json:"id"` 23 | Name string `json:"name"` 24 | Type string `json:"type"` 25 | Config any `json:"config"` 26 | } 27 | 28 | type Instance interface { 29 | Init() error 30 | Upload(ctx context.Context) error 31 | } 32 | 33 | func (r *Request) GenData(id uint16) Data { 34 | configBytes, _ := json.Marshal(r.Config) 35 | return Data{ 36 | ID: id, 37 | Name: r.Name, 38 | Type: r.Type, 39 | Config: string(configBytes), 40 | } 41 | } 42 | 43 | func (d *Data) GenResponse() Response { 44 | var config any 45 | json.Unmarshal([]byte(d.Config), &config) 46 | return Response{ 47 | ID: d.ID, 48 | Name: d.Name, 49 | Type: d.Type, 50 | Config: config, 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/modules/country/channel/cloudflare.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | ) 8 | 9 | type CloudflareCDN struct{} 10 | 11 | func (c *CloudflareCDN) Url() string { 12 | return "https://cloudflare.com/cdn-cgi/trace" 13 | } 14 | 15 | func (c *CloudflareCDN) Header(req *http.Request) { 16 | } 17 | 18 | func (c *CloudflareCDN) CountryCode(body []byte) string { 19 | prefix := []byte("loc=") 20 | idx := bytes.Index(body, prefix) 21 | if idx == -1 { 22 | return "" 23 | } 24 | start := idx + len(prefix) 25 | endRel := bytes.IndexByte(body[start:], '\n') 26 | var v []byte 27 | if endRel == -1 { 28 | v = body[start:] 29 | } else { 30 | v = body[start : start+endRel] 31 | } 32 | v = bytes.TrimSpace(v) 33 | return string(v) 34 | } 35 | 36 | type CloudflareSpeed struct{} 37 | 38 | func (c *CloudflareSpeed) Url() string { 39 | return "https://speed.cloudflare.com/meta" 40 | } 41 | 42 | func (c *CloudflareSpeed) Header(req *http.Request) { 43 | UserAgent(req) 44 | } 45 | 46 | func (c *CloudflareSpeed) CountryCode(body []byte) string { 47 | var speed struct { 48 | CountryCode string `json:"country"` 49 | } 50 | if err := json.Unmarshal(body, &speed); err != nil { 51 | return "" 52 | } 53 | return speed.CountryCode 54 | } 55 | 56 | func init() { 57 | register(&CloudflareCDN{}) 58 | register(&CloudflareSpeed{}) 59 | } 60 | -------------------------------------------------------------------------------- /internal/server/handlers/scalar.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package handlers 4 | 5 | import ( 6 | "net/http" 7 | 8 | "github.com/bestruirui/bestsub/internal/server/router" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func init() { 13 | router.NewGroupRouter("/scalar"). 14 | AddRoute( 15 | router.NewRoute("/", router.GET). 16 | Handle(scalar), 17 | ). 18 | AddRoute( 19 | router.NewRoute("/api.json", router.GET). 20 | Handle(apidata), 21 | ) 22 | } 23 | 24 | var scalarHTML = []byte(` 25 | 26 | 27 | 28 | BestSub API 29 | 30 | 31 | 32 | 33 |
34 | 35 | 48 | 49 | 50 | `) 51 | 52 | func scalar(c *gin.Context) { 53 | c.Data(http.StatusOK, "text/html; charset=utf-8", scalarHTML) 54 | } 55 | func apidata(c *gin.Context) { 56 | c.File("docs/api/swagger.json") 57 | } 58 | -------------------------------------------------------------------------------- /internal/modules/subcer/config.go: -------------------------------------------------------------------------------- 1 | package subcer 2 | 3 | const pref = ` 4 | common: 5 | api_mode: true 6 | base_path: base 7 | clash_rule_base: base/all_base.tpl 8 | surge_rule_base: base/all_base.tpl 9 | surfboard_rule_base: base/all_base.tpl 10 | mellow_rule_base: base/all_base.tpl 11 | quan_rule_base: base/all_base.tpl 12 | quanx_rule_base: base/all_base.tpl 13 | loon_rule_base: base/all_base.tpl 14 | sssub_rule_base: base/all_base.tpl 15 | singbox_rule_base: base/all_base.tpl 16 | reload_conf_on_request: false 17 | 18 | 19 | node_pref: 20 | clash_use_new_field_name: true 21 | clash_proxies_style: flow 22 | clash_proxy_groups_style: block 23 | singbox_add_clash_modes: true 24 | 25 | 26 | emojis: 27 | add_emoji: false 28 | remove_old_emoji: false 29 | 30 | template: 31 | globals: 32 | - {key: clash.http_port, value: 7890} 33 | - {key: clash.socks_port, value: 7891} 34 | - {key: clash.allow_lan, value: true} 35 | - {key: clash.log_level, value: info} 36 | - {key: clash.external_controller, value: '127.0.0.1:9090'} 37 | - {key: singbox.allow_lan, value: true} 38 | - {key: singbox.mixed_port, value: 2080} 39 | 40 | server: 41 | listen: %s 42 | port: %d 43 | 44 | advanced: 45 | log_level: debug 46 | max_pending_connections: 10240 47 | max_concurrent_threads: 100 48 | enable_cache: true 49 | cache_subscription: 0 50 | cache_config: 86400 51 | cache_ruleset: 86400 52 | ` 53 | -------------------------------------------------------------------------------- /internal/core/update/webui.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/bestruirui/bestsub/internal/config" 7 | "github.com/bestruirui/bestsub/internal/database/op" 8 | "github.com/bestruirui/bestsub/internal/models/setting" 9 | "github.com/bestruirui/bestsub/internal/utils/log" 10 | ) 11 | 12 | func InitUI() error { 13 | if _, err := os.Stat(config.Base().Server.UIPath + "/index.html"); err != nil { 14 | log.Infof("ui not found, downloading...") 15 | err = updateUI() 16 | if err != nil { 17 | log.Warnf("auto update ui failed, please download ui manually from %s and unzip to %s: %v", op.GetSettingStr(setting.FRONTEND_URL), config.Base().Server.UIPath, err) 18 | os.Exit(1) 19 | return err 20 | } 21 | } 22 | log.Infof("UI is already up to date") 23 | return nil 24 | } 25 | 26 | func UpdateUI() error { 27 | log.Infof("start update ui") 28 | err := updateUI() 29 | if err != nil { 30 | log.Warnf("update ui failed, please download ui manually from %s and unzip to %s: %v", op.GetSettingStr(setting.FRONTEND_URL), config.Base().Server.UIPath, err) 31 | return err 32 | } 33 | log.Infof("update ui success") 34 | return nil 35 | } 36 | 37 | func updateUI() error { 38 | bytes, err := download(op.GetSettingStr(setting.FRONTEND_URL), op.GetSettingBool(setting.FRONTEND_URL_PROXY)) 39 | if err != nil { 40 | return err 41 | } 42 | if err := unzip(bytes, config.Base().Server.UIPath); err != nil { 43 | return err 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/utils/generic/queue.go: -------------------------------------------------------------------------------- 1 | package generic 2 | 3 | type Integer interface { 4 | ~int | ~int8 | ~int16 | ~int32 | ~int64 | 5 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 6 | } 7 | 8 | type Queue[T Integer] struct { 9 | Data []T 10 | Ptr int 11 | Full bool 12 | } 13 | 14 | func NewQueue[T Integer](capacity int) *Queue[T] { 15 | if capacity <= 0 { 16 | panic("queue capacity must be positive") 17 | } 18 | return &Queue[T]{ 19 | Data: make([]T, 0, capacity), 20 | Ptr: 0, 21 | Full: false, 22 | } 23 | } 24 | 25 | func (q *Queue[T]) Update(value T) { 26 | if q.Full { 27 | q.Data[q.Ptr] = value 28 | q.Ptr = (q.Ptr + 1) % len(q.Data) 29 | } else { 30 | q.Data = append(q.Data, value) 31 | if len(q.Data) == cap(q.Data) { 32 | q.Full = true 33 | } 34 | } 35 | } 36 | 37 | func (q *Queue[T]) GetAll() []T { 38 | if len(q.Data) == 0 { 39 | return nil 40 | } 41 | 42 | result := make([]T, len(q.Data)) 43 | 44 | if q.Full { 45 | tailLen := len(q.Data) - q.Ptr 46 | copy(result, q.Data[q.Ptr:]) 47 | copy(result[tailLen:], q.Data[:q.Ptr]) 48 | } else { 49 | copy(result, q.Data) 50 | } 51 | 52 | return result 53 | } 54 | 55 | func (q *Queue[T]) Clear() { 56 | q.Data = q.Data[:0] 57 | q.Ptr = 0 58 | q.Full = false 59 | } 60 | 61 | func (q *Queue[T]) Average() T { 62 | if len(q.Data) == 0 { 63 | return 0 64 | } 65 | 66 | var sum int64 67 | for _, value := range q.Data { 68 | sum += int64(value) 69 | } 70 | 71 | return T(sum / int64(len(q.Data))) 72 | } 73 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/database/client/sqlite" 7 | "github.com/bestruirui/bestsub/internal/database/interfaces" 8 | "github.com/bestruirui/bestsub/internal/database/op" 9 | "github.com/bestruirui/bestsub/internal/utils/log" 10 | ) 11 | 12 | func Initialize(sqltype, path string) error { 13 | var err error 14 | var repo interfaces.Repository 15 | switch sqltype { 16 | case "sqlite": 17 | repo, err = sqlite.New(path) 18 | if err != nil { 19 | log.Fatalf("failed to create sqlite database: %v", err) 20 | } 21 | default: 22 | log.Fatalf("unsupported database type: %s", sqltype) 23 | } 24 | op.SetRepo(repo) 25 | if err := repo.Migrate(); err != nil { 26 | log.Fatalf("failed to migrate database: %v", err) 27 | } 28 | if err := initAuth(context.Background(), op.AuthRepo()); err != nil { 29 | log.Fatalf("failed to initialize auth: %v", err) 30 | } 31 | if err := initSystemSetting(context.Background(), op.SettingRepo()); err != nil { 32 | log.Fatalf("failed to initialize system config: %v", err) 33 | } 34 | if err := initNotifyTemplate(context.Background(), op.NotifyTemplateRepo()); err != nil { 35 | log.Fatalf("failed to initialize notify templates: %v", err) 36 | } 37 | return nil 38 | } 39 | func Close() error { 40 | if err := op.Close(); err != nil { 41 | log.Errorf("failed to close database: %v", err) 42 | return err 43 | } 44 | log.Debugf("database closed") 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/core/system/monitor.go: -------------------------------------------------------------------------------- 1 | package system 2 | 3 | import ( 4 | "os" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/bestruirui/bestsub/internal/models/system" 9 | "github.com/bestruirui/bestsub/internal/utils/log" 10 | "github.com/shirou/gopsutil/v4/process" 11 | ) 12 | 13 | var ( 14 | startTime string 15 | uploadBytes uint64 16 | downloadBytes uint64 17 | ) 18 | 19 | func init() { 20 | startTime = time.Now().Format(time.RFC3339) 21 | } 22 | 23 | func AddUploadBytes(bytes uint64) { 24 | atomic.AddUint64(&uploadBytes, bytes) 25 | } 26 | 27 | func AddDownloadBytes(bytes uint64) { 28 | atomic.AddUint64(&downloadBytes, bytes) 29 | } 30 | 31 | func GetSystemInfo() *system.Info { 32 | proc, err := process.NewProcess(int32(os.Getpid())) 33 | if err != nil { 34 | log.Debugf("Failed to create process instance: %v", err) 35 | return nil 36 | } 37 | 38 | memInfo, err := proc.MemoryInfo() 39 | if err != nil { 40 | log.Debugf("Failed to get process memory info: %v", err) 41 | return nil 42 | } 43 | 44 | cpuPercent, err := proc.CPUPercent() 45 | if err != nil { 46 | log.Debugf("Failed to get process CPU percent: %v", err) 47 | return nil 48 | } 49 | 50 | return &system.Info{ 51 | MemoryUsed: memInfo.RSS, 52 | CPUPercent: cpuPercent, 53 | StartTime: startTime, 54 | UploadBytes: atomic.LoadUint64(&uploadBytes), 55 | DownloadBytes: atomic.LoadUint64(&downloadBytes), 56 | } 57 | } 58 | 59 | func Reset() { 60 | atomic.StoreUint64(&uploadBytes, 0) 61 | atomic.StoreUint64(&downloadBytes, 0) 62 | } 63 | -------------------------------------------------------------------------------- /internal/database/op/auth.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/interfaces" 8 | "github.com/bestruirui/bestsub/internal/models/auth" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | var authRepo interfaces.AuthRepository 13 | var authData *auth.Data 14 | 15 | func AuthRepo() interfaces.AuthRepository { 16 | if authRepo == nil { 17 | authRepo = repo.Auth() 18 | } 19 | return authRepo 20 | } 21 | 22 | func AuthGet() (auth.Data, error) { 23 | var err error 24 | if authData == nil { 25 | authData, err = AuthRepo().Get(context.Background()) 26 | } 27 | return *authData, err 28 | } 29 | func AuthUpdateName(name string) error { 30 | if authData == nil { 31 | AuthGet() 32 | } 33 | authData.UserName = name 34 | err := AuthRepo().UpdateName(context.Background(), name) 35 | if err != nil { 36 | return err 37 | } 38 | return nil 39 | } 40 | func AuthUpdatePassWord(password string) error { 41 | if authData == nil { 42 | AuthGet() 43 | } 44 | hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 45 | if err != nil { 46 | return err 47 | } 48 | authData.Password = string(hashedBytes) 49 | err = AuthRepo().UpdatePassword(context.Background(), authData.Password) 50 | if err != nil { 51 | return err 52 | } 53 | return nil 54 | } 55 | func AuthVerify(username, password string) error { 56 | if authData == nil { 57 | AuthGet() 58 | } 59 | if authData.UserName != username { 60 | return fmt.Errorf("用户名不匹配") 61 | } 62 | return bcrypt.CompareHashAndPassword([]byte(authData.Password), []byte(password)) 63 | } 64 | -------------------------------------------------------------------------------- /internal/modules/register/register.go: -------------------------------------------------------------------------------- 1 | package register 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/bestruirui/bestsub/internal/utils/desc" 10 | ) 11 | 12 | type registerInfo struct { 13 | im map[string]any 14 | aim map[string][]desc.Data 15 | } 16 | 17 | var registers = map[string]*registerInfo{} 18 | 19 | func register(t string, i any) { 20 | r, exists := registers[t] 21 | if !exists { 22 | r = ®isterInfo{ 23 | im: make(map[string]any), 24 | aim: make(map[string][]desc.Data), 25 | } 26 | registers[t] = r 27 | } 28 | m := strings.ToLower(reflect.TypeOf(i).Elem().Name()) 29 | r.im[m] = i 30 | r.aim[m] = desc.Gen(i) 31 | } 32 | 33 | func Get[T any](t string, m string, c string) (T, error) { 34 | ri, exists := registers[t] 35 | if !exists { 36 | return *new(T), errors.New("category not found") 37 | } 38 | 39 | info, exists := ri.im[m] 40 | if !exists { 41 | return *new(T), errors.New("item not found") 42 | } 43 | 44 | ni := reflect.New(reflect.TypeOf(info).Elem()).Interface() 45 | 46 | if c != "" { 47 | err := json.Unmarshal([]byte(c), ni) 48 | if err != nil { 49 | return *new(T), err 50 | } 51 | } 52 | 53 | return ni.(T), nil 54 | } 55 | 56 | func GetList(t string) []string { 57 | ri, exists := registers[t] 58 | if !exists { 59 | return nil 60 | } 61 | 62 | keys := make([]string, 0, len(ri.im)) 63 | for k := range ri.im { 64 | keys = append(keys, k) 65 | } 66 | return keys 67 | } 68 | 69 | func GetInfoMap(t string) map[string][]desc.Data { 70 | ri, exists := registers[t] 71 | if !exists { 72 | return nil 73 | } 74 | return ri.aim 75 | } 76 | -------------------------------------------------------------------------------- /internal/models/setting/default.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | func DefaultSetting() []Setting { 4 | return []Setting{ 5 | 6 | { 7 | Key: PROXY_ENABLE, 8 | Value: "false", 9 | }, 10 | { 11 | Key: PROXY_URL, 12 | Value: "socks5://user:pass@127.0.0.1:1080", 13 | }, 14 | { 15 | Key: LOG_RETENTION_DAYS, 16 | Value: "7", 17 | }, 18 | { 19 | Key: FRONTEND_URL, 20 | Value: "https://github.com/bestruirui/BestSubFront/releases/latest/download/out.zip", 21 | }, 22 | { 23 | Key: FRONTEND_URL_PROXY, 24 | Value: "false", 25 | }, 26 | { 27 | Key: SUBCONVERTER_URL, 28 | Value: "https://github.com/bestruirui/subconverter/releases/latest/download/", 29 | }, 30 | { 31 | Key: SUBCONVERTER_URL_PROXY, 32 | Value: "false", 33 | }, 34 | { 35 | Key: SUB_DISABLE_AUTO, 36 | Value: "0", 37 | }, 38 | { 39 | Key: NODE_POOL_SIZE, 40 | Value: "1000", 41 | }, 42 | { 43 | Key: NODE_TEST_URL, 44 | Value: "https://www.gstatic.com/generate_204", 45 | }, 46 | { 47 | Key: NODE_TEST_TIMEOUT, 48 | Value: "5", 49 | }, 50 | { 51 | Key: NODE_PROTOCOL_FILTER_ENABLE, 52 | Value: "false", 53 | }, 54 | { 55 | Key: NODE_PROTOCOL_FILTER_MODE, 56 | Value: "false", 57 | }, 58 | { 59 | Key: NODE_PROTOCOL_FILTER, 60 | Value: "", 61 | }, 62 | { 63 | Key: TASK_MAX_THREAD, 64 | Value: "200", 65 | }, 66 | { 67 | Key: TASK_MAX_TIMEOUT, 68 | Value: "60", 69 | }, 70 | { 71 | Key: TASK_MAX_RETRY, 72 | Value: "3", 73 | }, 74 | { 75 | Key: NOTIFY_OPERATION, 76 | Value: "0", 77 | }, 78 | { 79 | Key: NOTIFY_ID, 80 | Value: "0", 81 | }, 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/models/notify/notify.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | type Data struct { 9 | ID uint16 `db:"id" json:"id"` 10 | Name string `db:"name" json:"name"` 11 | Type string `db:"type" json:"type"` 12 | Config string `db:"config" json:"config"` 13 | } 14 | type NameAndID struct { 15 | ID uint16 `json:"id"` 16 | Name string `json:"name"` 17 | } 18 | type Request struct { 19 | Name string `json:"name"` 20 | Type string `json:"type"` 21 | Config any `json:"config"` 22 | } 23 | 24 | type Response struct { 25 | ID uint16 `json:"id"` 26 | Name string `json:"name"` 27 | Type string `json:"type"` 28 | Config any `json:"config"` 29 | } 30 | 31 | type Template struct { 32 | Type string `db:"type" json:"type"` 33 | Template string `db:"template" json:"template"` 34 | } 35 | 36 | type Instance interface { 37 | Init() error 38 | Send(title string, body *bytes.Buffer) error 39 | } 40 | 41 | const ( 42 | TypeLoginSuccess uint16 = 1 << 0 // 登录成功通知 43 | TypeLoginFailed uint16 = 1 << 1 // 登录失败通知 44 | ) 45 | 46 | var TypeMap = map[uint16]string{ 47 | TypeLoginSuccess: "login_success", 48 | TypeLoginFailed: "login_failed", 49 | } 50 | 51 | func (c *Request) GenData(id uint16) Data { 52 | configBytes, err := json.Marshal(c.Config) 53 | if err != nil { 54 | return Data{} 55 | } 56 | return Data{ 57 | ID: id, 58 | Name: c.Name, 59 | Type: c.Type, 60 | Config: string(configBytes), 61 | } 62 | } 63 | func (d *Data) GenResponse() Response { 64 | var config any 65 | json.Unmarshal([]byte(d.Config), &config) 66 | return Response{ 67 | ID: d.ID, 68 | Name: d.Name, 69 | Type: d.Type, 70 | Config: config, 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/sqlite.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | _ "modernc.org/sqlite" 10 | ) 11 | 12 | // DB SQLite数据库连接包装器 13 | type DB struct { 14 | db *sql.DB 15 | } 16 | 17 | // New 创建新的SQLite数据库连接 18 | func New(databasePath string) (interfaces.Repository, error) { 19 | db, err := sql.Open("sqlite", databasePath) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to open database: %w", err) 22 | } 23 | 24 | db.SetMaxOpenConns(1) 25 | db.SetMaxIdleConns(1) 26 | db.SetConnMaxLifetime(time.Hour) 27 | 28 | if err := enablePragmas(db); err != nil { 29 | db.Close() 30 | return nil, fmt.Errorf("failed to set pragmas: %w", err) 31 | } 32 | repository := DB{db: db} 33 | 34 | return &repository, nil 35 | } 36 | 37 | // Close 关闭数据库连接 38 | func (db *DB) Close() error { 39 | return db.db.Close() 40 | } 41 | 42 | // enablePragmas 启用SQLite优化选项 43 | func enablePragmas(db *sql.DB) error { 44 | pragmas := map[string]string{ 45 | "journal_mode": "WAL", // 启用WAL模式提高并发性能 46 | "synchronous": "NORMAL", // 平衡性能和安全性 47 | "cache_size": "-64000", // 64MB缓存 48 | "foreign_keys": "ON", // 启用外键约束 49 | "temp_store": "MEMORY", // 临时表存储在内存中 50 | "busy_timeout": "5000", // 5秒忙等待超时 51 | "wal_autocheckpoint": "1000", // WAL自动检查点 52 | "optimize": "", // 优化数据库 53 | } 54 | 55 | for key, value := range pragmas { 56 | var query string 57 | if value == "" { 58 | query = fmt.Sprintf("PRAGMA %s", key) 59 | } else { 60 | query = fmt.Sprintf("PRAGMA %s = %s", key, value) 61 | } 62 | 63 | if _, err := db.Exec(query); err != nil { 64 | return fmt.Errorf("failed to execute pragma %s: %w", query, err) 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/utils/desc/desc.go: -------------------------------------------------------------------------------- 1 | package desc 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | const ( 8 | TypeBoolean = "boolean" 9 | TypeNumber = "number" 10 | TypeString = "string" 11 | TypeSelect = "select" 12 | TypeMultiSelect = "multi_select" 13 | ) 14 | 15 | type Data struct { 16 | Name string `json:"name,omitempty"` 17 | Key string `json:"key,omitempty"` 18 | Type string `json:"type,omitempty"` 19 | Value string `json:"value,omitempty"` 20 | Options string `json:"options,omitempty"` 21 | Require bool `json:"require,omitempty"` 22 | Desc string `json:"desc,omitempty"` 23 | } 24 | 25 | func Gen(v any) []Data { 26 | t := reflect.TypeOf(v) 27 | if t.Kind() == reflect.Ptr { 28 | t = t.Elem() 29 | } 30 | return gen(t) 31 | } 32 | 33 | func gen(t reflect.Type) []Data { 34 | var items []Data 35 | for i := 0; i < t.NumField(); i++ { 36 | field := t.Field(i) 37 | if field.Type.Kind() == reflect.Struct { 38 | items = append(items, gen(field.Type)...) 39 | continue 40 | } 41 | tag := field.Tag 42 | key, ok := tag.Lookup("json") 43 | if !ok { 44 | continue 45 | } 46 | typeName := tag.Get("type") 47 | if typeName == "" { 48 | typeName = getType(field.Type.Name()) 49 | } 50 | item := Data{ 51 | Name: tag.Get("name"), 52 | Key: key, 53 | Type: typeName, 54 | Value: tag.Get("value"), 55 | Options: tag.Get("options"), 56 | Require: tag.Get("require") == "true", 57 | Desc: tag.Get("desc"), 58 | } 59 | items = append(items, item) 60 | } 61 | return items 62 | } 63 | 64 | func getType(t string) string { 65 | switch t { 66 | case "bool": 67 | return "boolean" 68 | case "int", "int8", "int16", "int32", "int64", 69 | "uint", "uint8", "uint16", "uint32", "uint64", 70 | "float32", "float64": 71 | return "number" 72 | case "string", "[]byte": 73 | return "string" 74 | default: 75 | return "object" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /cmd/bestsub/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/bestruirui/bestsub/internal/config" 5 | "github.com/bestruirui/bestsub/internal/core/cron" 6 | "github.com/bestruirui/bestsub/internal/core/node" 7 | "github.com/bestruirui/bestsub/internal/core/task" 8 | "github.com/bestruirui/bestsub/internal/core/update" 9 | "github.com/bestruirui/bestsub/internal/database" 10 | "github.com/bestruirui/bestsub/internal/database/op" 11 | "github.com/bestruirui/bestsub/internal/models/setting" 12 | "github.com/bestruirui/bestsub/internal/modules/subcer" 13 | "github.com/bestruirui/bestsub/internal/server/auth" 14 | "github.com/bestruirui/bestsub/internal/server/server" 15 | "github.com/bestruirui/bestsub/internal/utils/info" 16 | "github.com/bestruirui/bestsub/internal/utils/log" 17 | "github.com/bestruirui/bestsub/internal/utils/shutdown" 18 | ) 19 | 20 | func main() { 21 | 22 | info.Banner() 23 | 24 | cfg := config.Base() 25 | 26 | if err := log.Initialize(cfg.Log.Level, cfg.Log.Path, cfg.Log.Output); err != nil { 27 | panic(err) 28 | } 29 | if err := database.Initialize(cfg.Database.Type, cfg.Database.Path); err != nil { 30 | panic(err) 31 | } 32 | 33 | if err := server.Initialize(); err != nil { 34 | panic(err) 35 | } 36 | 37 | update.InitUI() 38 | update.InitSubconverter() 39 | 40 | subcer.Start() 41 | 42 | task.Init(op.GetSettingInt(setting.TASK_MAX_THREAD)) 43 | 44 | cron.Start() 45 | cron.FetchLoad() 46 | cron.CheckLoad() 47 | 48 | node.InitNodePool(op.GetSettingInt(setting.NODE_POOL_SIZE)) 49 | 50 | log.CleanupOldLogs(5) 51 | 52 | server.Start() 53 | 54 | shutdown.Register(server.Close) // ↓↓ 55 | shutdown.Register(database.Close) // ↓↓ 56 | shutdown.Register(auth.CloseSession) // ↓↓ 57 | shutdown.Register(node.CloseNodePool) // ↓↓ 58 | shutdown.Register(subcer.Stop) // ↓↓ 59 | shutdown.Register(log.Close) // ↓↓ 60 | 61 | shutdown.Listen() 62 | } 63 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | "unicode" 10 | "unicode/utf8" 11 | ) 12 | 13 | // 检查目录是否可写 14 | func IsWritableDir(dir string) bool { 15 | // 尝试在目录中创建临时文件 16 | testFile := filepath.Join(dir, ".write_test") 17 | file, err := os.Create(testFile) 18 | if err != nil { 19 | return false 20 | } 21 | file.Close() 22 | os.Remove(testFile) 23 | return true 24 | } 25 | 26 | // 检查字符串切片是否包含指定字符串 27 | func Contains(slice []string, item string) bool { 28 | for _, s := range slice { 29 | if s == item { 30 | return true 31 | } 32 | } 33 | return false 34 | } 35 | func RemoveAllControlCharacters(data *[]byte) { 36 | var cleanedData []byte 37 | original := *data 38 | for len(original) > 0 { 39 | r, size := utf8.DecodeRune(original) 40 | if r != utf8.RuneError && (r >= 32 && r <= 126) || r == '\n' || r == '\t' || r == '\r' || unicode.Is(unicode.Han, r) { 41 | cleanedData = append(cleanedData, original[:size]...) 42 | } 43 | original = original[size:] 44 | } 45 | *data = cleanedData 46 | } 47 | func IsDebug() bool { 48 | debug := os.Getenv("BESTSUB_DEBUG") 49 | return strings.ToLower(debug) == "true" 50 | } 51 | 52 | // IPToUint32 将IP地址转换为uint32 53 | func IPToUint32(ip string) uint32 { 54 | ip = strings.TrimSpace(ip) 55 | if ip == "" { 56 | return 0 57 | } 58 | 59 | parts := strings.Split(ip, ".") 60 | if len(parts) != 4 { 61 | return 0 62 | } 63 | 64 | var result uint32 65 | for i, part := range parts { 66 | partInt, err := strconv.Atoi(part) 67 | if err != nil || partInt < 0 || partInt > 255 { 68 | return 0 69 | } 70 | result |= uint32(partInt) << ((3 - i) * 8) 71 | } 72 | return result 73 | } 74 | 75 | // Uint32ToIP 将uint32转换为IP地址 76 | func Uint32ToIP(ip uint32) string { 77 | return fmt.Sprintf("%d.%d.%d.%d", 78 | (ip>>24)&0xFF, 79 | (ip>>16)&0xFF, 80 | (ip>>8)&0xFF, 81 | ip&0xFF) 82 | } 83 | -------------------------------------------------------------------------------- /internal/models/node/node.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/bestruirui/bestsub/internal/utils/generic" 7 | "github.com/cespare/xxhash/v2" 8 | ) 9 | 10 | const ( 11 | Alive uint64 = 1 << 0 12 | Country uint64 = 1 << 1 13 | TikTok uint64 = 1 << 2 14 | TikTokIDC uint64 = 1 << 3 15 | ) 16 | 17 | type Data struct { 18 | Base 19 | Info *Info 20 | } 21 | 22 | type Base struct { 23 | Raw []byte 24 | SubId uint16 25 | UniqueKey uint64 26 | } 27 | 28 | type UniqueKey struct { 29 | Server string `yaml:"server"` 30 | Servername string `yaml:"servername"` 31 | Port string `yaml:"port"` 32 | Type string `yaml:"type"` 33 | Uuid string `yaml:"uuid"` 34 | Username string `yaml:"username"` 35 | Password string `yaml:"password"` 36 | } 37 | 38 | type Info struct { 39 | SpeedUp generic.Queue[uint32] 40 | SpeedDown generic.Queue[uint32] 41 | Delay generic.Queue[uint16] 42 | Risk uint8 43 | AliveStatus uint64 44 | IP uint32 45 | Country string 46 | } 47 | 48 | type SimpleInfo struct { 49 | SpeedUp uint32 `json:"speed_up"` 50 | SpeedDown uint32 `json:"speed_down"` 51 | Delay uint16 `json:"delay"` 52 | Risk uint8 `json:"risk"` 53 | Count uint32 `json:"count"` 54 | } 55 | 56 | type Filter struct { 57 | SubId []uint16 `json:"sub_id"` 58 | SubIdExclude bool `json:"sub_id_exclude"` 59 | SpeedUpMore uint32 `json:"speed_up_more"` 60 | SpeedDownMore uint32 `json:"speed_down_more"` 61 | Country []string `json:"country"` 62 | CountryExclude bool `json:"country_exclude"` 63 | DelayLessThan uint16 `json:"delay_less_than"` 64 | AliveStatus uint64 `json:"alive_status"` 65 | RiskLessThan uint8 `json:"risk_less_than"` 66 | } 67 | 68 | func (i *Info) SetAliveStatus(AliveStatus uint64, status bool) { 69 | if status { 70 | i.AliveStatus |= AliveStatus 71 | } else { 72 | i.AliveStatus &= ^AliveStatus 73 | } 74 | } 75 | 76 | func (u *UniqueKey) Gen() uint64 { 77 | bytes, _ := json.Marshal(u) 78 | return xxhash.Sum64(bytes) 79 | } 80 | -------------------------------------------------------------------------------- /internal/core/node/info.go: -------------------------------------------------------------------------------- 1 | package node 2 | 3 | import nodeModel "github.com/bestruirui/bestsub/internal/models/node" 4 | 5 | func RefreshInfo() { 6 | refreshMutex.Lock() 7 | defer refreshMutex.Unlock() 8 | 9 | for k := range subAggBuf { 10 | delete(subAggBuf, k) 11 | } 12 | for k := range countryAggBuf { 13 | delete(countryAggBuf, k) 14 | } 15 | 16 | poolMutex.RLock() 17 | for _, n := range pool { 18 | s := subAggBuf[n.Base.SubId] 19 | if s == nil { 20 | s = &infoSums{} 21 | subAggBuf[n.Base.SubId] = s 22 | } 23 | s.count++ 24 | s.sumSpeedUp += uint64(n.Info.SpeedUp.Average()) 25 | s.sumSpeedDown += uint64(n.Info.SpeedDown.Average()) 26 | s.sumDelay += uint64(n.Info.Delay.Average()) 27 | s.sumRisk += uint64(n.Info.Risk) 28 | 29 | c := countryAggBuf[n.Info.Country] 30 | if c == nil { 31 | c = &infoSums{} 32 | countryAggBuf[n.Info.Country] = c 33 | } 34 | c.count++ 35 | c.sumSpeedUp += uint64(n.Info.SpeedUp.Average()) 36 | c.sumSpeedDown += uint64(n.Info.SpeedDown.Average()) 37 | c.sumDelay += uint64(n.Info.Delay.Average()) 38 | c.sumRisk += uint64(n.Info.Risk) 39 | } 40 | poolMutex.RUnlock() 41 | 42 | for k := range subInfoMap { 43 | delete(subInfoMap, k) 44 | } 45 | for k := range countryInfoMap { 46 | delete(countryInfoMap, k) 47 | } 48 | 49 | for subID, s := range subAggBuf { 50 | if s.count == 0 { 51 | continue 52 | } 53 | subInfoMap[subID] = nodeModel.SimpleInfo{ 54 | Count: s.count, 55 | SpeedUp: uint32(s.sumSpeedUp / uint64(s.count)), 56 | SpeedDown: uint32(s.sumSpeedDown / uint64(s.count)), 57 | Delay: uint16(s.sumDelay / uint64(s.count)), 58 | Risk: uint8(s.sumRisk / uint64(s.count)), 59 | } 60 | } 61 | for country, c := range countryAggBuf { 62 | if c.count == 0 { 63 | continue 64 | } 65 | countryInfoMap[country] = nodeModel.SimpleInfo{ 66 | Count: c.count, 67 | SpeedUp: uint32(c.sumSpeedUp / uint64(c.count)), 68 | SpeedDown: uint32(c.sumSpeedDown / uint64(c.count)), 69 | Delay: uint16(c.sumDelay / uint64(c.count)), 70 | Risk: uint8(c.sumRisk / uint64(c.count)), 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/utils/info/info.go: -------------------------------------------------------------------------------- 1 | package info 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/bestruirui/bestsub/internal/utils/color" 9 | ) 10 | 11 | var ( 12 | Version = "dev" 13 | Commit = "unknown" 14 | BuildTime = "unknown" 15 | Author = "bestrui" 16 | Repo = "https://github.com/bestruirui/bestsub" 17 | ) 18 | 19 | func Banner() { 20 | logo := ` 21 | ██████╗ ███████╗███████╗████████╗███████╗██╗ ██╗██████╗ 22 | ██╔══██╗██╔════╝██╔════╝╚══██╔══╝██╔════╝██║ ██║██╔══██╗ 23 | ██████╔╝█████╗ ███████╗ ██║ ███████╗██║ ██║██████╔╝ 24 | ██╔══██╗██╔══╝ ╚════██║ ██║ ╚════██║██║ ██║██╔══██╗ 25 | ██████╔╝███████╗███████║ ██║ ███████║╚██████╔╝██████╔╝ 26 | ╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═════╝ 27 | ` 28 | 29 | fmt.Print(color.Cyan + color.Bold) 30 | fmt.Println(logo) 31 | fmt.Print(color.Reset) 32 | 33 | fmt.Print(color.Blue + color.Bold) 34 | fmt.Println(" 🚀 BestSub - Best Sub For You") 35 | fmt.Print(color.Reset) 36 | 37 | fmt.Print(color.Dim) 38 | fmt.Println(" " + strings.Repeat("─", 60)) 39 | fmt.Print(color.Reset) 40 | 41 | printInfo("Version", Version, color.Green) 42 | printInfo("Commit", Commit[:min(8, len(Commit))], color.Yellow) 43 | printInfo("Build Time", formatDate(BuildTime), color.Blue) 44 | printInfo("Built By", Author, color.Purple) 45 | printInfo("Repo", Repo, color.Cyan) 46 | 47 | fmt.Print(color.Dim) 48 | fmt.Println(" " + strings.Repeat("═", 60)) 49 | fmt.Print(color.Reset) 50 | } 51 | 52 | func printInfo(label, value, print_color string) { 53 | fmt.Printf(" %s%-12s%s %s%s%s\n", 54 | color.Dim, label+":", color.Reset, 55 | print_color, value, color.Reset) 56 | } 57 | 58 | func formatDate(date string) string { 59 | if date == "unknown" || date == "" { 60 | return "unknown" 61 | } 62 | 63 | layouts := []string{ 64 | "2006-01-02T15:04:05Z", 65 | "2006-01-02 15:04:05", 66 | "2006-01-02", 67 | time.RFC3339, 68 | } 69 | 70 | for _, layout := range layouts { 71 | if t, err := time.Parse(layout, date); err == nil { 72 | return t.Format("2006-01-02 15:04") 73 | } 74 | } 75 | 76 | return date 77 | } 78 | 79 | func min(a, b int) int { 80 | if a < b { 81 | return a 82 | } 83 | return b 84 | } 85 | -------------------------------------------------------------------------------- /internal/modules/notify/notify.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/op" 8 | notifyModel "github.com/bestruirui/bestsub/internal/models/notify" 9 | "github.com/bestruirui/bestsub/internal/models/setting" 10 | _ "github.com/bestruirui/bestsub/internal/modules/notify/channel" 11 | "github.com/bestruirui/bestsub/internal/modules/register" 12 | "github.com/bestruirui/bestsub/internal/utils/desc" 13 | "github.com/bestruirui/bestsub/internal/utils/log" 14 | ) 15 | 16 | type Desc = desc.Data 17 | 18 | func SendSystemNotify(operation uint16, title string, content any) error { 19 | if operation&uint16(op.GetSettingInt(setting.NOTIFY_OPERATION)) == 0 { 20 | return nil 21 | } 22 | 23 | nt, err := op.GetNotifyTemplateByType(notifyModel.TypeMap[operation]) 24 | if err != nil { 25 | log.Errorf("failed to get notify template: %v", operation) 26 | return err 27 | } 28 | 29 | t, err := template.New("notify").Parse(nt) 30 | if err != nil { 31 | log.Errorf("failed to parse notify template: %v", err) 32 | return err 33 | } 34 | 35 | var buf bytes.Buffer 36 | err = t.Execute(&buf, content) 37 | if err != nil { 38 | log.Errorf("failed to execute notify template: %v", err) 39 | return err 40 | } 41 | 42 | sysNotifyID := op.GetSettingInt(setting.NOTIFY_ID) 43 | notifyConfig, err := op.GetNotifyByID(uint16(sysNotifyID)) 44 | if err != nil { 45 | log.Errorf("failed to get notify config: %v", sysNotifyID) 46 | return err 47 | } 48 | 49 | notify, err := Get(notifyConfig.Type, notifyConfig.Config) 50 | if err != nil { 51 | log.Errorf("failed to get notify: %v", err) 52 | return err 53 | } 54 | 55 | err = notify.Init() 56 | if err != nil { 57 | log.Errorf("failed to init notify: %v", err) 58 | return err 59 | } 60 | 61 | err = notify.Send(title, &buf) 62 | if err != nil { 63 | log.Errorf("failed to send notify: %v", err) 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func Get(m string, c string) (notifyModel.Instance, error) { 71 | return register.Get[notifyModel.Instance]("notify", m, c) 72 | } 73 | 74 | func GetChannels() []string { 75 | return register.GetList("notify") 76 | } 77 | 78 | func GetInfoMap() map[string][]desc.Data { 79 | return register.GetInfoMap("notify") 80 | } 81 | -------------------------------------------------------------------------------- /internal/server/middleware/static.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/bestruirui/bestsub/internal/config" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | var staticExtensions = map[string]bool{ 14 | ".js": true, 15 | ".css": true, 16 | ".mjs": true, 17 | 18 | ".png": true, 19 | ".jpg": true, 20 | ".jpeg": true, 21 | ".gif": true, 22 | ".svg": true, 23 | ".ico": true, 24 | ".webp": true, 25 | ".avif": true, 26 | ".bmp": true, 27 | 28 | ".woff": true, 29 | ".woff2": true, 30 | ".ttf": true, 31 | ".eot": true, 32 | ".otf": true, 33 | 34 | ".xml": true, 35 | ".json": true, 36 | ".txt": true, 37 | ".pdf": true, 38 | } 39 | 40 | var ( 41 | cacheOneHourHeader = "public, max-age=3600" 42 | cacheOneWeekHeader = "public, max-age=604800" 43 | cacheOneMonthHeader = "public, max-age=2592000" 44 | cacheOneYearHeader = "public, max-age=31536000" 45 | 46 | fileServer http.Handler 47 | ) 48 | 49 | func init() { 50 | 51 | fileServer = http.FileServer(http.Dir(config.Base().Server.UIPath)) 52 | } 53 | 54 | func Static() gin.HandlerFunc { 55 | return func(c *gin.Context) { 56 | reqPath := c.Request.URL.Path 57 | 58 | if strings.HasPrefix(reqPath, "/api") { 59 | c.Next() 60 | return 61 | } 62 | 63 | if reqPath == "/" || reqPath == "" { 64 | reqPath = "/index.html" 65 | } 66 | 67 | ext := path.Ext(reqPath) 68 | 69 | switch ext { 70 | case ".js", ".css", ".mjs": 71 | c.Header("Cache-Control", cacheOneYearHeader) 72 | case ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp", ".avif", ".bmp": 73 | c.Header("Cache-Control", cacheOneMonthHeader) 74 | case ".woff", ".woff2", ".ttf", ".eot", ".otf": 75 | c.Header("Cache-Control", cacheOneYearHeader) 76 | case ".xml", ".json", ".txt": 77 | c.Header("Cache-Control", cacheOneHourHeader) 78 | case ".html": 79 | c.Header("Cache-Control", cacheOneHourHeader) 80 | default: 81 | c.Header("Cache-Control", cacheOneWeekHeader) 82 | } 83 | 84 | if _, err := os.Stat(config.Base().Server.UIPath + reqPath); err != nil { 85 | if staticExtensions[ext] { 86 | c.Status(http.StatusNotFound) 87 | return 88 | } 89 | c.Request.URL.Path = "/index.html" 90 | } 91 | fileServer.ServeHTTP(c.Writer, c.Request) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /internal/utils/cache/cache.go: -------------------------------------------------------------------------------- 1 | // This implementation is based on and modified from https://github.com/fanjindong/go-cache 2 | package cache 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/cespare/xxhash/v2" 8 | ) 9 | 10 | func keyToString[K comparable](key K) string { 11 | return fmt.Sprintf("%v", key) 12 | } 13 | 14 | type Cache[K comparable, V any] interface { 15 | Set(k K, v V) 16 | Get(k K) (V, bool) 17 | GetAll() map[K]V 18 | Del(keys ...K) int 19 | Exists(keys ...K) bool 20 | Len() int 21 | Clear() 22 | } 23 | 24 | func New[K comparable, V any](shards int) Cache[K, V] { 25 | if shards <= 0 { 26 | shards = 1024 27 | } 28 | 29 | c := &cache[K, V]{ 30 | shards: make([]*shard[K, V], shards), 31 | shardMask: uint64(shards - 1), 32 | } 33 | for i := 0; i < shards; i++ { 34 | c.shards[i] = &shard[K, V]{hashmap: map[K]V{}} 35 | } 36 | 37 | return c 38 | } 39 | 40 | type cache[K comparable, V any] struct { 41 | shards []*shard[K, V] 42 | shardMask uint64 43 | } 44 | 45 | func (c *cache[K, V]) Set(k K, v V) { 46 | hashedKey := xxhash.Sum64String(keyToString(k)) 47 | shard := c.getShard(hashedKey) 48 | shard.set(k, v) 49 | } 50 | 51 | func (c *cache[K, V]) Get(k K) (V, bool) { 52 | hashedKey := xxhash.Sum64String(keyToString(k)) 53 | shard := c.getShard(hashedKey) 54 | return shard.get(k) 55 | } 56 | 57 | func (c *cache[K, V]) GetAll() map[K]V { 58 | result := make(map[K]V) 59 | for _, shard := range c.shards { 60 | shardData := shard.getAll() 61 | for k, v := range shardData { 62 | result[k] = v 63 | } 64 | } 65 | return result 66 | } 67 | 68 | func (c *cache[K, V]) Del(ks ...K) int { 69 | var count int 70 | for _, k := range ks { 71 | hashedKey := xxhash.Sum64String(keyToString(k)) 72 | shard := c.getShard(hashedKey) 73 | count += shard.del(k) 74 | } 75 | return count 76 | } 77 | 78 | func (c *cache[K, V]) Exists(ks ...K) bool { 79 | for _, k := range ks { 80 | if _, found := c.Get(k); !found { 81 | return false 82 | } 83 | } 84 | return true 85 | } 86 | 87 | func (c *cache[K, V]) Len() int { 88 | var count int 89 | for _, shard := range c.shards { 90 | count += shard.len() 91 | } 92 | return count 93 | } 94 | 95 | func (c *cache[K, V]) getShard(hashedKey uint64) (shard *shard[K, V]) { 96 | return c.shards[hashedKey&c.shardMask] 97 | } 98 | 99 | func (c *cache[K, V]) Clear() { 100 | for _, s := range c.shards { 101 | s.clear() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /internal/models/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "time" 4 | 5 | type Data struct { 6 | ID uint8 `db:"id" json:"-"` 7 | UserName string `db:"username" json:"username"` 8 | Password string `db:"password" json:"-"` 9 | } 10 | 11 | type Session struct { 12 | IsActive bool `json:"is_active"` 13 | ClientIP uint32 `json:"client_ip"` 14 | UserAgent string `json:"user_agent"` 15 | ExpiresAt uint32 `json:"expires_at"` 16 | CreatedAt uint32 `json:"created_at"` 17 | LastAccessAt uint32 `json:"last_access_at"` 18 | HashRToken uint64 `json:"-"` 19 | HashAToken uint64 `json:"-"` 20 | } 21 | 22 | type SessionResponse struct { 23 | ID uint8 `json:"id"` 24 | IsActive bool `json:"is_active"` 25 | ClientIP string `json:"client_ip"` 26 | UserAgent string `json:"user_agent"` 27 | ExpiresAt time.Time `json:"expires_at"` 28 | CreatedAt time.Time `json:"created_at"` 29 | LastAccessAt time.Time `json:"last_access_at"` 30 | } 31 | 32 | type LoginRequest struct { 33 | Username string `json:"username" binding:"required" example:"admin"` 34 | Password string `json:"password" binding:"required" example:"admin"` 35 | } 36 | 37 | type LoginResponse struct { 38 | AccessToken string `json:"access_token" example:"access_token_string"` 39 | RefreshToken string `json:"refresh_token" example:"refresh_token_string"` 40 | AccessExpiresAt time.Time `json:"access_expires_at" example:"2024-01-01T12:00:00Z"` 41 | RefreshExpiresAt time.Time `json:"refresh_expires_at" example:"2024-01-01T12:00:00Z"` 42 | } 43 | 44 | type ChangePasswordRequest struct { 45 | Username string `json:"username" binding:"required" example:"admin"` 46 | OldPassword string `json:"old_password" binding:"required" example:"old_password"` 47 | NewPassword string `json:"new_password" binding:"required" example:"new_password"` 48 | } 49 | 50 | type UpdateUserInfoRequest struct { 51 | Username string `json:"username" binding:"required" example:"admin"` 52 | } 53 | 54 | type SessionListResponse struct { 55 | Sessions []SessionResponse `json:"sessions"` 56 | Total uint8 `json:"total"` 57 | } 58 | 59 | type RefreshTokenRequest struct { 60 | RefreshToken string `json:"refresh_token" binding:"required" example:"refresh_token_string"` 61 | } 62 | type LoginNotify struct { 63 | Username string `json:"username"` 64 | IP string `json:"ip"` 65 | Time string `json:"time"` 66 | Msg string `json:"msg"` 67 | UserAgent string `json:"user_agent"` 68 | } 69 | -------------------------------------------------------------------------------- /internal/server/handlers/setting.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/op" 8 | "github.com/bestruirui/bestsub/internal/models/setting" 9 | "github.com/bestruirui/bestsub/internal/server/middleware" 10 | "github.com/bestruirui/bestsub/internal/server/resp" 11 | "github.com/bestruirui/bestsub/internal/server/router" 12 | "github.com/bestruirui/bestsub/internal/utils/log" 13 | "github.com/gin-gonic/gin" 14 | ) 15 | 16 | func init() { 17 | 18 | router.NewGroupRouter("/api/v1/setting"). 19 | Use(middleware.Auth()). 20 | AddRoute( 21 | router.NewRoute("", router.GET). 22 | Handle(getSetting), 23 | ). 24 | AddRoute( 25 | router.NewRoute("", router.PUT). 26 | Handle(updateSetting), 27 | ) 28 | } 29 | 30 | // getSetting 获取配置项 31 | // @Summary 获取配置项 32 | // @Description 获取系统所有配置项,支持按分组过滤和关键字搜索 33 | // @Tags 配置 34 | // @Accept json 35 | // @Produce json 36 | // @Security BearerAuth 37 | // @Param group query string false "分组名称" 38 | // @Success 200 {object} resp.ResponseStruct{data=[]setting.Setting} "获取成功" 39 | // @Failure 401 {object} resp.ResponseStruct "未授权" 40 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 41 | // @Router /api/v1/setting [get] 42 | func getSetting(c *gin.Context) { 43 | result, err := op.GetAllSetting(context.Background()) 44 | if err != nil { 45 | log.Errorf("Failed to get all setting: %v", err) 46 | resp.Error(c, http.StatusInternalServerError, "failed to get all setting") 47 | return 48 | } 49 | resp.Success(c, result) 50 | } 51 | 52 | // updateSetting 更新配置项 53 | // @Summary 更新配置项 54 | // @Description 根据请求数据中的ID批量更新配置项的值和描述 55 | // @Tags 配置 56 | // @Accept json 57 | // @Produce json 58 | // @Security BearerAuth 59 | // @Param request body []setting.Setting true "更新配置项请求" 60 | // @Success 200 {object} resp.ResponseStruct "更新成功" 61 | // @Failure 400 {object} resp.ResponseStruct "请求参数错误" 62 | // @Failure 401 {object} resp.ResponseStruct "未授权" 63 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 64 | // @Router /api/v1/setting [put] 65 | func updateSetting(c *gin.Context) { 66 | var req []setting.Setting 67 | if err := c.ShouldBindJSON(&req); err != nil { 68 | resp.ErrorBadRequest(c) 69 | return 70 | } 71 | 72 | err := op.UpdateSetting(context.Background(), &req) 73 | if err != nil { 74 | log.Errorf("Failed to update config: %v", err) 75 | resp.Error(c, http.StatusInternalServerError, "failed to update config") 76 | return 77 | } 78 | 79 | resp.Success(c, nil) 80 | } 81 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/migration/001_table.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import "github.com/bestruirui/bestsub/internal/database/migration" 4 | 5 | // Migration001Table 初始数据库架构 6 | func Migration001Table() string { 7 | return ` 8 | CREATE TABLE IF NOT EXISTS "auth" ( 9 | "id" INTEGER, 10 | "username" TEXT NOT NULL UNIQUE, 11 | "password" TEXT NOT NULL, 12 | PRIMARY KEY("id") 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS "setting" ( 16 | "key" TEXT NOT NULL UNIQUE, 17 | "value" TEXT NOT NULL, 18 | PRIMARY KEY("key") 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS "notify_template" ( 22 | "type" TEXT NOT NULL, 23 | "template" TEXT NOT NULL, 24 | PRIMARY KEY("type") 25 | ); 26 | 27 | CREATE TABLE IF NOT EXISTS "notify" ( 28 | "id" INTEGER NOT NULL UNIQUE, 29 | "name" TEXT NOT NULL, 30 | "type" TEXT NOT NULL, 31 | "config" TEXT NOT NULL, 32 | PRIMARY KEY("id") 33 | ); 34 | 35 | CREATE TABLE IF NOT EXISTS "check_task" ( 36 | "id" INTEGER, 37 | "enable" BOOLEAN NOT NULL, 38 | "name" TEXT, 39 | "task" TEXT NOT NULL, 40 | "config" TEXT, 41 | "result" TEXT, 42 | PRIMARY KEY("id") 43 | ); 44 | 45 | CREATE TABLE IF NOT EXISTS "storage" ( 46 | "id" INTEGER, 47 | "name" TEXT, 48 | "type" TEXT NOT NULL, 49 | "config" TEXT NOT NULL, 50 | PRIMARY KEY("id") 51 | ); 52 | 53 | CREATE TABLE IF NOT EXISTS "sub_template" ( 54 | "id" INTEGER, 55 | "name" TEXT, 56 | "type" TEXT NOT NULL, 57 | "template" TEXT NOT NULL, 58 | PRIMARY KEY("id") 59 | ); 60 | 61 | CREATE TABLE IF NOT EXISTS "sub" ( 62 | "id" INTEGER NOT NULL, 63 | "enable" BOOLEAN NOT NULL DEFAULT true, 64 | "name" TEXT, 65 | "cron_expr" TEXT NOT NULL, 66 | "config" TEXT NOT NULL, 67 | "result" TEXT DEFAULT '{}', 68 | "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 69 | "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 70 | PRIMARY KEY("id") 71 | ); 72 | 73 | CREATE TABLE IF NOT EXISTS "share" ( 74 | "id" INTEGER NOT NULL, 75 | "enable" BOOLEAN NOT NULL DEFAULT false, 76 | "name" TEXT NOT NULL, 77 | "gen" TEXT NOT NULL, 78 | "token" TEXT NOT NULL UNIQUE, 79 | "access_count" INTEGER DEFAULT 0, 80 | "max_access_count" INTEGER DEFAULT 0, 81 | "expires" INTEGER DEFAULT 0, 82 | PRIMARY KEY("id") 83 | ); 84 | 85 | CREATE TABLE IF NOT EXISTS "migration" ( 86 | "date" INTEGER NOT NULL UNIQUE, 87 | "version" TEXT NOT NULL, 88 | "description" TEXT, 89 | "applied_at" DATETIME NOT NULL, 90 | PRIMARY KEY("date") 91 | ); 92 | ` 93 | } 94 | 95 | // init 自动注册迁移 96 | func init() { 97 | migration.Register(ClientName, 202507171100, "dev", "Tables", Migration001Table) 98 | } 99 | -------------------------------------------------------------------------------- /internal/server/handlers/pprof.go: -------------------------------------------------------------------------------- 1 | //go:build debug 2 | 3 | package handlers 4 | 5 | import ( 6 | "net/http/pprof" 7 | 8 | "github.com/bestruirui/bestsub/internal/server/router" 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func init() { 13 | 14 | router.NewGroupRouter("/debug/pprof"). 15 | AddRoute( 16 | router.NewRoute("/", router.GET). 17 | Handle(index), 18 | ). 19 | AddRoute( 20 | router.NewRoute("/cmdline", router.GET). 21 | Handle(cmdline), 22 | ). 23 | AddRoute( 24 | router.NewRoute("/profile", router.GET). 25 | Handle(profile), 26 | ). 27 | AddRoute( 28 | router.NewRoute("/symbol", router.GET). 29 | Handle(symbol), 30 | ). 31 | AddRoute( 32 | router.NewRoute("/symbol", router.POST). 33 | Handle(symbol), 34 | ). 35 | AddRoute( 36 | router.NewRoute("/trace", router.GET). 37 | Handle(trace), 38 | ). 39 | AddRoute( 40 | router.NewRoute("/allocs", router.GET). 41 | Handle(allocs), 42 | ). 43 | AddRoute( 44 | router.NewRoute("/block", router.GET). 45 | Handle(block), 46 | ). 47 | AddRoute( 48 | router.NewRoute("/goroutine", router.GET). 49 | Handle(goroutine), 50 | ). 51 | AddRoute( 52 | router.NewRoute("/heap", router.GET). 53 | Handle(heap), 54 | ). 55 | AddRoute( 56 | router.NewRoute("/mutex", router.GET). 57 | Handle(mutex), 58 | ). 59 | AddRoute( 60 | router.NewRoute("/threadcreate", router.GET). 61 | Handle(threadcreate), 62 | ) 63 | } 64 | 65 | func index(c *gin.Context) { 66 | pprof.Index(c.Writer, c.Request) 67 | } 68 | 69 | func cmdline(c *gin.Context) { 70 | pprof.Cmdline(c.Writer, c.Request) 71 | } 72 | 73 | func profile(c *gin.Context) { 74 | pprof.Profile(c.Writer, c.Request) 75 | } 76 | 77 | func symbol(c *gin.Context) { 78 | pprof.Symbol(c.Writer, c.Request) 79 | } 80 | 81 | func trace(c *gin.Context) { 82 | pprof.Trace(c.Writer, c.Request) 83 | } 84 | 85 | func allocs(c *gin.Context) { 86 | pprof.Handler("allocs").ServeHTTP(c.Writer, c.Request) 87 | } 88 | 89 | func block(c *gin.Context) { 90 | pprof.Handler("block").ServeHTTP(c.Writer, c.Request) 91 | } 92 | 93 | func goroutine(c *gin.Context) { 94 | pprof.Handler("goroutine").ServeHTTP(c.Writer, c.Request) 95 | } 96 | 97 | func heap(c *gin.Context) { 98 | pprof.Handler("heap").ServeHTTP(c.Writer, c.Request) 99 | } 100 | 101 | func mutex(c *gin.Context) { 102 | pprof.Handler("mutex").ServeHTTP(c.Writer, c.Request) 103 | } 104 | 105 | func threadcreate(c *gin.Context) { 106 | pprof.Handler("threadcreate").ServeHTTP(c.Writer, c.Request) 107 | } 108 | -------------------------------------------------------------------------------- /internal/database/op/storage.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/interfaces" 8 | "github.com/bestruirui/bestsub/internal/models/storage" 9 | "github.com/bestruirui/bestsub/internal/utils/cache" 10 | ) 11 | 12 | var storageRepo interfaces.StorageRepository 13 | var storageCache = cache.New[uint16, storage.Data](16) 14 | 15 | func StorageRepo() interfaces.StorageRepository { 16 | if storageRepo == nil { 17 | storageRepo = repo.Storage() 18 | } 19 | return storageRepo 20 | } 21 | func GetStorageList(ctx context.Context) ([]storage.Data, error) { 22 | storageList := storageCache.GetAll() 23 | if len(storageList) == 0 { 24 | err := refreshStorageCache(context.Background()) 25 | if err != nil { 26 | return nil, err 27 | } 28 | storageList = storageCache.GetAll() 29 | } 30 | var result = make([]storage.Data, 0, len(storageList)) 31 | for _, v := range storageList { 32 | result = append(result, v) 33 | } 34 | return result, nil 35 | } 36 | 37 | func GetStorageByID(ctx context.Context, id uint16) (*storage.Data, error) { 38 | if storageCache.Len() == 0 { 39 | if err := refreshStorageCache(ctx); err != nil { 40 | return nil, err 41 | } 42 | } 43 | if s, ok := storageCache.Get(id); ok { 44 | return &s, nil 45 | } 46 | return nil, fmt.Errorf("storage not found") 47 | } 48 | func CreateStorage(ctx context.Context, storage *storage.Data) error { 49 | if storageCache.Len() == 0 { 50 | if err := refreshStorageCache(ctx); err != nil { 51 | return err 52 | } 53 | } 54 | if err := StorageRepo().Create(ctx, storage); err != nil { 55 | return err 56 | } 57 | storageCache.Set(storage.ID, *storage) 58 | return nil 59 | } 60 | func UpdateStorage(ctx context.Context, storage *storage.Data) error { 61 | if storageCache.Len() == 0 { 62 | if err := refreshStorageCache(ctx); err != nil { 63 | return err 64 | } 65 | } 66 | if err := StorageRepo().Update(ctx, storage); err != nil { 67 | return err 68 | } 69 | storageCache.Set(storage.ID, *storage) 70 | return nil 71 | } 72 | 73 | func DeleteStorage(ctx context.Context, id uint16) error { 74 | if storageCache.Len() == 0 { 75 | if err := refreshStorageCache(ctx); err != nil { 76 | return err 77 | } 78 | } 79 | if err := StorageRepo().Delete(ctx, id); err != nil { 80 | return err 81 | } 82 | storageCache.Del(id) 83 | return nil 84 | } 85 | 86 | func refreshStorageCache(ctx context.Context) error { 87 | storageList, err := StorageRepo().List(ctx) 88 | if err != nil { 89 | return err 90 | } 91 | for _, s := range *storageList { 92 | storageCache.Set(s.ID, s) 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/modules/subcer/subcer.go: -------------------------------------------------------------------------------- 1 | package subcer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "runtime" 12 | "strings" 13 | "sync" 14 | 15 | "github.com/bestruirui/bestsub/internal/config" 16 | "github.com/bestruirui/bestsub/internal/utils" 17 | "github.com/bestruirui/bestsub/internal/utils/log" 18 | ) 19 | 20 | var ( 21 | cmd *exec.Cmd 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | mu sync.RWMutex 25 | ) 26 | 27 | func init() { 28 | prefPath := filepath.Join(config.Base().SubConverter.Path, "pref.yml") 29 | if _, err := os.Stat(prefPath); err != nil { 30 | os.MkdirAll(config.Base().SubConverter.Path, 0755) 31 | cfg := fmt.Sprintf(pref, config.Base().SubConverter.Host, config.Base().SubConverter.Port) 32 | if err := os.WriteFile(prefPath, []byte(cfg), 0644); err != nil { 33 | log.Errorf("failed to write subconverter config: %v", err) 34 | os.Exit(1) 35 | } 36 | } 37 | } 38 | 39 | func Start() error { 40 | cfg := config.Base() 41 | 42 | ctx, cancel = context.WithCancel(context.Background()) 43 | binPath := filepath.Join(cfg.SubConverter.Path, "subconverter") 44 | if runtime.GOOS == "windows" { 45 | binPath += ".exe" 46 | } 47 | 48 | cmd = exec.CommandContext(ctx, binPath) 49 | cmd.Dir = filepath.Dir(binPath) 50 | if utils.IsDebug() { 51 | cmd.Stdout = os.Stdout 52 | cmd.Stderr = os.Stderr 53 | } 54 | if err := cmd.Start(); err != nil { 55 | cancel() 56 | log.Warnf("failed to start subconverter process: %v", err) 57 | return err 58 | } 59 | 60 | log.Info("subconverter service started") 61 | return nil 62 | } 63 | 64 | func Stop() error { 65 | if cancel != nil { 66 | cancel() 67 | } 68 | 69 | if cmd != nil && cmd.Process != nil { 70 | cmd.Wait() 71 | } 72 | 73 | log.Debug("subconverter service stopped") 74 | return nil 75 | } 76 | 77 | func Lock() { 78 | mu.Lock() 79 | } 80 | func RLock() { 81 | mu.RLock() 82 | } 83 | 84 | func RUnlock() { 85 | mu.RUnlock() 86 | } 87 | 88 | func Unlock() { 89 | mu.Unlock() 90 | } 91 | 92 | func GetBaseUrl() string { 93 | return fmt.Sprintf("http://127.0.0.1:%d", config.Base().SubConverter.Port) 94 | } 95 | func GetVersion() string { 96 | resp, err := http.Get(fmt.Sprintf("%s/version", GetBaseUrl())) 97 | if err != nil { 98 | return "" 99 | } 100 | defer resp.Body.Close() 101 | body, err := io.ReadAll(resp.Body) 102 | if err != nil { 103 | return "" 104 | } 105 | parts := strings.Split(string(body), " ") 106 | if len(parts) > 1 { 107 | return parts[1] 108 | } 109 | return "" 110 | } 111 | -------------------------------------------------------------------------------- /internal/models/share/share.go: -------------------------------------------------------------------------------- 1 | package share 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 7 | ) 8 | 9 | type Data struct { 10 | ID uint16 `db:"id" json:"id"` 11 | Enable bool `db:"enable" json:"enable"` 12 | Name string `db:"name" json:"name"` 13 | Gen string `db:"gen" json:"gen"` 14 | Token string `db:"token" json:"token"` 15 | AccessCount uint32 `db:"access_count" json:"access_count"` 16 | MaxAccessCount uint32 `db:"max_access_count" json:"max_access_count"` 17 | Expires uint64 `db:"expires" json:"expires"` 18 | } 19 | 20 | type GenConfig struct { 21 | Filter nodeModel.Filter `json:"filter"` 22 | Rename string `json:"rename"` 23 | Proxy bool `json:"proxy"` 24 | SubConverter SubConverterConfig `json:"sub_converter"` 25 | } 26 | 27 | type SubConverterConfig struct { 28 | Target string `url:"target" json:"target"` 29 | Config string `url:"config" json:"config"` 30 | } 31 | 32 | type Request struct { 33 | Enable bool `json:"enable"` 34 | Name string `json:"name"` 35 | Token string `json:"token"` 36 | Gen GenConfig `json:"gen"` 37 | MaxAccessCount uint32 `json:"max_access_count"` 38 | Expires uint64 `json:"expires"` 39 | } 40 | 41 | type Response struct { 42 | ID uint16 `json:"id"` 43 | Name string `json:"name"` 44 | Enable bool `json:"enable"` 45 | AccessCount uint32 `json:"access_count"` 46 | MaxAccessCount uint32 `json:"max_access_count"` 47 | Expires uint64 `json:"expires"` 48 | Token string `json:"token"` 49 | Gen GenConfig `json:"gen"` 50 | } 51 | 52 | type UpdateAccessCountDB struct { 53 | ID uint16 `db:"id"` 54 | AccessCount uint32 `db:"access_count"` 55 | } 56 | 57 | func (r *Request) GenData() Data { 58 | configBytes, err := json.Marshal(r.Gen) 59 | if err != nil { 60 | return Data{} 61 | } 62 | return Data{ 63 | Enable: r.Enable, 64 | Name: r.Name, 65 | Token: r.Token, 66 | MaxAccessCount: r.MaxAccessCount, 67 | Expires: r.Expires, 68 | Gen: string(configBytes), 69 | } 70 | } 71 | 72 | func (r *Data) GenResponse() Response { 73 | var config GenConfig 74 | if err := json.Unmarshal([]byte(r.Gen), &config); err != nil { 75 | return Response{} 76 | } 77 | return Response{ 78 | ID: r.ID, 79 | Name: r.Name, 80 | Enable: r.Enable, 81 | AccessCount: r.AccessCount, 82 | MaxAccessCount: r.MaxAccessCount, 83 | Expires: r.Expires, 84 | Token: r.Token, 85 | Gen: config, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/core/check/checker/country.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "gopkg.in/yaml.v3" 9 | 10 | "github.com/bestruirui/bestsub/internal/core/mihomo" 11 | "github.com/bestruirui/bestsub/internal/core/node" 12 | "github.com/bestruirui/bestsub/internal/core/task" 13 | checkModel "github.com/bestruirui/bestsub/internal/models/check" 14 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 15 | "github.com/bestruirui/bestsub/internal/modules/country" 16 | "github.com/bestruirui/bestsub/internal/modules/register" 17 | "github.com/bestruirui/bestsub/internal/utils/log" 18 | ) 19 | 20 | type Country struct { 21 | Thread int `json:"thread" name:"线程数" value:"100"` 22 | Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` 23 | } 24 | 25 | func (e *Country) Init() error { 26 | return nil 27 | } 28 | 29 | func (e *Country) Run(ctx context.Context, log *log.Logger, subID []uint16) checkModel.Result { 30 | startTime := time.Now() 31 | var nodes []nodeModel.Data 32 | if len(subID) == 0 { 33 | nodes = node.GetAll() 34 | } else { 35 | nodes = *node.GetBySubId(subID) 36 | } 37 | threads := e.Thread 38 | if threads <= 0 || threads > len(nodes) { 39 | threads = len(nodes) 40 | } 41 | if threads > task.MaxThread() { 42 | threads = task.MaxThread() 43 | } 44 | if threads == 0 { 45 | log.Warnf("country check task failed, no nodes") 46 | return checkModel.Result{ 47 | Msg: "no nodes", 48 | LastRun: time.Now(), 49 | Duration: time.Since(startTime).Milliseconds(), 50 | } 51 | } 52 | sem := make(chan struct{}, threads) 53 | defer close(sem) 54 | 55 | var wg sync.WaitGroup 56 | for _, nd := range nodes { 57 | if nd.Info.AliveStatus&nodeModel.Country != 0 { 58 | continue 59 | } 60 | sem <- struct{}{} 61 | wg.Add(1) 62 | n := nd 63 | task.Submit(func() { 64 | defer func() { 65 | <-sem 66 | wg.Done() 67 | }() 68 | var raw map[string]any 69 | if err := yaml.Unmarshal(n.Raw, &raw); err != nil { 70 | log.Warnf("yaml.Unmarshal failed: %v", err) 71 | return 72 | } 73 | client := mihomo.Proxy(raw) 74 | if client == nil { 75 | return 76 | } 77 | client.Timeout = time.Duration(e.Timeout) * time.Second 78 | defer client.Release() 79 | countryCode := country.GetCode(ctx, client.Client) 80 | if countryCode != "" { 81 | n.Info.Country = countryCode 82 | n.Info.SetAliveStatus(nodeModel.Country, true) 83 | } else { 84 | n.Info.SetAliveStatus(nodeModel.Country, false) 85 | } 86 | }) 87 | } 88 | wg.Wait() 89 | return checkModel.Result{ 90 | Msg: "success", 91 | LastRun: time.Now(), 92 | Duration: time.Since(startTime).Milliseconds(), 93 | } 94 | } 95 | 96 | func init() { 97 | register.Check(&Country{}) 98 | } 99 | -------------------------------------------------------------------------------- /internal/server/handlers/update.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bestruirui/bestsub/internal/core/update" 7 | "github.com/bestruirui/bestsub/internal/server/middleware" 8 | "github.com/bestruirui/bestsub/internal/server/resp" 9 | "github.com/bestruirui/bestsub/internal/server/router" 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func init() { 14 | router.NewGroupRouter("/api/v1/update"). 15 | Use(middleware.Auth()). 16 | AddRoute( 17 | router.NewRoute("", router.GET). 18 | Handle(latest), 19 | ). 20 | AddRoute( 21 | router.NewRoute("/:name", router.POST). 22 | Handle(updateFunc), 23 | ) 24 | } 25 | 26 | // latest 最新版本 27 | // @Summary 最新版本 28 | // @Description 获取程序最新版本信息 29 | // @Tags 更新 30 | // @Accept json 31 | // @Produce json 32 | // @Security BearerAuth 33 | // @Success 200 {object} resp.ResponseStruct{data=map[string]update.LatestInfo} "获取成功" 34 | // @Failure 401 {object} resp.ResponseStruct "未授权" 35 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 36 | // @Router /api/v1/update [get] 37 | func latest(c *gin.Context) { 38 | latestInfo := make(map[string]update.LatestInfo, 3) 39 | bestsub, err := update.GetLatestBestsubInfo() 40 | if err != nil { 41 | resp.Error(c, http.StatusInternalServerError, err.Error()) 42 | return 43 | } 44 | ui, err := update.GetLatestUIInfo() 45 | if err != nil { 46 | resp.Error(c, http.StatusInternalServerError, err.Error()) 47 | return 48 | } 49 | subconverter, err := update.GetLatestSubconverterInfo() 50 | if err != nil { 51 | resp.Error(c, http.StatusInternalServerError, err.Error()) 52 | return 53 | } 54 | 55 | latestInfo["bestsub"] = *bestsub 56 | latestInfo["webui"] = *ui 57 | latestInfo["subconverter"] = *subconverter 58 | resp.Success(c, latestInfo) 59 | } 60 | 61 | // update 更新 62 | // @Summary 更新 63 | // @Description 更新程序 64 | // @Tags 更新 65 | // @Accept json 66 | // @Produce json 67 | // @Security BearerAuth 68 | // @Success 200 {object} resp.ResponseStruct{data=string} "获取成功" 69 | // @Failure 401 {object} resp.ResponseStruct "未授权" 70 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 71 | // @Router /api/v1/update/:name [post] 72 | func updateFunc(c *gin.Context) { 73 | name := c.Param("name") 74 | switch name { 75 | case "subconverter": 76 | err := update.UpdateSubconverter() 77 | if err != nil { 78 | resp.Error(c, http.StatusInternalServerError, err.Error()) 79 | return 80 | } 81 | case "webui": 82 | err := update.UpdateUI() 83 | if err != nil { 84 | resp.Error(c, http.StatusInternalServerError, err.Error()) 85 | return 86 | } 87 | case "bestsub": 88 | err := update.UpdateCore() 89 | if err != nil { 90 | resp.Error(c, http.StatusInternalServerError, err.Error()) 91 | return 92 | } 93 | default: 94 | resp.ErrorBadRequest(c) 95 | } 96 | resp.Success(c, nil) 97 | } 98 | -------------------------------------------------------------------------------- /internal/database/init.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/bestruirui/bestsub/internal/database/interfaces" 7 | "github.com/bestruirui/bestsub/internal/database/op" 8 | authModel "github.com/bestruirui/bestsub/internal/models/auth" 9 | "github.com/bestruirui/bestsub/internal/models/notify" 10 | "github.com/bestruirui/bestsub/internal/models/setting" 11 | "github.com/bestruirui/bestsub/internal/utils/log" 12 | "golang.org/x/crypto/bcrypt" 13 | ) 14 | 15 | func initAuth(ctx context.Context, auth interfaces.AuthRepository) error { 16 | isInitialized, err := auth.IsInitialized(ctx) 17 | if err != nil { 18 | log.Fatalf("failed to check if database is initialized: %v", err) 19 | } 20 | if !isInitialized { 21 | authData := authModel.Default() 22 | hashedBytes, err := bcrypt.GenerateFromPassword([]byte(authData.Password), bcrypt.DefaultCost) 23 | if err != nil { 24 | log.Fatalf("failed to hash password: %v", err) 25 | } 26 | authData.Password = string(hashedBytes) 27 | if err := auth.Initialize(ctx, &authData); err != nil { 28 | log.Fatalf("failed to initialize auth: %v", err) 29 | } 30 | log.Info("初始化默认管理员账号 用户名: admin 密码: admin") 31 | } 32 | return nil 33 | } 34 | func initSystemSetting(ctx context.Context, systemSetting interfaces.SettingRepository) error { 35 | defaultSystemSetting := setting.DefaultSetting() 36 | existingSystemSetting, err := op.GetAllSetting(ctx) 37 | notExistSetting := make([]setting.Setting, 0) 38 | if err != nil { 39 | log.Fatalf("failed to get existing system setting: %v", err) 40 | } 41 | 42 | existingSystemSettingMap := make(map[string]bool) 43 | updateSetting := make([]setting.Setting, 0) 44 | for _, item := range existingSystemSetting { 45 | existingSystemSettingMap[item.Key] = true 46 | } 47 | if len(updateSetting) > 0 { 48 | if err := systemSetting.Update(ctx, &updateSetting); err != nil { 49 | log.Fatalf("failed to update system setting: %v", err) 50 | } 51 | } 52 | 53 | for _, s := range defaultSystemSetting { 54 | if !existingSystemSettingMap[s.Key] { 55 | notExistSetting = append(notExistSetting, s) 56 | } 57 | } 58 | 59 | if len(notExistSetting) > 0 { 60 | if err := systemSetting.Create(ctx, ¬ExistSetting); err != nil { 61 | log.Fatalf("failed to create missing system setting: %v", err) 62 | } 63 | } 64 | return nil 65 | } 66 | func initNotifyTemplate(ctx context.Context, notifyTemplateRepo interfaces.NotifyTemplateRepository) error { 67 | defaultNotifyTemplates := notify.DefaultTemplates() 68 | existingNotifyTemplates, err := notifyTemplateRepo.List(ctx) 69 | if err != nil { 70 | log.Fatalf("failed to get existing notify templates: %v", err) 71 | } 72 | existingNotifyTemplatesMap := make(map[string]bool) 73 | for _, template := range *existingNotifyTemplates { 74 | existingNotifyTemplatesMap[template.Type] = true 75 | } 76 | for _, template := range defaultNotifyTemplates { 77 | if !existingNotifyTemplatesMap[template.Type] { 78 | if err := notifyTemplateRepo.Create(ctx, &template); err != nil { 79 | log.Fatalf("failed to create missing notify template %s: %v", template.Type, err) 80 | } 81 | } 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/server/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/bestruirui/bestsub/internal/models/auth" 8 | "github.com/bestruirui/bestsub/internal/utils" 9 | "github.com/golang-jwt/jwt/v5" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | // Claims JWT声明结构 14 | type Claims struct { 15 | SessionID uint8 `json:"session_id"` 16 | Username string `json:"username"` 17 | jwt.RegisteredClaims 18 | } 19 | 20 | // GenerateTokenPair 生成访问令牌和刷新令牌对 21 | func GenerateTokenPair(sessionID uint8, username, secret string) (*auth.LoginResponse, error) { 22 | 23 | now := time.Now() 24 | 25 | accessExpiresAt := now.Add(15 * time.Minute) 26 | if utils.IsDebug() { 27 | accessExpiresAt = now.Add(24 * time.Hour) 28 | } 29 | 30 | claims := &Claims{ 31 | SessionID: sessionID, 32 | Username: username, 33 | RegisteredClaims: jwt.RegisteredClaims{ 34 | ExpiresAt: jwt.NewNumericDate(accessExpiresAt), 35 | IssuedAt: jwt.NewNumericDate(now), 36 | NotBefore: jwt.NewNumericDate(now), 37 | Issuer: "bestsub", 38 | Subject: fmt.Sprintf("session-%d", sessionID), 39 | ID: uuid.New().String(), 40 | }, 41 | } 42 | 43 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 44 | accessToken, err := token.SignedString([]byte(secret)) 45 | if err != nil { 46 | return nil, fmt.Errorf("failed to sign access token: %w", err) 47 | } 48 | 49 | refreshExpiresAt := now.Add(7 * 24 * time.Hour) 50 | 51 | refreshClaims := &Claims{ 52 | SessionID: sessionID, 53 | Username: username, 54 | RegisteredClaims: jwt.RegisteredClaims{ 55 | ExpiresAt: jwt.NewNumericDate(refreshExpiresAt), 56 | IssuedAt: jwt.NewNumericDate(now), 57 | NotBefore: jwt.NewNumericDate(now), 58 | Issuer: "bestsub", 59 | Subject: fmt.Sprintf("session-%d", sessionID), 60 | ID: uuid.New().String(), 61 | }, 62 | } 63 | 64 | token = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) 65 | refreshToken, err := token.SignedString([]byte(secret)) 66 | if err != nil { 67 | return nil, fmt.Errorf("failed to sign refresh token: %w", err) 68 | } 69 | 70 | return &auth.LoginResponse{ 71 | AccessToken: accessToken, 72 | RefreshToken: refreshToken, 73 | AccessExpiresAt: accessExpiresAt, 74 | RefreshExpiresAt: refreshExpiresAt, 75 | }, nil 76 | } 77 | 78 | // ValidateToken 验证JWT令牌 79 | func ValidateToken(tokenString, secret string) (*Claims, error) { 80 | 81 | token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { 82 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 83 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 84 | } 85 | return []byte(secret), nil 86 | }) 87 | 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to parse token: %w", err) 90 | } 91 | 92 | if claims, ok := token.Claims.(*Claims); ok && token.Valid { 93 | if time.Now().After(claims.ExpiresAt.Time) { 94 | return nil, fmt.Errorf("token has expired") 95 | } 96 | return claims, nil 97 | } 98 | 99 | return nil, fmt.Errorf("invalid token") 100 | } 101 | -------------------------------------------------------------------------------- /internal/database/op/setting.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | "github.com/bestruirui/bestsub/internal/models/setting" 10 | "github.com/bestruirui/bestsub/internal/utils/cache" 11 | ) 12 | 13 | var settingRepo interfaces.SettingRepository 14 | var settingCache = cache.New[string, string](4) 15 | 16 | func SettingRepo() interfaces.SettingRepository { 17 | if settingRepo == nil { 18 | settingRepo = repo.Setting() 19 | } 20 | return settingRepo 21 | } 22 | func GetAllSettingMap(ctx context.Context) (map[string]string, error) { 23 | sysConfCache := settingCache.GetAll() 24 | if len(sysConfCache) == 0 { 25 | err := refreshSettingCache(context.Background()) 26 | if err != nil { 27 | return nil, err 28 | } 29 | sysConfCache = settingCache.GetAll() 30 | } 31 | return sysConfCache, nil 32 | } 33 | func GetAllSetting(ctx context.Context) ([]setting.Setting, error) { 34 | sysConfCache := settingCache.GetAll() 35 | if len(sysConfCache) == 0 { 36 | err := refreshSettingCache(context.Background()) 37 | if err != nil { 38 | return nil, err 39 | } 40 | sysConfCache = settingCache.GetAll() 41 | } 42 | var result []setting.Setting 43 | for key, value := range sysConfCache { 44 | result = append(result, setting.Setting{ 45 | Key: key, 46 | Value: value, 47 | }) 48 | } 49 | return result, nil 50 | } 51 | func GetSettingByKey(key string) (string, error) { 52 | if value, ok := settingCache.Get(key); ok { 53 | return value, nil 54 | } 55 | err := refreshSettingCache(context.Background()) 56 | if err != nil { 57 | return "", err 58 | } 59 | if value, ok := settingCache.Get(key); ok { 60 | return value, nil 61 | } 62 | return "", fmt.Errorf("config not found") 63 | } 64 | func UpdateSetting(ctx context.Context, setting *[]setting.Setting) error { 65 | if settingCache.Len() == 0 { 66 | err := refreshSettingCache(context.Background()) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | if err := SettingRepo().Update(ctx, setting); err != nil { 72 | return err 73 | } 74 | for _, item := range *setting { 75 | settingCache.Set(item.Key, item.Value) 76 | } 77 | return nil 78 | 79 | } 80 | func GetSettingStr(key string) string { 81 | value, err := GetSettingByKey(key) 82 | if err != nil { 83 | return "" 84 | } 85 | return value 86 | } 87 | func GetSettingInt(key string) int { 88 | value, err := GetSettingByKey(key) 89 | if err != nil { 90 | return 0 91 | } 92 | i, err := strconv.Atoi(value) 93 | if err != nil { 94 | return 0 95 | } 96 | return i 97 | } 98 | func GetSettingBool(key string) bool { 99 | value, err := GetSettingByKey(key) 100 | if err != nil { 101 | return false 102 | } 103 | return value == "true" 104 | } 105 | 106 | func refreshSettingCache(ctx context.Context) error { 107 | settingCache.Clear() 108 | configs, err := SettingRepo().GetAll(ctx) 109 | if err != nil { 110 | return err 111 | } 112 | for _, config := range *configs { 113 | settingCache.Set(config.Key, config.Value) 114 | } 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/migrator.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/client/sqlite/migration" 8 | migModel "github.com/bestruirui/bestsub/internal/database/migration" 9 | ) 10 | 11 | func (db *DB) Migrate() error { 12 | migrations := migration.Get() 13 | if len(migrations) == 0 { 14 | return nil 15 | } 16 | if err := db.ensureMigrationsTable(); err != nil { 17 | return fmt.Errorf("failed to ensure migrations table: %w", err) 18 | } 19 | 20 | appliedDates, err := db.getAppliedMigrations() 21 | if err != nil { 22 | return fmt.Errorf("failed to get applied migrations: %w", err) 23 | } 24 | 25 | var pendingMigrations []*migModel.Info 26 | for _, migration := range migrations { 27 | if !appliedDates[migration.Date] { 28 | pendingMigrations = append(pendingMigrations, migration) 29 | } 30 | } 31 | 32 | if len(pendingMigrations) == 0 { 33 | return nil 34 | } 35 | 36 | return db.applyMigrations(pendingMigrations) 37 | } 38 | 39 | func (db *DB) ensureMigrationsTable() error { 40 | migrationTable := ` 41 | CREATE TABLE IF NOT EXISTS "migration" ( 42 | "date" INTEGER NOT NULL UNIQUE, 43 | "version" TEXT NOT NULL, 44 | "description" TEXT NOT NULL, 45 | "applied_at" DATETIME NOT NULL, 46 | PRIMARY KEY("date") 47 | );` 48 | 49 | _, err := db.db.Exec(migrationTable) 50 | if err != nil { 51 | return fmt.Errorf("failed to create migration table: %w", err) 52 | } 53 | return nil 54 | } 55 | 56 | func (db *DB) getAppliedMigrations() (map[uint64]bool, error) { 57 | appliedDates := make(map[uint64]bool) 58 | 59 | rows, err := db.db.Query("SELECT date FROM migration") 60 | if err != nil { 61 | return appliedDates, err 62 | } 63 | defer rows.Close() 64 | 65 | for rows.Next() { 66 | var date uint64 67 | if err := rows.Scan(&date); err != nil { 68 | return nil, fmt.Errorf("failed to scan migration date: %w", err) 69 | } 70 | appliedDates[date] = true 71 | } 72 | 73 | if err := rows.Err(); err != nil { 74 | return nil, fmt.Errorf("failed to iterate migration rows: %w", err) 75 | } 76 | 77 | return appliedDates, nil 78 | } 79 | 80 | func (db *DB) applyMigrations(migrations []*migModel.Info) error { 81 | tx, err := db.db.Begin() 82 | if err != nil { 83 | return fmt.Errorf("failed to begin transaction: %w", err) 84 | } 85 | defer tx.Rollback() 86 | 87 | insertStmt, err := tx.Prepare("INSERT INTO migration (date, version, description, applied_at) VALUES (?, ?, ?, ?)") 88 | if err != nil { 89 | return fmt.Errorf("failed to prepare insert statement: %w", err) 90 | } 91 | defer insertStmt.Close() 92 | 93 | for _, migration := range migrations { 94 | _, err = tx.Exec(migration.Content()) 95 | if err != nil { 96 | return fmt.Errorf("failed to execute migration %d SQL: %w", migration.Date, err) 97 | } 98 | 99 | _, err = insertStmt.Exec(migration.Date, migration.Version, migration.Description, time.Now()) 100 | if err != nil { 101 | return fmt.Errorf("failed to record migration %d: %w", migration.Date, err) 102 | } 103 | } 104 | 105 | if err = tx.Commit(); err != nil { 106 | return fmt.Errorf("failed to commit migrations transaction: %w", err) 107 | } 108 | 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /internal/core/update/core.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "syscall" 10 | 11 | "github.com/bestruirui/bestsub/internal/database/op" 12 | "github.com/bestruirui/bestsub/internal/models/setting" 13 | "github.com/bestruirui/bestsub/internal/utils/log" 14 | "github.com/bestruirui/bestsub/internal/utils/shutdown" 15 | ) 16 | 17 | func UpdateCore() error { 18 | log.Infof("start update core") 19 | err := updateCore() 20 | if err != nil { 21 | log.Warnf("update core failed, please update manually", err) 22 | return err 23 | } 24 | log.Infof("update core success") 25 | return nil 26 | } 27 | 28 | func updateCore() error { 29 | arch := runtime.GOARCH 30 | goos := runtime.GOOS 31 | 32 | var downloadUrl string 33 | 34 | var filename string 35 | switch goos { 36 | case "windows": 37 | switch arch { 38 | case "386": 39 | filename = "bestsub-windows-x86.zip" 40 | case "amd64": 41 | filename = "bestsub-windows-x86_64.zip" 42 | default: 43 | log.Errorf("unsupported windows architecture: %s", arch) 44 | return fmt.Errorf("unsupported windows architecture: %s", arch) 45 | } 46 | case "darwin": 47 | switch arch { 48 | case "amd64": 49 | filename = "bestsub-darwin-x86_64.zip" 50 | case "arm64": 51 | filename = "bestsub-darwin-arm64.zip" 52 | default: 53 | log.Errorf("unsupported darwin architecture: %s", arch) 54 | return fmt.Errorf("unsupported darwin architecture: %s", arch) 55 | } 56 | case "linux": 57 | switch arch { 58 | case "386": 59 | filename = "bestsub-linux-x86.zip" 60 | case "amd64": 61 | filename = "bestsub-linux-x86_64.zip" 62 | case "arm": 63 | filename = "bestsub-linux-armv7.zip" 64 | case "arm64": 65 | filename = "bestsub-linux-arm64.zip" 66 | default: 67 | log.Errorf("unsupported linux architecture: %s", arch) 68 | return fmt.Errorf("unsupported linux architecture: %s", arch) 69 | } 70 | default: 71 | log.Errorf("unsupported operating system: %s", goos) 72 | return fmt.Errorf("unsupported operating system: %s", goos) 73 | } 74 | 75 | downloadUrl = bestsubUpdateUrl + "/" + filename 76 | 77 | bytes, err := download(downloadUrl, op.GetSettingBool(setting.PROXY_ENABLE)) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | execPath, err := os.Executable() 83 | if err != nil { 84 | return err 85 | } 86 | execDir := filepath.Dir(execPath) 87 | if err := unzip(bytes, execDir); err != nil { 88 | return err 89 | } 90 | go restartExecutable(execPath) 91 | return nil 92 | } 93 | 94 | func restartExecutable(execPath string) { 95 | var err error 96 | shutdown.All() 97 | if runtime.GOOS == "windows" { 98 | cmd := exec.Command(execPath, os.Args[1:]...) 99 | log.Infof("restarting: %q %q", execPath, os.Args[1:]) 100 | cmd.Stdin = os.Stdin 101 | cmd.Stdout = os.Stdout 102 | cmd.Stderr = os.Stderr 103 | err = cmd.Start() 104 | if err != nil { 105 | log.Errorf("restarting: %s", err) 106 | } 107 | 108 | os.Exit(0) 109 | } 110 | 111 | log.Infof("restarting: %q %q", execPath, os.Args[1:]) 112 | err = syscall.Exec(execPath, os.Args, os.Environ()) 113 | if err != nil { 114 | log.Errorf("restarting: %s", err) 115 | } 116 | log.Infof("restarting success") 117 | } 118 | -------------------------------------------------------------------------------- /internal/modules/notify/channel/email.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "net/smtp" 8 | "strings" 9 | 10 | "github.com/bestruirui/bestsub/internal/modules/register" 11 | ) 12 | 13 | type Email struct { 14 | Server string `json:"server" require:"true" name:"SMTP服务器"` 15 | Port int `json:"port" require:"true" name:"端口"` 16 | Username string `json:"username" require:"true" name:"用户名"` 17 | Password string `json:"password" require:"true" name:"密码"` 18 | From string `json:"from" require:"true" name:"发件人"` 19 | To string `json:"to" require:"true" name:"接收人"` 20 | TLS bool `json:"tls" require:"true" name:"TLS"` 21 | 22 | addr string 23 | auth smtp.Auth 24 | recipients []string 25 | } 26 | 27 | func (e *Email) Init() error { 28 | e.addr = fmt.Sprintf("%s:%d", e.Server, e.Port) 29 | e.auth = smtp.PlainAuth("", e.Username, e.Password, e.Server) 30 | 31 | recipients := strings.Split(e.To, ",") 32 | e.recipients = make([]string, len(recipients)) 33 | for i, recipient := range recipients { 34 | e.recipients[i] = strings.TrimSpace(recipient) 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func (e *Email) Send(title string, body *bytes.Buffer) error { 41 | if body == nil { 42 | return fmt.Errorf("email body is nil") 43 | } 44 | 45 | message := e.buildMessage(title, body) 46 | 47 | if err := e.sendMail(message); err != nil { 48 | return fmt.Errorf("send email failed: %w", err) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func (e *Email) buildMessage(subject string, body *bytes.Buffer) *bytes.Buffer { 55 | var message bytes.Buffer 56 | 57 | message.WriteString(fmt.Sprintf("From: %s\r\n", e.From)) 58 | message.WriteString(fmt.Sprintf("To: %s\r\n", e.To)) 59 | message.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) 60 | message.WriteString("MIME-Version: 1.0\r\n") 61 | message.WriteString("Content-Type: text/html; charset=UTF-8\r\n") 62 | message.WriteString("\r\n") 63 | 64 | body.WriteTo(&message) 65 | 66 | return &message 67 | } 68 | func (e *Email) sendMail(message *bytes.Buffer) error { 69 | if e.TLS { 70 | return e.sendMailWithTLS(message) 71 | } else { 72 | return smtp.SendMail(e.addr, e.auth, e.From, e.recipients, message.Bytes()) 73 | } 74 | } 75 | 76 | func (e *Email) sendMailWithTLS(message *bytes.Buffer) error { 77 | tlsConfig := &tls.Config{ 78 | ServerName: e.Server, 79 | } 80 | conn, err := tls.Dial("tcp", e.addr, tlsConfig) 81 | if err != nil { 82 | return err 83 | } 84 | defer conn.Close() 85 | 86 | client, err := smtp.NewClient(conn, e.Server) 87 | if err != nil { 88 | return err 89 | } 90 | defer client.Quit() 91 | 92 | if err := client.Auth(e.auth); err != nil { 93 | return err 94 | } 95 | 96 | if err := client.Mail(e.From); err != nil { 97 | return err 98 | } 99 | 100 | for _, recipient := range e.recipients { 101 | if err := client.Rcpt(recipient); err != nil { 102 | return err 103 | } 104 | } 105 | 106 | writer, err := client.Data() 107 | if err != nil { 108 | return err 109 | } 110 | defer writer.Close() 111 | 112 | if _, err := writer.Write(message.Bytes()); err != nil { 113 | return err 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func init() { 120 | register.Notify(&Email{}) 121 | } 122 | -------------------------------------------------------------------------------- /internal/server/handlers/log.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/bestruirui/bestsub/internal/server/middleware" 9 | "github.com/bestruirui/bestsub/internal/server/resp" 10 | "github.com/bestruirui/bestsub/internal/server/router" 11 | "github.com/bestruirui/bestsub/internal/utils/log" 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | func init() { 16 | router.NewGroupRouter("/api/v1/log"). 17 | Use(middleware.Auth()). 18 | AddRoute( 19 | router.NewRoute("/list", router.GET). 20 | Handle(getLogFileList), 21 | ). 22 | AddRoute( 23 | router.NewRoute("/content", router.GET). 24 | Handle(getLogContent), 25 | ) 26 | } 27 | 28 | // @Summary 获取日志列表 29 | // @Description 获取日志列表 30 | // @Tags 日志 31 | // @Accept json 32 | // @Produce json 33 | // @Security BearerAuth 34 | // @Param path query string true "日志文件路径" 35 | // @Success 200 {object} resp.ResponseStruct{data=[]uint64} "获取成功" 36 | // @Failure 401 {object} resp.ResponseStruct "未授权" 37 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 38 | // @Router /api/v1/log/list [get] 39 | func getLogFileList(c *gin.Context) { 40 | path := c.Query("path") 41 | if path == "" { 42 | resp.Error(c, http.StatusBadRequest, "path parameter is required") 43 | return 44 | } 45 | logFileList, err := log.GetLogFileList(path) 46 | if err != nil { 47 | resp.Error(c, http.StatusInternalServerError, err.Error()) 48 | return 49 | } 50 | resp.Success(c, logFileList) 51 | } 52 | 53 | // @Summary 获取日志内容 54 | // @Description 获取日志内容 55 | // @Tags 日志 56 | // @Accept json 57 | // @Produce json 58 | // @Security BearerAuth 59 | // @Param path query string true "日志文件路径" 60 | // @Param timestamp query uint64 true "日志文件时间戳" 61 | // @Success 200 {object} resp.ResponseStruct{data=[]object{level=string,time=string,msg=string}} "获取成功" 62 | // @Failure 400 {object} resp.ResponseStruct "参数错误" 63 | // @Failure 401 {object} resp.ResponseStruct "未授权" 64 | // @Failure 404 {object} resp.ResponseStruct "文件不存在" 65 | // @Failure 500 {object} resp.ResponseStruct "服务器内部错误" 66 | // @Router /api/v1/log/content [get] 67 | func getLogContent(c *gin.Context) { 68 | path := c.Query("path") 69 | if path == "" { 70 | resp.Error(c, http.StatusBadRequest, "path parameter is required") 71 | return 72 | } 73 | timestampStr := c.Query("timestamp") 74 | 75 | if timestampStr == "" { 76 | resp.Error(c, http.StatusBadRequest, "timestamp parameter is required") 77 | return 78 | } 79 | 80 | timestamp, err := strconv.ParseUint(timestampStr, 10, 64) 81 | if err != nil { 82 | resp.Error(c, http.StatusBadRequest, "invalid timestamp format") 83 | return 84 | } 85 | 86 | c.Header("Content-Type", "application/json; charset=utf-8") 87 | c.Header("Transfer-Encoding", "chunked") 88 | c.Header("Cache-Control", "no-cache") 89 | c.Header("Connection", "keep-alive") 90 | c.Header("X-Accel-Buffering", "no") 91 | 92 | c.Status(http.StatusOK) 93 | w := c.Writer 94 | 95 | w.WriteString(`{"code":200,"message":"success","data":[`) 96 | w.Flush() 97 | 98 | err = log.StreamLogToHTTP(path, timestamp, w) 99 | if err != nil { 100 | w.WriteString(`],"error":"`) 101 | w.WriteString(strings.ReplaceAll(err.Error(), `"`, `\"`)) 102 | w.WriteString(`"}`) 103 | w.Flush() 104 | return 105 | } 106 | 107 | w.WriteString(`]}`) 108 | w.Flush() 109 | } 110 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/auth.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | "github.com/bestruirui/bestsub/internal/models/auth" 10 | "github.com/bestruirui/bestsub/internal/utils/log" 11 | ) 12 | 13 | // Get 获取认证信息 14 | func (db *DB) Auth() interfaces.AuthRepository { 15 | return &AuthRepository{db: db} 16 | } 17 | 18 | // AuthRepository 认证数据访问实现 19 | type AuthRepository struct { 20 | db *DB 21 | } 22 | 23 | // Get 获取认证信息 24 | func (db *AuthRepository) Get(ctx context.Context) (*auth.Data, error) { 25 | log.Debugf("Get auth") 26 | query := `SELECT id, username, password FROM auth LIMIT 1` 27 | 28 | var authData auth.Data 29 | err := db.db.db.QueryRowContext(ctx, query).Scan( 30 | &authData.ID, 31 | &authData.UserName, 32 | &authData.Password, 33 | ) 34 | 35 | if err != nil { 36 | if err == sql.ErrNoRows { 37 | return nil, nil 38 | } 39 | return nil, fmt.Errorf("failed to get auth: %w", err) 40 | } 41 | 42 | return &authData, nil 43 | } 44 | 45 | // UpdateName 更新用户名 46 | func (r *AuthRepository) UpdateName(ctx context.Context, name string) error { 47 | log.Debugf("UpdateName: %s", name) 48 | query := `UPDATE auth SET username = ? WHERE id = (SELECT id FROM auth LIMIT 1)` 49 | 50 | result, err := r.db.db.ExecContext(ctx, query, name) 51 | if err != nil { 52 | return fmt.Errorf("failed to update username: %w", err) 53 | } 54 | 55 | rowsAffected, err := result.RowsAffected() 56 | if err != nil { 57 | return fmt.Errorf("failed to get rows affected: %w", err) 58 | } 59 | 60 | if rowsAffected == 0 { 61 | return fmt.Errorf("no auth record found to update") 62 | } 63 | 64 | return nil 65 | } 66 | 67 | // UpdatePassword 更新密码 68 | func (r *AuthRepository) UpdatePassword(ctx context.Context, hashPassword string) error { 69 | log.Debugf("UpdatePassword: %s", hashPassword) 70 | query := `UPDATE auth SET password = ? WHERE id = (SELECT id FROM auth LIMIT 1)` 71 | 72 | result, err := r.db.db.ExecContext(ctx, query, hashPassword) 73 | if err != nil { 74 | return fmt.Errorf("failed to update password: %w", err) 75 | } 76 | 77 | rowsAffected, err := result.RowsAffected() 78 | if err != nil { 79 | return fmt.Errorf("failed to get rows affected: %w", err) 80 | } 81 | 82 | if rowsAffected == 0 { 83 | return fmt.Errorf("no auth record found to update") 84 | } 85 | 86 | return nil 87 | } 88 | 89 | // Initialize 初始化认证信息 90 | func (r *AuthRepository) Initialize(ctx context.Context, authData *auth.Data) error { 91 | log.Debugf("Initialize: %s", authData.UserName) 92 | query := `INSERT INTO auth (username, password) VALUES (?, ?)` 93 | _, err := r.db.db.ExecContext(ctx, query, authData.UserName, authData.Password) 94 | if err != nil { 95 | return fmt.Errorf("failed to initialize auth: %w", err) 96 | } 97 | 98 | return nil 99 | } 100 | 101 | // IsInitialized 验证是否已初始化 102 | func (r *AuthRepository) IsInitialized(ctx context.Context) (bool, error) { 103 | log.Debugf("IsInitialized") 104 | query := `SELECT EXISTS(SELECT 1 FROM auth LIMIT 1)` 105 | 106 | var exists bool 107 | err := r.db.db.QueryRowContext(ctx, query).Scan(&exists) 108 | if err != nil { 109 | return false, fmt.Errorf("failed to check auth initialization: %w", err) 110 | } 111 | 112 | return exists, nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/models/check/check.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/bestruirui/bestsub/internal/utils/log" 9 | ) 10 | 11 | type Instance interface { 12 | Init() error 13 | Run(ctx context.Context, log *log.Logger, subID []uint16) Result 14 | } 15 | 16 | type Data struct { 17 | ID uint16 `db:"id" json:"id"` 18 | Name string `db:"name" json:"name" description:"检测任务名称"` 19 | Enable bool `db:"enable" json:"enable" description:"是否启用"` 20 | Task string `db:"task" json:"task" description:"任务配置"` 21 | Config string `db:"config" json:"config" description:"检测器配置"` 22 | Result string `db:"result" json:"result" description:"检测结果"` 23 | } 24 | 25 | type Task struct { 26 | SubIdExclude bool `json:"sub_id_exclude" example:"false" description:"是否排除订阅ID"` 27 | SubID []uint16 `json:"sub_id" example:"1" description:"订阅ID"` 28 | CronExpr string `json:"cron_expr" example:"0 0 * * *" description:"cron表达式"` 29 | Notify bool `json:"notify" example:"true" description:"是否通知"` 30 | NotifyChannel int `json:"notify_channel" example:"1" description:"通知渠道"` 31 | LogWriteFile bool `json:"log_write_file" example:"true" description:"是否写入日志文件"` 32 | LogLevel string `json:"log_level" example:"info" description:"日志级别"` 33 | Timeout int `json:"timeout" example:"60" description:"超时时间 分钟"` 34 | Type string `json:"type" example:"test" description:"任务类型"` 35 | } 36 | 37 | type Result struct { 38 | Msg string `json:"msg" description:"消息"` 39 | Extra any `json:"extra" description:"额外信息"` 40 | LastRun time.Time `json:"last_run" description:"上次运行时间"` 41 | Duration int64 `json:"duration" description:"运行时长(单位:毫秒)"` 42 | } 43 | 44 | type Request struct { 45 | Name string `db:"name" json:"name" example:"测试检测任务" description:"检测任务名称"` 46 | Enable bool `db:"enable" json:"enable" description:"是否启用"` 47 | Task Task `db:"task" json:"task" description:"任务配置"` 48 | Config any `db:"config" json:"config" description:"检测器配置"` 49 | } 50 | 51 | type Response struct { 52 | ID uint16 `db:"id" json:"id" description:"检测任务ID"` 53 | Name string `db:"name" json:"name" description:"检测任务名称"` 54 | Enable bool `db:"enable" json:"enable" description:"是否启用"` 55 | Task Task `db:"task" json:"task" description:"任务配置"` 56 | Config any `db:"config" json:"config" description:"检测器配置"` 57 | Status string `db:"-" json:"status" description:"检测状态"` 58 | Result Result `db:"result" json:"result" description:"检测结果"` 59 | } 60 | 61 | func (r *Data) GenResponse(status string) Response { 62 | var resp Response 63 | resp.ID = r.ID 64 | resp.Name = r.Name 65 | resp.Enable = r.Enable 66 | resp.Status = status 67 | if err := json.Unmarshal([]byte(r.Task), &resp.Task); err != nil { 68 | return resp 69 | } 70 | if err := json.Unmarshal([]byte(r.Config), &resp.Config); err != nil { 71 | return resp 72 | } 73 | if err := json.Unmarshal([]byte(r.Result), &resp.Result); err != nil { 74 | return resp 75 | } 76 | return resp 77 | } 78 | 79 | func (r *Request) GenData() Data { 80 | var data Data 81 | taskBytes, err := json.Marshal(r.Task) 82 | if err != nil { 83 | log.Errorf("failed to marshal task: %v", err) 84 | return data 85 | } 86 | taskStr := string(taskBytes) 87 | configBytes, err := json.Marshal(r.Config) 88 | if err != nil { 89 | log.Errorf("failed to marshal config: %v", err) 90 | return data 91 | } 92 | configStr := string(configBytes) 93 | data.Task = taskStr 94 | data.Config = configStr 95 | data.Name = r.Name 96 | data.Enable = r.Enable 97 | return data 98 | } 99 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/storage.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | "github.com/bestruirui/bestsub/internal/models/storage" 10 | "github.com/bestruirui/bestsub/internal/utils/log" 11 | ) 12 | 13 | // StorageRepository 存储配置数据访问实现 14 | type StorageRepository struct { 15 | db *DB 16 | } 17 | 18 | // newStorageRepository 创建存储配置仓库 19 | func (db *DB) Storage() interfaces.StorageRepository { 20 | return &StorageRepository{db: db} 21 | } 22 | 23 | // Create 创建存储配置 24 | func (r *StorageRepository) Create(ctx context.Context, config *storage.Data) error { 25 | log.Debugf("Create storage config") 26 | query := `INSERT INTO storage (name, type, config) 27 | VALUES (?, ?, ?)` 28 | 29 | result, err := r.db.db.ExecContext(ctx, query, 30 | config.Name, 31 | config.Type, 32 | config.Config, 33 | ) 34 | 35 | if err != nil { 36 | return fmt.Errorf("failed to create storage config: %w", err) 37 | } 38 | 39 | id, err := result.LastInsertId() 40 | if err != nil { 41 | return fmt.Errorf("failed to get storage config id: %w", err) 42 | } 43 | 44 | config.ID = uint16(id) 45 | 46 | return nil 47 | } 48 | 49 | // GetByID 根据ID获取存储配置 50 | func (r *StorageRepository) GetByID(ctx context.Context, id uint16) (*storage.Data, error) { 51 | log.Debugf("Get storage config by id") 52 | query := `SELECT id, name, type, config 53 | FROM storage WHERE id = ?` 54 | 55 | var config storage.Data 56 | err := r.db.db.QueryRowContext(ctx, query, id).Scan( 57 | &config.ID, 58 | &config.Name, 59 | &config.Type, 60 | &config.Config, 61 | ) 62 | 63 | if err != nil { 64 | if err == sql.ErrNoRows { 65 | return nil, nil 66 | } 67 | return nil, fmt.Errorf("failed to get storage config by id: %w", err) 68 | } 69 | 70 | return &config, nil 71 | } 72 | 73 | // Update 更新存储配置 74 | func (r *StorageRepository) Update(ctx context.Context, config *storage.Data) error { 75 | log.Debugf("Update storage config") 76 | query := `UPDATE storage SET name = ?, type = ?, config = ? WHERE id = ?` 77 | 78 | _, err := r.db.db.ExecContext(ctx, query, 79 | config.Name, 80 | config.Type, 81 | config.Config, 82 | config.ID, 83 | ) 84 | 85 | if err != nil { 86 | return fmt.Errorf("failed to update storage config: %w", err) 87 | } 88 | 89 | return nil 90 | } 91 | 92 | // Delete 删除存储配置 93 | func (r *StorageRepository) Delete(ctx context.Context, id uint16) error { 94 | log.Debugf("Delete storage config") 95 | query := `DELETE FROM storage WHERE id = ?` 96 | 97 | _, err := r.db.db.ExecContext(ctx, query, id) 98 | if err != nil { 99 | return fmt.Errorf("failed to delete storage config: %w", err) 100 | } 101 | 102 | return nil 103 | } 104 | 105 | // List 获取存储配置列表 106 | func (r *StorageRepository) List(ctx context.Context) (*[]storage.Data, error) { 107 | log.Debugf("List storage configs") 108 | query := `SELECT id, name, type, config 109 | FROM storage` 110 | 111 | var configs []storage.Data 112 | rows, err := r.db.db.QueryContext(ctx, query) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to list storage configs: %w", err) 115 | } 116 | defer rows.Close() 117 | 118 | for rows.Next() { 119 | var config storage.Data 120 | err := rows.Scan( 121 | &config.ID, 122 | &config.Name, 123 | &config.Type, 124 | &config.Config, 125 | ) 126 | if err != nil { 127 | return nil, fmt.Errorf("failed to scan storage config: %w", err) 128 | } 129 | configs = append(configs, config) 130 | } 131 | 132 | return &configs, nil 133 | } 134 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | release: 14 | name: release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v5 19 | with: 20 | fetch-depth: 0 21 | ref: master 22 | 23 | - name: Cache toolchains 24 | uses: actions/cache@v4 25 | with: 26 | path: ~/.bestsub/toolchains 27 | key: ${{ runner.os }}-toolchains-${{ hashFiles('go.mod') }}-${{ hashFiles('scripts/build.sh') }} 28 | 29 | - name: Setup Go 30 | uses: actions/setup-go@v5 31 | 32 | - name: Build 33 | run: bash scripts/build.sh release 34 | 35 | - name: Get latest tag 36 | id: tag 37 | run: | 38 | LATEST_TAG=$(git describe --tags --abbrev=0) 39 | echo "TAG_NAME=$LATEST_TAG" >> $GITHUB_OUTPUT 40 | 41 | - name: Upload Release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | files: build/archives/* 45 | prerelease: false 46 | tag_name: ${{ steps.tag.outputs.TAG_NAME }} 47 | 48 | - name: Docker meta (Debian) 49 | id: meta-debian 50 | uses: docker/metadata-action@v5 51 | with: 52 | images: | 53 | ghcr.io/${{ github.repository }} 54 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} 55 | tags: | 56 | type=raw,value=latest 57 | type=raw,value=${{ steps.tag.outputs.TAG_NAME }} 58 | 59 | - name: Docker meta (Alpine) 60 | id: meta-alpine 61 | uses: docker/metadata-action@v5 62 | with: 63 | images: | 64 | ghcr.io/${{ github.repository }} 65 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} 66 | tags: | 67 | type=raw,value=latest-alpine 68 | type=raw,value=${{ steps.tag.outputs.TAG_NAME }}-alpine 69 | 70 | - name: Set up QEMU 71 | uses: docker/setup-qemu-action@v3 72 | 73 | - name: Set up Docker Buildx 74 | uses: docker/setup-buildx-action@v3 75 | 76 | - name: Login to GitHub Container Registry 77 | uses: docker/login-action@v3 78 | with: 79 | registry: ghcr.io 80 | username: ${{ github.actor }} 81 | password: ${{ secrets.GITHUB_TOKEN }} 82 | 83 | - name: Login to Docker Hub 84 | uses: docker/login-action@v3 85 | with: 86 | username: ${{ secrets.DOCKERHUB_USERNAME }} 87 | password: ${{ secrets.DOCKERHUB_TOKEN }} 88 | 89 | - name: Build and push (Alpine) 90 | uses: docker/build-push-action@v5 91 | with: 92 | context: . 93 | file: ./scripts/dockerfiles/Dockerfile.alpine 94 | push: true 95 | platforms: linux/amd64,linux/i386,linux/arm64,linux/arm/v7 96 | tags: ${{ steps.meta-alpine.outputs.tags }} 97 | labels: ${{ steps.meta-alpine.outputs.labels }} 98 | build-args: | 99 | TARGETPLATFORM 100 | 101 | - name: Build and push (Debian) 102 | uses: docker/build-push-action@v5 103 | with: 104 | context: . 105 | file: ./scripts/dockerfiles/Dockerfile.debian 106 | push: true 107 | platforms: linux/amd64,linux/i386,linux/arm64,linux/arm/v7 108 | tags: ${{ steps.meta-debian.outputs.tags }} 109 | labels: ${{ steps.meta-debian.outputs.labels }} 110 | build-args: | 111 | TARGETPLATFORM -------------------------------------------------------------------------------- /internal/database/client/sqlite/check.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | "github.com/bestruirui/bestsub/internal/models/check" 10 | "github.com/bestruirui/bestsub/internal/utils/log" 11 | ) 12 | 13 | type CheckRepository struct { 14 | db *DB 15 | } 16 | 17 | func (db *DB) Check() interfaces.CheckRepository { 18 | return &CheckRepository{db: db} 19 | } 20 | 21 | func (r *CheckRepository) Create(ctx context.Context, t *check.Data) error { 22 | log.Debugf("Create check") 23 | query := `INSERT INTO check_task (enable, name, task, config, result) 24 | VALUES (?, ?, ?, ?, ?)` 25 | 26 | result, err := r.db.db.ExecContext(ctx, query, 27 | t.Enable, 28 | t.Name, 29 | t.Task, 30 | t.Config, 31 | t.Result, 32 | ) 33 | 34 | if err != nil { 35 | return fmt.Errorf("failed to create check: %w", err) 36 | } 37 | 38 | id, err := result.LastInsertId() 39 | if err != nil { 40 | return fmt.Errorf("failed to get check id: %w", err) 41 | } 42 | t.ID = uint16(id) 43 | return nil 44 | } 45 | 46 | func (r *CheckRepository) GetByID(ctx context.Context, id uint16) (*check.Data, error) { 47 | log.Debugf("Get check by id") 48 | query := `SELECT id, enable, name, task, config, result 49 | FROM check_task WHERE id = ?` 50 | 51 | var t check.Data 52 | err := r.db.db.QueryRowContext(ctx, query, id).Scan( 53 | &t.ID, 54 | &t.Enable, 55 | &t.Name, 56 | &t.Task, 57 | &t.Config, 58 | &t.Result, 59 | ) 60 | 61 | if err != nil { 62 | if err == sql.ErrNoRows { 63 | return nil, nil 64 | } 65 | return nil, fmt.Errorf("failed to get check by id: %w", err) 66 | } 67 | 68 | return &t, nil 69 | } 70 | 71 | func (r *CheckRepository) Update(ctx context.Context, t *check.Data) error { 72 | log.Debugf("Update check") 73 | query := `UPDATE check_task SET enable = ?, name = ?, task = ?, config = ?, result = ? WHERE id = ?` 74 | 75 | _, err := r.db.db.ExecContext(ctx, query, 76 | t.Enable, 77 | t.Name, 78 | t.Task, 79 | t.Config, 80 | t.Result, 81 | t.ID, 82 | ) 83 | 84 | if err != nil { 85 | return fmt.Errorf("failed to update check: %w", err) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | func (r *CheckRepository) Delete(ctx context.Context, id uint16) error { 92 | log.Debugf("Delete check") 93 | query := `DELETE FROM check_task WHERE id = ?` 94 | 95 | _, err := r.db.db.ExecContext(ctx, query, id) 96 | if err != nil { 97 | return fmt.Errorf("failed to delete check: %w", err) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func (r *CheckRepository) List(ctx context.Context) (*[]check.Data, error) { 104 | log.Debugf("List check") 105 | query := `SELECT id, enable, name, task, config, result 106 | FROM check_task ORDER BY id DESC` 107 | 108 | rows, err := r.db.db.QueryContext(ctx, query) 109 | if err != nil { 110 | log.Errorf("failed to list checks: %v", err) 111 | return nil, fmt.Errorf("failed to list checks: %w", err) 112 | } 113 | defer rows.Close() 114 | 115 | var checks []check.Data 116 | for rows.Next() { 117 | var t check.Data 118 | err := rows.Scan( 119 | &t.ID, 120 | &t.Enable, 121 | &t.Name, 122 | &t.Task, 123 | &t.Config, 124 | &t.Result, 125 | ) 126 | if err != nil { 127 | log.Errorf("failed to scan check: %v", err) 128 | return nil, fmt.Errorf("failed to scan check: %w", err) 129 | } 130 | checks = append(checks, t) 131 | } 132 | 133 | if err = rows.Err(); err != nil { 134 | log.Errorf("failed to iterate checks: %v", err) 135 | return nil, fmt.Errorf("failed to iterate checks: %w", err) 136 | } 137 | 138 | return &checks, nil 139 | } 140 | -------------------------------------------------------------------------------- /internal/models/sub/sub.go: -------------------------------------------------------------------------------- 1 | package sub 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 8 | ) 9 | 10 | type Data struct { 11 | ID uint16 `db:"id" json:"id"` 12 | Enable bool `db:"enable" json:"enable"` 13 | Name string `db:"name" json:"name"` 14 | Tags string `db:"tags" json:"tags"` 15 | CronExpr string `db:"cron_expr" json:"cron_expr"` 16 | Config string `db:"config" json:"config"` 17 | Result string `db:"result" json:"result"` 18 | CreatedAt time.Time `db:"created_at" json:"created_at"` 19 | UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 20 | } 21 | 22 | type Config struct { 23 | Url string `json:"url"` 24 | Proxy bool `json:"proxy"` 25 | Timeout int `json:"timeout"` 26 | ProtocolFilterEnable bool `json:"protocol_filter_enable"` 27 | ProtocolFilterMode bool `json:"protocol_filter_mode"` 28 | ProtocolFilter []string `json:"protocol_filter"` 29 | } 30 | 31 | type Result struct { 32 | Success uint16 `json:"success,omitempty" description:"成功次数"` 33 | Fail uint16 `json:"fail,omitempty" description:"失败次数"` 34 | NodeNullCount uint16 `json:"node_null_count,omitempty" description:"节点为空次数"` 35 | Msg string `json:"msg,omitempty" description:"消息"` 36 | RawCount uint32 `json:"raw_count,omitempty" description:"节点数量"` 37 | LastRun time.Time `json:"last_run,omitempty" description:"上次运行时间"` 38 | Duration uint16 `json:"duration,omitempty" description:"运行时长(单位:毫秒)"` 39 | } 40 | 41 | type Request struct { 42 | Name string `json:"name" description:"订阅任务名称"` 43 | Tags []string `json:"tags" description:"订阅标签"` 44 | Enable bool `json:"enable" description:"是否启用"` 45 | CronExpr string `json:"cron_expr" example:"0 0 * * *" description:"cron表达式"` 46 | Config Config `json:"config"` 47 | } 48 | 49 | type Response struct { 50 | ID uint16 `json:"id" description:"订阅任务ID"` 51 | Name string `json:"name" description:"订阅任务名称"` 52 | Tags []string `json:"tags" description:"订阅标签"` 53 | Enable bool `json:"enable" description:"是否启用"` 54 | CronExpr string `json:"cron_expr" description:"cron表达式"` 55 | Config Config `json:"config" description:"订阅器配置"` 56 | Status string `json:"status" description:"订阅状态"` 57 | Result Result `json:"result" description:"订阅结果"` 58 | Info nodeModel.SimpleInfo `json:"info" description:"订阅信息"` 59 | CreatedAt time.Time `json:"created_at" description:"创建时间"` 60 | UpdatedAt time.Time `json:"updated_at" description:"更新时间"` 61 | } 62 | 63 | func (c *Request) GenData(id uint16) Data { 64 | configBytes, err := json.Marshal(c.Config) 65 | if err != nil { 66 | return Data{} 67 | } 68 | tags, _ := json.Marshal(c.Tags) 69 | return Data{ 70 | ID: id, 71 | Name: c.Name, 72 | Tags: string(tags), 73 | Enable: c.Enable, 74 | CronExpr: c.CronExpr, 75 | Config: string(configBytes), 76 | } 77 | } 78 | func (d *Data) GenResponse(status string, subInfo nodeModel.SimpleInfo) Response { 79 | var config Config 80 | json.Unmarshal([]byte(d.Config), &config) 81 | var result Result 82 | json.Unmarshal([]byte(d.Result), &result) 83 | tags := make([]string, 0) 84 | json.Unmarshal([]byte(d.Tags), &tags) 85 | return Response{ 86 | ID: d.ID, 87 | Name: d.Name, 88 | Tags: tags, 89 | Enable: d.Enable, 90 | CronExpr: d.CronExpr, 91 | Config: config, 92 | Status: status, 93 | Result: result, 94 | Info: subInfo, 95 | CreatedAt: d.CreatedAt, 96 | UpdatedAt: d.UpdatedAt, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/utils/ua/ua.go: -------------------------------------------------------------------------------- 1 | package ua 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | ) 7 | 8 | func SetHeader(req *http.Request) { 9 | req.Header.Set("User-Agent", Random()) 10 | } 11 | 12 | func Random() string { 13 | return "Mozilla/5.0 (" + platforms[rand.Intn(len(platforms))] + ") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chromeVersions[rand.Intn(len(chromeVersions))] + " Safari/537.36" 14 | } 15 | 16 | var ( 17 | chromeVersions = []string{ 18 | "139.0.7258.128", 19 | "139.0.7258.67", 20 | "138.0.7204.185", 21 | "138.0.7204.170", 22 | "138.0.7204.159", 23 | "138.0.7204.102", 24 | "138.0.7204.100", 25 | "138.0.7204.51", 26 | "138.0.7204.49", 27 | "137.0.7151.122", 28 | "138.0.7204.35", 29 | "137.0.7151.121", 30 | "137.0.7151.105", 31 | "137.0.7151.104", 32 | "137.0.7151.57", 33 | "137.0.7151.55", 34 | "136.0.7103.116", 35 | "137.0.7151.40", 36 | "136.0.7103.113", 37 | "136.0.7103.92", 38 | "135.0.7049.117", 39 | "136.0.7103.48", 40 | "135.0.7049.114", 41 | "135.0.7049.86", 42 | "135.0.7049.42", 43 | "135.0.7049.41", 44 | "134.0.6998.167", 45 | "134.0.6998.119", 46 | "134.0.6998.117", 47 | "134.0.6998.37", 48 | "134.0.6998.35", 49 | "133.0.6943.128", 50 | "133.0.6943.100", 51 | "133.0.6943.59", 52 | "133.0.6943.53", 53 | "132.0.6834.162", 54 | "133.0.6943.35", 55 | "132.0.6834.160", 56 | "132.0.6834.112", 57 | "132.0.6834.110", 58 | "131.0.6778.267", 59 | "132.0.6834.83", 60 | "131.0.6778.264", 61 | "131.0.6778.204", 62 | "131.0.6778.139", 63 | "131.0.6778.109", 64 | "131.0.6778.71", 65 | "131.0.6778.69", 66 | "130.0.6723.119", 67 | "131.0.6778.33", 68 | "130.0.6723.116", 69 | "130.0.6723.71", 70 | "130.0.6723.60", 71 | "130.0.6723.58", 72 | "129.0.6668.103", 73 | "130.0.6723.44", 74 | "129.0.6668.100", 75 | "129.0.6668.72", 76 | "129.0.6668.60", 77 | "129.0.6668.42", 78 | "128.0.6613.122", 79 | "128.0.6613.121", 80 | "128.0.6613.115", 81 | "128.0.6613.113", 82 | "127.0.6533.122", 83 | "128.0.6613.36", 84 | "127.0.6533.119", 85 | "127.0.6533.100", 86 | "127.0.6533.74", 87 | "127.0.6533.72", 88 | "126.0.6478.185", 89 | "127.0.6533.57", 90 | "126.0.6478.183", 91 | "126.0.6478.128", 92 | "126.0.6478.116", 93 | "126.0.6478.114", 94 | "126.0.6478.61", 95 | "125.0.6422.176", 96 | "126.0.6478.56", 97 | "125.0.6422.144", 98 | "126.0.6478.36", 99 | "125.0.6422.142", 100 | "125.0.6422.114", 101 | "125.0.6422.77", 102 | "125.0.6422.76", 103 | "124.0.6367.210", 104 | "125.0.6422.60", 105 | "124.0.6367.208", 106 | "124.0.6367.201", 107 | "124.0.6367.156", 108 | "125.0.6422.41", 109 | "124.0.6367.155", 110 | "124.0.6367.119", 111 | "124.0.6367.92", 112 | "124.0.6367.63", 113 | "124.0.6367.61", 114 | "123.0.6312.124", 115 | "124.0.6367.60", 116 | "123.0.6312.122", 117 | "123.0.6312.106", 118 | "123.0.6312.105", 119 | "123.0.6312.60", 120 | "123.0.6312.58", 121 | "122.0.6261.131", 122 | "123.0.6312.46", 123 | "122.0.6261.129", 124 | "122.0.6261.128", 125 | "122.0.6261.112", 126 | "122.0.6261.111", 127 | "122.0.6261.71", 128 | "122.0.6261.69", 129 | "121.0.6167.189", 130 | "122.0.6261.57", 131 | "121.0.6167.187", 132 | "121.0.6167.186", 133 | "121.0.6167.162", 134 | "121.0.6167.160", 135 | "121.0.6167.140", 136 | "121.0.6167.86", 137 | "121.0.6167.85", 138 | "120.0.6099.227", 139 | "120.0.6099.225", 140 | "121.0.6167.75", 141 | "120.0.6099.224", 142 | "120.0.6099.218", 143 | "120.0.6099.216", 144 | "120.0.6099.200", 145 | "120.0.6099.199", 146 | "120.0.6099.129", 147 | "120.0.6099.110", 148 | "120.0.6099.109", 149 | "120.0.6099.62", 150 | "120.0.6099.56", 151 | } 152 | 153 | platforms = []string{ 154 | "Windows NT 10.0; Win64; x64", 155 | "Macintosh; Intel Mac OS X 10_15_7", 156 | } 157 | ) 158 | -------------------------------------------------------------------------------- /internal/core/update/subcer.go: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/bestruirui/bestsub/internal/config" 9 | "github.com/bestruirui/bestsub/internal/database/op" 10 | "github.com/bestruirui/bestsub/internal/models/setting" 11 | "github.com/bestruirui/bestsub/internal/modules/subcer" 12 | "github.com/bestruirui/bestsub/internal/utils/log" 13 | ) 14 | 15 | func InitSubconverter() error { 16 | filePath := config.Base().SubConverter.Path + "/subconverter" 17 | if runtime.GOOS == "windows" { 18 | filePath += ".exe" 19 | } 20 | if _, err := os.Stat(filePath); err != nil { 21 | log.Infof("subconverter not found, downloading...") 22 | err = updateSubconverter() 23 | if err != nil { 24 | log.Warnf("auto update subconverter failed, please download subconverter manually from %s and move to %s: %v", op.GetSettingStr(setting.SUBCONVERTER_URL), config.Base().SubConverter.Path, err) 25 | os.Exit(1) 26 | return err 27 | } 28 | if _, err := os.Stat(filePath); err != nil { 29 | log.Warnf("subconverter not found, please download subconverter manually from %s and move to %s: %v", op.GetSettingStr(setting.SUBCONVERTER_URL), config.Base().SubConverter.Path, err) 30 | os.Exit(1) 31 | return err 32 | } 33 | } 34 | log.Infof("subconverter is already up to date") 35 | return nil 36 | } 37 | 38 | func UpdateSubconverter() error { 39 | log.Infof("start update subconverter") 40 | err := updateSubconverter() 41 | if err != nil { 42 | log.Warnf("update subconverter failed, please download subconverter manually from %s and move to %s: %v", op.GetSettingStr(setting.SUBCONVERTER_URL), config.Base().SubConverter.Path, err) 43 | return err 44 | } 45 | log.Infof("update subconverter success") 46 | return nil 47 | } 48 | 49 | func updateSubconverter() error { 50 | arch := runtime.GOARCH 51 | goos := runtime.GOOS 52 | 53 | var downloadUrl string 54 | baseUrl := op.GetSettingStr(setting.SUBCONVERTER_URL) 55 | 56 | var filename string 57 | switch goos { 58 | case "windows": 59 | switch arch { 60 | case "386": 61 | filename = "subconverter_win32.zip" 62 | case "amd64": 63 | filename = "subconverter_win64.zip" 64 | default: 65 | log.Errorf("unsupported windows architecture: %s", arch) 66 | return fmt.Errorf("unsupported windows architecture: %s", arch) 67 | } 68 | case "darwin": 69 | switch arch { 70 | case "amd64": 71 | filename = "subconverter_darwin64.zip" 72 | case "arm64": 73 | filename = "subconverter_darwinarm.zip" 74 | default: 75 | log.Errorf("unsupported darwin architecture: %s", arch) 76 | return fmt.Errorf("unsupported darwin architecture: %s", arch) 77 | } 78 | case "linux": 79 | switch arch { 80 | case "386": 81 | filename = "subconverter_linux32.zip" 82 | case "amd64": 83 | filename = "subconverter_linux64.zip" 84 | case "arm": 85 | filename = "subconverter_armv7.zip" 86 | case "arm64": 87 | filename = "subconverter_aarch64.zip" 88 | default: 89 | log.Errorf("unsupported linux architecture: %s", arch) 90 | return fmt.Errorf("unsupported linux architecture: %s", arch) 91 | } 92 | default: 93 | log.Errorf("unsupported operating system: %s", goos) 94 | return fmt.Errorf("unsupported operating system: %s", goos) 95 | } 96 | 97 | downloadUrl = baseUrl + "/" + filename 98 | 99 | bytes, err := download(downloadUrl, op.GetSettingBool(setting.SUBCONVERTER_URL_PROXY)) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | if err := os.MkdirAll(config.Base().SubConverter.Path, 0755); err != nil { 105 | log.Errorf("failed to create directory: %v", err) 106 | return err 107 | } 108 | subcer.Lock() 109 | defer subcer.Unlock() 110 | subcer.Stop() 111 | if err := unzip(bytes, config.Base().SubConverter.Path); err != nil { 112 | return err 113 | } 114 | subcer.Start() 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/server/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bestruirui/bestsub/internal/config" 7 | "github.com/bestruirui/bestsub/internal/server/auth" 8 | "github.com/bestruirui/bestsub/internal/server/resp" 9 | "github.com/bestruirui/bestsub/internal/utils" 10 | "github.com/bestruirui/bestsub/internal/utils/log" 11 | "github.com/cespare/xxhash/v2" 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // Auth JWT认证中间件 16 | func Auth() gin.HandlerFunc { 17 | return func(c *gin.Context) { 18 | authHeader := c.GetHeader("Authorization") 19 | if authHeader == "" { 20 | resp.Error(c, http.StatusUnauthorized, "Authorization header is required") 21 | c.Abort() 22 | return 23 | } 24 | token := authHeader[7:] 25 | 26 | claims, err := auth.ValidateToken(token, config.Base().JWT.Secret) 27 | if err != nil { 28 | log.Warnf("JWT validation failed: %v", err) 29 | resp.Error(c, http.StatusUnauthorized, "Invalid or expired token") 30 | c.Abort() 31 | return 32 | } 33 | 34 | sess, err := auth.GetSession(claims.SessionID) 35 | if err != nil { 36 | log.Warnf("Session not found: %v", err) 37 | resp.Error(c, http.StatusUnauthorized, "Session not found or expired") 38 | c.Abort() 39 | return 40 | } 41 | 42 | if !sess.IsActive { 43 | log.Warnf("Session %d is not active", claims.SessionID) 44 | resp.Error(c, http.StatusUnauthorized, "Session is not active") 45 | c.Abort() 46 | return 47 | } 48 | 49 | if sess.HashAToken != xxhash.Sum64String(token) { 50 | log.Warnf("Token hash mismatch: session=%d, request=%d", sess.HashAToken, xxhash.Sum64String(token)) 51 | resp.Error(c, http.StatusUnauthorized, "Token hash mismatch") 52 | c.Abort() 53 | return 54 | } 55 | 56 | clientIP := utils.IPToUint32(c.ClientIP()) 57 | if sess.ClientIP != clientIP { 58 | log.Warnf("Client IP mismatch: session=%s, request=%s", 59 | utils.Uint32ToIP(sess.ClientIP), c.ClientIP()) 60 | resp.Error(c, http.StatusUnauthorized, "Client IP mismatch") 61 | c.Abort() 62 | return 63 | } 64 | userAgent := c.GetHeader("User-Agent") 65 | if sess.UserAgent != userAgent { 66 | log.Warnf("User-Agent mismatch for session %d", claims.SessionID) 67 | resp.Error(c, http.StatusUnauthorized, "User-Agent mismatch") 68 | c.Abort() 69 | return 70 | } 71 | 72 | c.Set("session_id", claims.SessionID) 73 | c.Next() 74 | } 75 | } 76 | 77 | // WSAuth WebSocket专用认证中间件 78 | // WebSocket连接的认证处理与普通HTTP请求不同,需要特殊处理 79 | func WSAuth() gin.HandlerFunc { 80 | return func(c *gin.Context) { 81 | token := c.Query("token") 82 | 83 | if token == "" { 84 | log.Warnf("WebSocket authentication failed: missing token, IP=%s", c.ClientIP()) 85 | c.AbortWithStatus(http.StatusUnauthorized) 86 | return 87 | } 88 | 89 | claims, err := auth.ValidateToken(token, config.Base().JWT.Secret) 90 | if err != nil { 91 | log.Warnf("WebSocket JWT validation failed: %v, IP=%s", err, c.ClientIP()) 92 | c.AbortWithStatus(http.StatusUnauthorized) 93 | return 94 | } 95 | 96 | sess, err := auth.GetSession(claims.SessionID) 97 | if err != nil { 98 | log.Warnf("WebSocket session not found: %v, IP=%s", err, c.ClientIP()) 99 | c.AbortWithStatus(http.StatusUnauthorized) 100 | return 101 | } 102 | 103 | if !sess.IsActive { 104 | log.Warnf("WebSocket session is not active, SessionID=%d, IP=%s", claims.SessionID, c.ClientIP()) 105 | c.AbortWithStatus(http.StatusUnauthorized) 106 | return 107 | } 108 | 109 | clientIP := utils.IPToUint32(c.ClientIP()) 110 | 111 | if sess.ClientIP != clientIP { 112 | log.Warnf("WebSocket client IP mismatch: session=%s, request=%s", 113 | utils.Uint32ToIP(sess.ClientIP), c.ClientIP()) 114 | c.AbortWithStatus(http.StatusUnauthorized) 115 | return 116 | } 117 | 118 | c.Set("session_id", claims.SessionID) 119 | c.Next() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /internal/database/op/check.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/bestruirui/bestsub/internal/database/interfaces" 10 | "github.com/bestruirui/bestsub/internal/models/check" 11 | "github.com/bestruirui/bestsub/internal/utils/cache" 12 | "github.com/bestruirui/bestsub/internal/utils/log" 13 | ) 14 | 15 | var checkRepo interfaces.CheckRepository 16 | var checkCache = cache.New[uint16, check.Data](16) 17 | 18 | func CheckRepo() interfaces.CheckRepository { 19 | if checkRepo == nil { 20 | checkRepo = repo.Check() 21 | } 22 | return checkRepo 23 | } 24 | func GetCheckByID(id uint16) (check.Data, error) { 25 | if checkCache.Len() == 0 { 26 | if err := refreshCheckCache(); err != nil { 27 | return check.Data{}, err 28 | } 29 | } 30 | if t, ok := checkCache.Get(id); ok { 31 | return t, nil 32 | } 33 | return check.Data{}, fmt.Errorf("check not found") 34 | } 35 | func CreateCheck(ctx context.Context, t *check.Data) error { 36 | if checkCache.Len() == 0 { 37 | if err := refreshCheckCache(); err != nil { 38 | return err 39 | } 40 | } 41 | if err := CheckRepo().Create(ctx, t); err != nil { 42 | return err 43 | } 44 | checkCache.Set(t.ID, *t) 45 | return nil 46 | } 47 | func UpdateCheck(ctx context.Context, t *check.Data) error { 48 | if checkCache.Len() == 0 { 49 | if err := refreshCheckCache(); err != nil { 50 | return err 51 | } 52 | } 53 | oldCheck, ok := checkCache.Get(t.ID) 54 | if !ok { 55 | return fmt.Errorf("task not found") 56 | } 57 | t.Result = oldCheck.Result 58 | if err := CheckRepo().Update(ctx, t); err != nil { 59 | return err 60 | } 61 | checkCache.Set(t.ID, *t) 62 | return nil 63 | } 64 | func UpdateCheckResult(id uint16, result check.Result) error { 65 | if checkCache.Len() == 0 { 66 | if err := refreshCheckCache(); err != nil { 67 | log.Errorf("failed to refresh check cache: %v", err) 68 | return err 69 | } 70 | } 71 | oldCheck, ok := checkCache.Get(id) 72 | if !ok { 73 | log.Errorf("check not found") 74 | return fmt.Errorf("task not found") 75 | } 76 | var oldResult check.Result 77 | if oldCheck.Result != "" { 78 | if err := json.Unmarshal([]byte(oldCheck.Result), &oldResult); err != nil { 79 | log.Errorf("failed to unmarshal check result: %v", err) 80 | return err 81 | } 82 | } 83 | oldResult.Msg = result.Msg 84 | oldResult.Extra = result.Extra 85 | oldResult.LastRun = time.Now() 86 | oldResult.Duration = result.Duration 87 | resultBytes, err := json.Marshal(oldResult) 88 | if err != nil { 89 | log.Errorf("failed to marshal check result: %v", err) 90 | return err 91 | } 92 | oldCheck.Result = string(resultBytes) 93 | if err := CheckRepo().Update(context.Background(), &oldCheck); err != nil { 94 | log.Errorf("failed to update check result: %v", err) 95 | return err 96 | } 97 | checkCache.Set(id, oldCheck) 98 | return nil 99 | } 100 | func DeleteCheck(ctx context.Context, id uint16) error { 101 | if checkCache.Len() == 0 { 102 | if err := refreshCheckCache(); err != nil { 103 | return err 104 | } 105 | } 106 | if err := CheckRepo().Delete(ctx, id); err != nil { 107 | return err 108 | } 109 | checkCache.Del(id) 110 | return nil 111 | } 112 | func GetCheckList() ([]check.Data, error) { 113 | taskList := checkCache.GetAll() 114 | if len(taskList) == 0 { 115 | err := refreshCheckCache() 116 | if err != nil { 117 | return nil, err 118 | } 119 | taskList = checkCache.GetAll() 120 | } 121 | var result = make([]check.Data, 0, len(taskList)) 122 | for _, v := range taskList { 123 | result = append(result, v) 124 | } 125 | return result, nil 126 | } 127 | func refreshCheckCache() error { 128 | checkCache.Clear() 129 | checks, err := CheckRepo().List(context.Background()) 130 | if err != nil { 131 | return err 132 | } 133 | for _, check := range *checks { 134 | checkCache.Set(check.ID, check) 135 | } 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /internal/server/server/server.go: -------------------------------------------------------------------------------- 1 | // Package server 提供 BestSub 应用程序的入口点。 2 | // 3 | // @title BestSub API 4 | // @version 1.0.0 5 | // @description BestSub - API 文档 6 | // @description 7 | // @description 这是 BestSub 的 API 文档 8 | // @description 9 | // @description ## 认证 10 | // @description 大多数接口需要使用 JWT 令牌进行认证。 11 | // @description 认证时,请在 Authorization 头中包含 JWT 令牌: 12 | // @description `Authorization: Bearer ` 13 | // @description 14 | // @description ## 错误响应 15 | // @description 所有错误响应都遵循统一格式,包含 code、message 和 error 字段。 16 | // @description 17 | // @description ## 成功响应 18 | // @description 所有成功响应都遵循统一格式,包含 code、message 和 data 字段。 19 | // 20 | // @contact.name BestSub API 支持 21 | // @contact.email support@bestsub.com 22 | // 23 | // @license.name GPL-3.0 24 | // @license.url https://opensource.org/license/gpl-3-0 25 | // 26 | // @securityDefinitions.apikey BearerAuth 27 | // @in header 28 | // @name Authorization 29 | // @description 类型为 "Bearer",后跟空格和 JWT 令牌。 30 | // 31 | // @tag.name 认证 32 | // @tag.description 用户认证相关接口 33 | // 34 | // @tag.name 系统 35 | // @tag.description 系统状态和健康检查接口 36 | package server 37 | 38 | import ( 39 | "context" 40 | "fmt" 41 | "net/http" 42 | "time" 43 | 44 | "github.com/bestruirui/bestsub/internal/config" 45 | _ "github.com/bestruirui/bestsub/internal/server/handlers" 46 | "github.com/bestruirui/bestsub/internal/server/middleware" 47 | "github.com/bestruirui/bestsub/internal/server/router" 48 | "github.com/bestruirui/bestsub/internal/utils/log" 49 | "github.com/gin-gonic/gin" 50 | ) 51 | 52 | const ( 53 | defaultReadTimeout = 30 * time.Second 54 | defaultWriteTimeout = 30 * time.Second 55 | defaultIdleTimeout = 60 * time.Second 56 | defaultShutdownTimeout = 30 * time.Second 57 | defaultMaxHeaderBytes = 1 << 20 // 1MB 58 | ) 59 | 60 | var server *Server 61 | 62 | type Server struct { 63 | httpServer *http.Server 64 | router *gin.Engine 65 | } 66 | 67 | func Initialize() error { 68 | 69 | r, routerErr := setRouter() 70 | if routerErr != nil { 71 | return fmt.Errorf("failed to set router: %w", routerErr) 72 | } 73 | 74 | server = &Server{ 75 | httpServer: &http.Server{ 76 | Addr: fmt.Sprintf("%s:%d", config.Base().Server.Host, config.Base().Server.Port), 77 | Handler: r, 78 | ReadTimeout: defaultReadTimeout, 79 | WriteTimeout: defaultWriteTimeout, 80 | IdleTimeout: defaultIdleTimeout, 81 | MaxHeaderBytes: defaultMaxHeaderBytes, 82 | }, 83 | router: r, 84 | } 85 | return nil 86 | } 87 | 88 | func Start() error { 89 | if server == nil { 90 | return fmt.Errorf("HTTP server not initialized, please call Initialize() first") 91 | } 92 | 93 | log.Infof("Starting HTTP server %s", server.httpServer.Addr) 94 | 95 | go func() { 96 | if err := server.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 97 | log.Errorf("Failed to start HTTP server: %v", err) 98 | } 99 | }() 100 | 101 | return nil 102 | } 103 | 104 | func Close() error { 105 | if server == nil { 106 | return fmt.Errorf("HTTP server not initialized") 107 | } 108 | 109 | ctx, cancel := context.WithTimeout(context.Background(), defaultShutdownTimeout) 110 | defer cancel() 111 | 112 | if err := server.httpServer.Shutdown(ctx); err != nil { 113 | log.Errorf("HTTP server force closed: %v", err) 114 | return fmt.Errorf("HTTP server force closed: %w", err) 115 | } 116 | 117 | log.Debug("HTTP server closed") 118 | return nil 119 | } 120 | 121 | func IsInitialized() bool { 122 | return server != nil 123 | } 124 | 125 | func setRouter() (*gin.Engine, error) { 126 | gin.SetMode(gin.ReleaseMode) 127 | r := gin.New() 128 | 129 | // r.Use(middleware.Logging()) 130 | r.Use(middleware.Recovery()) 131 | r.Use(middleware.Cors()) 132 | r.Use(middleware.Static()) 133 | 134 | if err := router.RegisterAll(r); err != nil { 135 | return nil, fmt.Errorf("failed to register routes: %w", err) 136 | } 137 | 138 | log.Debugf("successfully registered %d routes", router.GetRouterCount()) 139 | return r, nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/core/check/checker/tiktok.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "sync" 9 | "time" 10 | 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/bestruirui/bestsub/internal/core/mihomo" 14 | "github.com/bestruirui/bestsub/internal/core/node" 15 | "github.com/bestruirui/bestsub/internal/core/task" 16 | "github.com/bestruirui/bestsub/internal/models/check" 17 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 18 | "github.com/bestruirui/bestsub/internal/modules/register" 19 | "github.com/bestruirui/bestsub/internal/utils/log" 20 | "github.com/bestruirui/bestsub/internal/utils/ua" 21 | ) 22 | 23 | type TikTok struct { 24 | Thread int `json:"thread" name:"线程数" value:"200"` 25 | Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` 26 | } 27 | 28 | func (e *TikTok) Init() error { 29 | return nil 30 | } 31 | 32 | func (e *TikTok) Run(ctx context.Context, log *log.Logger, subID []uint16) check.Result { 33 | startTime := time.Now() 34 | var nodes []nodeModel.Data 35 | 36 | if len(subID) == 0 { 37 | nodes = node.GetAll() 38 | } else { 39 | nodes = *node.GetBySubId(subID) 40 | } 41 | 42 | threads := e.Thread 43 | if threads <= 0 || threads > len(nodes) { 44 | threads = len(nodes) 45 | } 46 | if threads > task.MaxThread() { 47 | threads = task.MaxThread() 48 | } 49 | if threads == 0 || len(nodes) == 0 { 50 | log.Warnf("tiktok check task failed, no nodes") 51 | return check.Result{ 52 | Msg: "no nodes", 53 | LastRun: time.Now(), 54 | Duration: time.Since(startTime).Milliseconds(), 55 | } 56 | } 57 | 58 | sem := make(chan struct{}, threads) 59 | defer close(sem) 60 | 61 | var wg sync.WaitGroup 62 | for _, nd := range nodes { 63 | sem <- struct{}{} 64 | wg.Add(1) 65 | n := nd 66 | task.Submit(func() { 67 | defer func() { 68 | <-sem 69 | wg.Done() 70 | }() 71 | 72 | var raw map[string]any 73 | if err := yaml.Unmarshal(n.Raw, &raw); err != nil { 74 | log.Warnf("yaml.Unmarshal failed: %v", err) 75 | return 76 | } 77 | 78 | switch e.detectTikTok(ctx, raw) { 79 | case 1: 80 | n.Info.SetAliveStatus(nodeModel.TikTok, true) 81 | case 2: 82 | n.Info.SetAliveStatus(nodeModel.TikTokIDC, true) 83 | default: 84 | n.Info.SetAliveStatus(nodeModel.TikTok, false) 85 | n.Info.SetAliveStatus(nodeModel.TikTokIDC, false) 86 | } 87 | }) 88 | } 89 | wg.Wait() 90 | 91 | log.Debugf("tiktok check task end") 92 | return check.Result{ 93 | Msg: "success", 94 | LastRun: time.Now(), 95 | Duration: time.Since(startTime).Milliseconds(), 96 | } 97 | } 98 | 99 | func (e *TikTok) detectTikTok(ctx context.Context, raw map[string]any) uint8 { 100 | client := mihomo.Proxy(raw) 101 | if client == nil { 102 | return 0 103 | } 104 | client.Timeout = time.Duration(e.Timeout) * time.Second 105 | defer client.Release() 106 | 107 | req, err := http.NewRequestWithContext(ctx, "GET", "https://www.tiktok.com/", nil) 108 | if err != nil { 109 | return 0 110 | } 111 | 112 | ua.SetHeader(req) 113 | resp, err := client.Do(req) 114 | if err != nil { 115 | return 0 116 | } 117 | defer resp.Body.Close() 118 | 119 | body, err := io.ReadAll(resp.Body) 120 | if err != nil { 121 | return 0 122 | } 123 | if extractRegion(body) { 124 | return 1 125 | } 126 | 127 | req, err = http.NewRequestWithContext(ctx, "GET", "https://www.tiktok.com/api/passport/web/region/get/", nil) 128 | if err != nil { 129 | return 0 130 | } 131 | ua.SetHeader(req) 132 | req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9") 133 | req.Header.Set("Accept-Language", "en") 134 | 135 | resp, err = client.Do(req) 136 | if err != nil { 137 | return 0 138 | } 139 | defer resp.Body.Close() 140 | body, err = io.ReadAll(resp.Body) 141 | if err != nil { 142 | return 0 143 | } 144 | if extractRegion(body) { 145 | return 2 146 | } 147 | 148 | return 0 149 | } 150 | 151 | func extractRegion(html []byte) bool { 152 | return bytes.Contains(html, []byte(`"region":`)) 153 | } 154 | 155 | func init() { 156 | register.Check(&TikTok{}) 157 | } 158 | -------------------------------------------------------------------------------- /internal/server/auth/session.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "errors" 7 | "os" 8 | "path" 9 | "time" 10 | 11 | "github.com/bestruirui/bestsub/internal/config" 12 | "github.com/bestruirui/bestsub/internal/models/auth" 13 | "github.com/bestruirui/bestsub/internal/utils" 14 | "github.com/bestruirui/bestsub/internal/utils/log" 15 | ) 16 | 17 | const MaxSessions = 10 18 | 19 | var ( 20 | ErrSessionPoolFull = errors.New("session pool is full") 21 | ErrInvalidSessionID = errors.New("invalid session ID") 22 | ErrSessionNotFound = errors.New("session not found") 23 | sessions = make([]auth.Session, MaxSessions) 24 | ) 25 | 26 | // Load 从文件加载会话信息 27 | func init() { 28 | sessionFile := config.Base().Session.AuthPath 29 | if _, err := os.Stat(sessionFile); os.IsNotExist(err) { 30 | return 31 | } 32 | 33 | data, err := os.ReadFile(sessionFile) 34 | if err != nil { 35 | return 36 | } 37 | 38 | if len(data) == 0 { 39 | return 40 | } 41 | 42 | decoder := gob.NewDecoder(bytes.NewReader(data)) 43 | if err := decoder.Decode(&sessions); err != nil { 44 | return 45 | } 46 | cleanup() 47 | } 48 | 49 | // CloseSession 关闭会话管理器,将会话信息保存到文件 50 | func CloseSession() error { 51 | cleanup() 52 | 53 | var buf bytes.Buffer 54 | encoder := gob.NewEncoder(&buf) 55 | if err := encoder.Encode(sessions); err != nil { 56 | return err 57 | } 58 | sessionFile := config.Base().Session.AuthPath 59 | 60 | dir := path.Dir(sessionFile) 61 | if _, err := os.Stat(dir); os.IsNotExist(err) { 62 | os.MkdirAll(dir, 0755) 63 | } 64 | if os.WriteFile(sessionFile, buf.Bytes(), 0600) != nil { 65 | log.Error("session save failed") 66 | } 67 | log.Debugf("session saved") 68 | return nil 69 | } 70 | 71 | func GetOneSession() (uint8, *auth.Session) { 72 | var oldestIndex uint8 = 0 73 | var oldestTime uint32 = 0 74 | found := false 75 | cleanup() 76 | for i := range sessions { 77 | if !sessions[i].IsActive { 78 | if sessions[i].CreatedAt == 0 { 79 | return uint8(i), &sessions[i] 80 | } 81 | 82 | if !found || sessions[i].CreatedAt < oldestTime { 83 | oldestIndex = uint8(i) 84 | oldestTime = sessions[i].CreatedAt 85 | found = true 86 | } 87 | } 88 | } 89 | 90 | if found { 91 | return oldestIndex, &sessions[oldestIndex] 92 | } 93 | return 0, nil 94 | } 95 | 96 | // GetSession 获取会话 97 | func GetSession(sessionID uint8) (*auth.Session, error) { 98 | cleanup() 99 | if sessionID >= MaxSessions { 100 | return nil, ErrInvalidSessionID 101 | } 102 | 103 | if int(sessionID) >= len(sessions) { 104 | return nil, ErrSessionNotFound 105 | } 106 | session := &sessions[sessionID] 107 | 108 | return session, nil 109 | } 110 | 111 | // DisableSession 禁用会话 112 | func DisableSession(sessionID uint8) error { 113 | cleanup() 114 | if sessionID >= MaxSessions { 115 | return ErrInvalidSessionID 116 | } 117 | 118 | sessions[sessionID].IsActive = false 119 | return nil 120 | } 121 | 122 | // DisableAllSession 禁用所有会话 123 | func DisableAllSession() { 124 | for i := range sessions { 125 | sessions[i].IsActive = false 126 | } 127 | } 128 | 129 | // GetAllSession 获取所有会话 130 | func GetAllSession() *[]auth.SessionResponse { 131 | cleanup() 132 | 133 | var sessionResponse []auth.SessionResponse 134 | 135 | for i := range sessions { 136 | if sessions[i].CreatedAt == 0 { 137 | continue 138 | } 139 | sessionResponse = append(sessionResponse, auth.SessionResponse{ 140 | ID: uint8(i), 141 | IsActive: sessions[i].IsActive, 142 | ClientIP: utils.Uint32ToIP(sessions[i].ClientIP), 143 | UserAgent: sessions[i].UserAgent, 144 | ExpiresAt: time.Unix(int64(sessions[i].ExpiresAt), 0), 145 | CreatedAt: time.Unix(int64(sessions[i].CreatedAt), 0), 146 | LastAccessAt: time.Unix(int64(sessions[i].LastAccessAt), 0), 147 | }) 148 | } 149 | 150 | return &sessionResponse 151 | } 152 | 153 | // cleanup 清理过期会话,返回清理数量 154 | func cleanup() int { 155 | 156 | cleaned := 0 157 | now := uint32(time.Now().Unix()) 158 | 159 | for i := range sessions { 160 | if sessions[i].IsActive && now > sessions[i].ExpiresAt { 161 | sessions[i].IsActive = false 162 | cleaned++ 163 | } 164 | } 165 | 166 | return cleaned 167 | } 168 | -------------------------------------------------------------------------------- /internal/core/mihomo/mihomo.go: -------------------------------------------------------------------------------- 1 | package mihomo 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "sync" 10 | "time" 11 | 12 | "github.com/bestruirui/bestsub/internal/database/op" 13 | "github.com/bestruirui/bestsub/internal/models/setting" 14 | "github.com/bestruirui/bestsub/internal/utils/log" 15 | "github.com/metacubex/mihomo/adapter" 16 | "github.com/metacubex/mihomo/constant" 17 | ) 18 | 19 | type HC struct { 20 | *http.Client 21 | proxy constant.Proxy 22 | } 23 | 24 | var clientPool = sync.Pool{ 25 | New: func() interface{} { 26 | return &http.Client{ 27 | Timeout: 300 * time.Second, 28 | } 29 | }, 30 | } 31 | 32 | var transportPool = sync.Pool{ 33 | New: func() interface{} { 34 | return &http.Transport{ 35 | DisableKeepAlives: true, 36 | TLSHandshakeTimeout: 30 * time.Second, 37 | ExpectContinueTimeout: 10 * time.Second, 38 | ResponseHeaderTimeout: 30 * time.Second, 39 | } 40 | }, 41 | } 42 | 43 | func parsePort(portStr string) (uint16, error) { 44 | port, err := strconv.ParseUint(portStr, 10, 16) 45 | if err != nil { 46 | return 0, err 47 | } 48 | return uint16(port), nil 49 | } 50 | 51 | func Default(useProxy bool) *HC { 52 | if !useProxy || !op.GetSettingBool(setting.PROXY_ENABLE) { 53 | return direct() 54 | } 55 | proxyUrl := op.GetSettingStr(setting.PROXY_URL) 56 | if proxyUrl == "" { 57 | log.Warnf("proxy url is empty") 58 | return direct() 59 | } 60 | 61 | parsed, err := url.Parse(proxyUrl) 62 | if err != nil { 63 | log.Warnf("parse proxy url failed: %v", err) 64 | return direct() 65 | } 66 | 67 | host, portStr, err := net.SplitHostPort(parsed.Host) 68 | if err != nil { 69 | log.Warnf("split host port failed: %v", err) 70 | return direct() 71 | } 72 | 73 | portInt, err := parsePort(portStr) 74 | if err != nil { 75 | log.Warnf("parse port failed: %v", err) 76 | return direct() 77 | } 78 | 79 | proxyConfig := map[string]any{ 80 | "name": "proxy", 81 | "server": host, 82 | "port": portInt, 83 | "username": parsed.User.Username(), 84 | } 85 | if password, ok := parsed.User.Password(); ok { 86 | proxyConfig["password"] = password 87 | } 88 | switch parsed.Scheme { 89 | case "socks5": 90 | proxyConfig["type"] = "socks5" 91 | case "http": 92 | proxyConfig["type"] = "http" 93 | case "https": 94 | proxyConfig["type"] = "http" 95 | proxyConfig["tls"] = true 96 | default: 97 | log.Warnf("unsupported proxy scheme: %s", parsed.Scheme) 98 | return direct() 99 | } 100 | return Proxy(proxyConfig) 101 | } 102 | 103 | func direct() *HC { 104 | var directProxy = map[string]any{ 105 | "name": "direct", 106 | "type": "direct", 107 | } 108 | return Proxy(directProxy) 109 | } 110 | 111 | func Proxy(raw map[string]any) *HC { 112 | if raw == nil { 113 | log.Warnf("proxy config is nil") 114 | return nil 115 | } 116 | proxy, err := adapter.ParseProxy(raw) 117 | if err != nil { 118 | if proxy != nil { 119 | proxy.Close() 120 | } 121 | log.Debugf("parse proxy failed: %v raw: %v", err, raw) 122 | return nil 123 | } 124 | 125 | client := clientPool.Get().(*http.Client) 126 | transport := transportPool.Get().(*http.Transport) 127 | 128 | transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { 129 | host, portStr, err := net.SplitHostPort(addr) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | u16Port, err := parsePort(portStr) 135 | if err != nil { 136 | log.Warnf("parse port failed, using port 0: %v", err) 137 | u16Port = 0 138 | } 139 | 140 | return proxy.DialContext(ctx, &constant.Metadata{ 141 | Host: host, 142 | DstPort: u16Port, 143 | }) 144 | } 145 | 146 | client.Transport = transport 147 | return &HC{Client: client, proxy: proxy} 148 | } 149 | 150 | func (h *HC) Release() { 151 | if h.Client == nil { 152 | return 153 | } 154 | if h.proxy != nil { 155 | h.proxy.Close() 156 | h.proxy = nil 157 | } 158 | if transport, ok := h.Transport.(*http.Transport); ok { 159 | transport.DialContext = nil 160 | transport.TLSClientConfig = nil 161 | transport.Proxy = nil 162 | transport.CloseIdleConnections() 163 | transportPool.Put(transport) 164 | } 165 | h.Transport = nil 166 | h.Timeout = 300 * time.Second 167 | h.CheckRedirect = nil 168 | h.Jar = nil 169 | clientPool.Put(h.Client) 170 | } 171 | -------------------------------------------------------------------------------- /internal/core/check/checker/alive.go: -------------------------------------------------------------------------------- 1 | package checker 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "sync" 8 | "sync/atomic" 9 | "time" 10 | 11 | "gopkg.in/yaml.v3" 12 | 13 | "github.com/bestruirui/bestsub/internal/core/mihomo" 14 | "github.com/bestruirui/bestsub/internal/core/node" 15 | "github.com/bestruirui/bestsub/internal/core/task" 16 | checkModel "github.com/bestruirui/bestsub/internal/models/check" 17 | nodeModel "github.com/bestruirui/bestsub/internal/models/node" 18 | "github.com/bestruirui/bestsub/internal/modules/register" 19 | "github.com/bestruirui/bestsub/internal/utils/log" 20 | ) 21 | 22 | type Alive struct { 23 | URL string `json:"url" name:"测试链接" value:"https://www.gstatic.com/generate_204"` 24 | ExptectCode int `json:"exptect_code" name:"期望状态码" value:"204"` 25 | Thread int `json:"thread" name:"线程数" value:"100"` 26 | Timeout int `json:"timeout" name:"超时时间" value:"10" desc:"单个节点检测的超时时间(s)"` 27 | } 28 | type Result struct { 29 | AliveCount uint16 `json:"alive_count" desc:"存活节点数量"` 30 | DeadCount uint16 `json:"dead_count" desc:"死亡节点数量"` 31 | Delay uint16 `json:"delay" desc:"平均延迟"` 32 | } 33 | 34 | func (e *Alive) Init() error { 35 | return nil 36 | } 37 | 38 | func (e *Alive) Run(ctx context.Context, log *log.Logger, subID []uint16) checkModel.Result { 39 | startTime := time.Now() 40 | var nodes []nodeModel.Data 41 | var aliveCount, deadCount, totalDelay int64 42 | if len(subID) == 0 { 43 | nodes = node.GetAll() 44 | } else { 45 | nodes = *node.GetBySubId(subID) 46 | } 47 | threads := e.Thread 48 | if threads <= 0 || threads > len(nodes) { 49 | threads = len(nodes) 50 | } 51 | if threads > task.MaxThread() { 52 | threads = task.MaxThread() 53 | } 54 | if threads == 0 || len(nodes) == 0 { 55 | log.Warnf("alive check task failed, no nodes") 56 | return checkModel.Result{ 57 | Msg: "no nodes", 58 | LastRun: time.Now(), 59 | Duration: time.Since(startTime).Milliseconds(), 60 | } 61 | } 62 | sem := make(chan struct{}, threads) 63 | defer close(sem) 64 | 65 | var wg sync.WaitGroup 66 | for _, nd := range nodes { 67 | sem <- struct{}{} 68 | wg.Add(1) 69 | n := nd 70 | task.Submit(func() { 71 | defer func() { 72 | <-sem 73 | wg.Done() 74 | }() 75 | var raw map[string]any 76 | if err := yaml.Unmarshal(n.Raw, &raw); err != nil { 77 | log.Warnf("yaml.Unmarshal failed: %v", err) 78 | return 79 | } 80 | start := time.Now() 81 | alive := e.detect(ctx, raw) 82 | if alive { 83 | log.Debugf("Node %s is alive ✔", raw["name"].(string)) 84 | atomic.AddInt64(&aliveCount, 1) 85 | n.Info.SetAliveStatus(nodeModel.Alive, true) 86 | n.Info.Delay.Update(uint16(time.Since(start).Milliseconds())) 87 | log.Debugf("Node %s delay: %dms", raw["name"].(string), n.Info.Delay.Average()) 88 | atomic.AddInt64(&totalDelay, int64(n.Info.Delay.Average())) 89 | } else { 90 | log.Debugf("Node %s is dead ✘", raw["name"].(string)) 91 | atomic.AddInt64(&deadCount, 1) 92 | n.Info.SetAliveStatus(nodeModel.Alive, false) 93 | n.Info.Delay.Update(uint16(65535)) 94 | } 95 | 96 | }) 97 | } 98 | wg.Wait() 99 | avgDelay := int64(0) 100 | if aliveCount > 0 { 101 | avgDelay = totalDelay / aliveCount 102 | } 103 | log.Debugf("alive check task end, alive: %d, dead: %d, average delay: %dms", aliveCount, deadCount, avgDelay) 104 | return checkModel.Result{ 105 | Msg: fmt.Sprintf("success, alive: %d, dead: %d, average delay: %dms", aliveCount, deadCount, avgDelay), 106 | LastRun: time.Now(), 107 | Duration: time.Since(startTime).Milliseconds(), 108 | Extra: map[string]any{ 109 | "alive": aliveCount, 110 | "dead": deadCount, 111 | "delay": avgDelay, 112 | }, 113 | } 114 | } 115 | 116 | func (e *Alive) detect(ctx context.Context, raw map[string]any) bool { 117 | client := mihomo.Proxy(raw) 118 | if client == nil { 119 | return false 120 | } 121 | client.Timeout = time.Duration(e.Timeout) * time.Second 122 | defer client.Release() 123 | request, err := http.NewRequestWithContext(ctx, "GET", e.URL, nil) 124 | if err != nil { 125 | return false 126 | } 127 | response, err := client.Do(request) 128 | if err != nil { 129 | return false 130 | } 131 | defer response.Body.Close() 132 | return response.StatusCode == e.ExptectCode 133 | } 134 | 135 | func init() { 136 | register.Check(&Alive{}) 137 | } 138 | -------------------------------------------------------------------------------- /internal/core/cron/check.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/bestruirui/bestsub/internal/core/check" 10 | "github.com/bestruirui/bestsub/internal/core/node" 11 | "github.com/bestruirui/bestsub/internal/database/op" 12 | checkModel "github.com/bestruirui/bestsub/internal/models/check" 13 | "github.com/bestruirui/bestsub/internal/utils/generic" 14 | "github.com/bestruirui/bestsub/internal/utils/log" 15 | "github.com/robfig/cron/v3" 16 | ) 17 | 18 | var checkFunc = generic.MapOf[uint16, cronFunc]{} 19 | var checkScheduled = generic.MapOf[uint16, cron.EntryID]{} 20 | var checkRunning = generic.MapOf[uint16, context.CancelFunc]{} 21 | 22 | func CheckLoad() { 23 | checkData, err := op.GetCheckList() 24 | if err != nil { 25 | log.Errorf("failed to load sub data: %v", err) 26 | return 27 | } 28 | for _, data := range checkData { 29 | CheckAdd(&data) 30 | } 31 | } 32 | func CheckAdd(data *checkModel.Data) error { 33 | var taskConfig checkModel.Task 34 | if err := json.Unmarshal([]byte(data.Task), &taskConfig); err != nil { 35 | log.Errorf("failed to unmarshal task config: %v", err) 36 | return err 37 | } 38 | checkFunc.Store(data.ID, cronFunc{ 39 | fn: func() { 40 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(taskConfig.Timeout)*time.Minute) 41 | checkRunning.Store(data.ID, cancel) 42 | defer func() { 43 | cancel() 44 | checkRunning.Delete(data.ID) 45 | }() 46 | logger, err := log.NewTaskLogger("check", data.ID, taskConfig.LogLevel, taskConfig.LogWriteFile) 47 | if err != nil { 48 | log.Errorf("failed to create logger: %v", err) 49 | return 50 | } 51 | go func() { 52 | <-ctx.Done() 53 | logger.Close() 54 | }() 55 | checker, err := check.Get(taskConfig.Type, data.Config) 56 | if err != nil { 57 | log.Errorf("failed to get execer: %v", err) 58 | return 59 | } 60 | log.Infof("%s task %d start", taskConfig.Type, data.ID) 61 | var result checkModel.Result 62 | if taskConfig.SubIdExclude { 63 | result = checker.Run(ctx, logger, node.GetBySubIdExclude(taskConfig.SubID)) 64 | } else { 65 | result = checker.Run(ctx, logger, taskConfig.SubID) 66 | } 67 | log.Infof("%s task %d end", taskConfig.Type, data.ID) 68 | op.UpdateCheckResult(data.ID, result) 69 | node.RefreshInfo() 70 | }, 71 | cronExpr: taskConfig.CronExpr, 72 | }) 73 | if data.Enable { 74 | CheckEnable(data.ID) 75 | } 76 | return nil 77 | } 78 | func CheckUpdate(data *checkModel.Data) error { 79 | CheckRemove(data.ID) 80 | CheckAdd(data) 81 | return nil 82 | } 83 | 84 | func CheckRun(id uint16) error { 85 | if ft, ok := checkFunc.Load(id); ok { 86 | go ft.fn() 87 | return nil 88 | } else { 89 | return fmt.Errorf("check task %d not found", id) 90 | } 91 | } 92 | 93 | func CheckEnable(id uint16) error { 94 | if _, ok := checkScheduled.Load(id); ok { 95 | log.Warnf("check task %d already scheduled", id) 96 | return nil 97 | } 98 | if ft, ok := checkFunc.Load(id); ok { 99 | entryID, err := scheduler.AddFunc(ft.cronExpr, ft.fn) 100 | if err != nil { 101 | log.Errorf("failed to add task: %v", err) 102 | return err 103 | } 104 | checkScheduled.Store(id, entryID) 105 | } 106 | return nil 107 | } 108 | func CheckDisable(id uint16) error { 109 | if entryID, ok := checkScheduled.Load(id); ok { 110 | scheduler.Remove(entryID) 111 | checkScheduled.Delete(id) 112 | if cancel, ok := checkRunning.Load(id); ok { 113 | cancel() 114 | checkRunning.Delete(id) 115 | } 116 | } 117 | return nil 118 | } 119 | 120 | func CheckRemove(id uint16) error { 121 | if entryID, ok := checkScheduled.Load(id); ok { 122 | scheduler.Remove(entryID) 123 | checkScheduled.Delete(id) 124 | checkFunc.Delete(id) 125 | if cancel, ok := checkRunning.Load(id); ok { 126 | cancel() 127 | checkRunning.Delete(id) 128 | } 129 | } 130 | return nil 131 | } 132 | func CheckStop(id uint16) error { 133 | if cancel, ok := checkRunning.Load(id); ok { 134 | cancel() 135 | checkRunning.Delete(id) 136 | } 137 | return nil 138 | } 139 | func CheckStatus(id uint16) string { 140 | if _, ok := checkRunning.Load(id); ok { 141 | return RunningStatus 142 | } 143 | if _, ok := checkScheduled.Load(id); ok { 144 | return ScheduledStatus 145 | } 146 | if _, ok := checkFunc.Load(id); ok { 147 | return PendingStatus 148 | } 149 | return DisabledStatus 150 | } 151 | -------------------------------------------------------------------------------- /internal/core/cron/fetch.go: -------------------------------------------------------------------------------- 1 | package cron 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "math/rand" 7 | "time" 8 | 9 | "github.com/bestruirui/bestsub/internal/core/fetch" 10 | "github.com/bestruirui/bestsub/internal/database/op" 11 | subModel "github.com/bestruirui/bestsub/internal/models/sub" 12 | "github.com/bestruirui/bestsub/internal/utils/generic" 13 | "github.com/bestruirui/bestsub/internal/utils/log" 14 | "github.com/robfig/cron/v3" 15 | ) 16 | 17 | var fetchFunc = generic.MapOf[uint16, cronFunc]{} 18 | var fetchScheduled = generic.MapOf[uint16, cron.EntryID]{} 19 | var fetchRunning = generic.MapOf[uint16, context.CancelFunc]{} 20 | 21 | func FetchLoad() { 22 | subData, err := op.GetSubList(context.Background()) 23 | if err != nil { 24 | log.Errorf("failed to load sub data: %v", err) 25 | return 26 | } 27 | for _, data := range subData { 28 | FetchAdd(&data) 29 | } 30 | } 31 | 32 | func FetchAdd(data *subModel.Data) error { 33 | fetchFunc.Store(data.ID, cronFunc{ 34 | fn: func() { 35 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 36 | fetchRunning.Store(data.ID, cancel) 37 | defer func() { 38 | cancel() 39 | fetchRunning.Delete(data.ID) 40 | }() 41 | result := fetch.Do(ctx, data.ID, data.Config) 42 | op.UpdateSubResult(ctx, data.ID, result) 43 | sub, err := op.GetSubByID(ctx, data.ID) 44 | if err != nil { 45 | log.Warnf("failed to get sub by id: %v", err) 46 | return 47 | } 48 | if !sub.Enable { 49 | FetchDisable(data.ID) 50 | log.Infof("fetch task %d auto disable", data.ID) 51 | } 52 | }, 53 | cronExpr: data.CronExpr, 54 | }) 55 | if data.Enable { 56 | FetchEnable(data.ID) 57 | } 58 | return nil 59 | } 60 | 61 | func FetchRun(subID uint16) subModel.Result { 62 | if ft, ok := fetchFunc.Load(subID); ok { 63 | ft.fn() 64 | } else { 65 | log.Warnf("fetch task %d not found", subID) 66 | return subModel.Result{ 67 | Msg: "fetch task not found", 68 | LastRun: time.Now(), 69 | } 70 | } 71 | sub, err := op.GetSubByID(context.Background(), subID) 72 | if err != nil { 73 | log.Warnf("failed to get sub by id: %v", err) 74 | return subModel.Result{ 75 | Msg: "fetch task not found", 76 | LastRun: time.Now(), 77 | } 78 | } 79 | var result subModel.Result 80 | if err := json.Unmarshal([]byte(sub.Result), &result); err != nil { 81 | log.Warnf("failed to unmarshal sub result: %v", err) 82 | return subModel.Result{ 83 | Msg: "failed to unmarshal sub result", 84 | LastRun: time.Now(), 85 | } 86 | } 87 | return result 88 | } 89 | 90 | func FetchEnable(subID uint16) error { 91 | if _, ok := fetchScheduled.Load(subID); ok { 92 | log.Warnf("fetch task %d already scheduled", subID) 93 | return nil 94 | } 95 | if ft, ok := fetchFunc.Load(subID); ok { 96 | entryID, err := scheduler.AddFunc(ft.cronExpr, 97 | func() { 98 | time.Sleep(time.Duration(rand.Intn(100)) * time.Second) 99 | ft.fn() 100 | }) 101 | if err != nil { 102 | log.Errorf("failed to add task: %v", err) 103 | return err 104 | } 105 | fetchScheduled.Store(subID, entryID) 106 | } 107 | return nil 108 | } 109 | func FetchDisable(subID uint16) error { 110 | if entryID, ok := fetchScheduled.Load(subID); ok { 111 | scheduler.Remove(entryID) 112 | fetchScheduled.Delete(subID) 113 | if cancel, ok := fetchRunning.Load(subID); ok { 114 | cancel() 115 | fetchRunning.Delete(subID) 116 | } 117 | } 118 | return nil 119 | } 120 | 121 | func FetchRemove(subID uint16) error { 122 | if entryID, ok := fetchScheduled.Load(subID); ok { 123 | scheduler.Remove(entryID) 124 | fetchScheduled.Delete(subID) 125 | fetchFunc.Delete(subID) 126 | if cancel, ok := fetchRunning.Load(subID); ok { 127 | cancel() 128 | fetchRunning.Delete(subID) 129 | } 130 | } 131 | return nil 132 | } 133 | func FetchStop(subID uint16) error { 134 | if cancel, ok := fetchRunning.Load(subID); ok { 135 | cancel() 136 | fetchRunning.Delete(subID) 137 | } 138 | return nil 139 | } 140 | func FetchUpdate(data *subModel.Data) error { 141 | FetchRemove(data.ID) 142 | FetchAdd(data) 143 | return nil 144 | } 145 | func FetchStatus(subID uint16) string { 146 | if _, ok := fetchRunning.Load(subID); ok { 147 | return RunningStatus 148 | } 149 | if _, ok := fetchScheduled.Load(subID); ok { 150 | return ScheduledStatus 151 | } 152 | if _, ok := fetchFunc.Load(subID); ok { 153 | return PendingStatus 154 | } 155 | return DisabledStatus 156 | } 157 | -------------------------------------------------------------------------------- /internal/database/op/notify.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/interfaces" 8 | "github.com/bestruirui/bestsub/internal/models/notify" 9 | "github.com/bestruirui/bestsub/internal/utils/cache" 10 | ) 11 | 12 | var nr interfaces.NotifyRepository 13 | var ntr interfaces.NotifyTemplateRepository 14 | var notifyTemplateCache = cache.New[string, string](4) 15 | var notifyCache = cache.New[uint16, notify.Data](4) 16 | 17 | func notifyRepo() interfaces.NotifyRepository { 18 | if nr == nil { 19 | nr = repo.Notify() 20 | } 21 | return nr 22 | } 23 | func GetNotifyList() ([]notify.Data, error) { 24 | notifyList := notifyCache.GetAll() 25 | if len(notifyList) == 0 { 26 | err := refreshNotifyCache(context.Background()) 27 | if err != nil { 28 | return nil, err 29 | } 30 | notifyList = notifyCache.GetAll() 31 | } 32 | var result = make([]notify.Data, 0, len(notifyList)) 33 | for _, v := range notifyList { 34 | result = append(result, v) 35 | } 36 | return result, nil 37 | } 38 | func GetNotifyByID(id uint16) (notify.Data, error) { 39 | if value, ok := notifyCache.Get(id); ok { 40 | return value, nil 41 | } 42 | err := refreshNotifyCache(context.Background()) 43 | if err != nil { 44 | return notify.Data{}, err 45 | } 46 | if value, ok := notifyCache.Get(id); ok { 47 | return value, nil 48 | } 49 | return notify.Data{}, fmt.Errorf("notify not found") 50 | } 51 | func UpdateNotify(ctx context.Context, n *notify.Data) error { 52 | if notifyCache.Len() == 0 { 53 | err := refreshNotifyCache(context.Background()) 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | if err := notifyRepo().Update(ctx, n); err != nil { 59 | return err 60 | } 61 | notifyCache.Set(n.ID, *n) 62 | return nil 63 | } 64 | func CreateNotify(ctx context.Context, n *notify.Data) error { 65 | if notifyCache.Len() == 0 { 66 | err := refreshNotifyCache(context.Background()) 67 | if err != nil { 68 | return err 69 | } 70 | } 71 | 72 | if err := notifyRepo().Create(ctx, n); err != nil { 73 | return err 74 | } 75 | notifyCache.Set(n.ID, *n) 76 | return nil 77 | } 78 | func DeleteNotify(ctx context.Context, id uint16) error { 79 | if notifyCache.Len() == 0 { 80 | err := refreshNotifyCache(context.Background()) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | if err := notifyRepo().Delete(ctx, id); err != nil { 86 | return err 87 | } 88 | notifyCache.Del(id) 89 | return nil 90 | } 91 | func refreshNotifyCache(ctx context.Context) error { 92 | notifyCache.Clear() 93 | notifyList, err := notifyRepo().List(ctx) 94 | if err != nil { 95 | return err 96 | } 97 | for _, n := range *notifyList { 98 | notifyCache.Set(n.ID, n) 99 | } 100 | return nil 101 | } 102 | 103 | func NotifyTemplateRepo() interfaces.NotifyTemplateRepository { 104 | if ntr == nil { 105 | ntr = repo.NotifyTemplate() 106 | } 107 | return ntr 108 | } 109 | func GetNotifyTemplateList() ([]notify.Template, error) { 110 | notifyTemplateList := notifyTemplateCache.GetAll() 111 | if len(notifyTemplateList) == 0 { 112 | err := refreshNotifyTemplate(context.Background()) 113 | if err != nil { 114 | return nil, err 115 | } 116 | notifyTemplateList = notifyTemplateCache.GetAll() 117 | } 118 | var result = make([]notify.Template, 0, len(notifyTemplateList)) 119 | for k, v := range notifyTemplateList { 120 | result = append(result, notify.Template{Type: k, Template: v}) 121 | } 122 | return result, nil 123 | } 124 | 125 | func GetNotifyTemplateByType(t string) (string, error) { 126 | if value, ok := notifyTemplateCache.Get(t); ok { 127 | return value, nil 128 | } 129 | err := refreshNotifyTemplate(context.Background()) 130 | if err != nil { 131 | return "", err 132 | } 133 | if value, ok := notifyTemplateCache.Get(t); ok { 134 | return value, nil 135 | } 136 | return "", fmt.Errorf("notify template not found") 137 | } 138 | func UpdateNotifyTemplate(ctx context.Context, nt *notify.Template) error { 139 | if notifyTemplateCache.Len() == 0 { 140 | refreshNotifyTemplate(context.Background()) 141 | } 142 | if err := NotifyTemplateRepo().Update(ctx, nt); err != nil { 143 | return err 144 | } 145 | notifyTemplateCache.Set(nt.Type, nt.Template) 146 | return nil 147 | } 148 | func refreshNotifyTemplate(ctx context.Context) error { 149 | notifyTemplateCache.Clear() 150 | notifyTemplates, err := NotifyTemplateRepo().List(ctx) 151 | if err != nil { 152 | return err 153 | } 154 | for _, t := range *notifyTemplates { 155 | notifyTemplateCache.Set(t.Type, t.Template) 156 | } 157 | return nil 158 | } 159 | -------------------------------------------------------------------------------- /internal/modules/share/share.go: -------------------------------------------------------------------------------- 1 | package share 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | "text/template" 12 | 13 | "github.com/bestruirui/bestsub/internal/config" 14 | "github.com/bestruirui/bestsub/internal/core/mihomo" 15 | "github.com/bestruirui/bestsub/internal/core/node" 16 | "github.com/bestruirui/bestsub/internal/database/op" 17 | "github.com/bestruirui/bestsub/internal/models/setting" 18 | "github.com/bestruirui/bestsub/internal/models/share" 19 | "github.com/bestruirui/bestsub/internal/modules/subcer" 20 | "github.com/bestruirui/bestsub/internal/utils" 21 | "github.com/bestruirui/bestsub/internal/utils/country" 22 | "github.com/google/go-querystring/query" 23 | ) 24 | 25 | func GenSubData(genConfigStr string, userAgent string, token string, extraQuery string) []byte { 26 | var genConfig share.GenConfig 27 | if err := json.Unmarshal([]byte(genConfigStr), &genConfig); err != nil { 28 | return nil 29 | } 30 | subUrlParam, _ := query.Values(genConfig.SubConverter) 31 | if genConfig.Proxy { 32 | subUrlParam.Add("config_proxy", op.GetSettingStr(setting.PROXY_URL)) 33 | } 34 | subUrlParam.Add("url", fmt.Sprintf("http://127.0.0.1:%d/api/v1/share/node/%s", config.Base().Server.Port, token)) 35 | subUrlParam.Add("remove_emoji", "false") 36 | subcer.RLock() 37 | defer subcer.RUnlock() 38 | requestUrl := fmt.Sprintf("%s/sub?%s&%s", subcer.GetBaseUrl(), subUrlParam.Encode(), extraQuery) 39 | client := mihomo.Default(false) 40 | if client == nil { 41 | return nil 42 | } 43 | request, err := http.NewRequest("GET", requestUrl, nil) 44 | if err != nil { 45 | return nil 46 | } 47 | request.Header.Set("User-Agent", userAgent) 48 | response, err := client.Do(request) 49 | if err != nil { 50 | return nil 51 | } 52 | defer response.Body.Close() 53 | body, err := io.ReadAll(response.Body) 54 | if err != nil { 55 | return nil 56 | } 57 | return body 58 | } 59 | 60 | func GenNodeData(config string) []byte { 61 | var genConfig share.GenConfig 62 | if err := json.Unmarshal([]byte(config), &genConfig); err != nil { 63 | return nil 64 | } 65 | nodes := node.GetByFilter(genConfig.Filter) 66 | var result bytes.Buffer 67 | result.Write(nodeData) 68 | tmpl, err := renameTemplate.Parse(genConfig.Rename) 69 | if err != nil { 70 | return nil 71 | } 72 | var newName bytes.Buffer 73 | for i, node := range *nodes { 74 | newName.Reset() 75 | result.Write(dash) 76 | subTags := op.GetSubTagsByID(context.Background(), node.Base.SubId) 77 | simpleInfo := renameTmpl{ 78 | SpeedUp: node.Info.SpeedUp.Average(), 79 | SpeedDown: node.Info.SpeedDown.Average(), 80 | Delay: uint32(node.Info.Delay.Average()), 81 | Risk: uint32(node.Info.Risk), 82 | Count: uint32(i + 1), 83 | Country: country.GetCountry(node.Info.Country), 84 | IP: utils.Uint32ToIP(node.Info.IP), 85 | SubName: op.GetSubNameByID(context.Background(), node.Base.SubId), 86 | SubTags: fmt.Sprintf("<%s>", strings.Join(subTags, "|")), 87 | SubTagsOrigin: subTags, 88 | } 89 | tmpl.Execute(&newName, simpleInfo) 90 | result.Write(rename(node.Base.Raw, newName.Bytes())) 91 | result.Write(newLine) 92 | } 93 | return result.Bytes() 94 | } 95 | 96 | func rename(raw []byte, newName []byte) []byte { 97 | idx := bytes.Index(raw, serverDelim) 98 | if idx < 0 { 99 | return raw 100 | } 101 | out := make([]byte, 0, len(name)+len(newName)+len(raw)-idx) 102 | out = append(out, name...) 103 | out = append(out, newName...) 104 | out = append(out, raw[idx:]...) 105 | return out 106 | } 107 | 108 | var ( 109 | name = []byte("{name: ") 110 | serverDelim = []byte(", server:") 111 | 112 | nodeData = []byte("proxies:\n") 113 | newLine = []byte("\n") 114 | dash = []byte(" - ") 115 | ) 116 | 117 | type renameTmpl struct { 118 | SpeedUp uint32 119 | SpeedDown uint32 120 | Delay uint32 121 | Risk uint32 122 | Country country.Country 123 | Count uint32 124 | IP string 125 | SubName string 126 | SubTags string 127 | SubTagsOrigin []string 128 | } 129 | 130 | var renameTemplate = template.New("node").Funcs(template.FuncMap{ 131 | "add": func(x, y uint32) uint32 { 132 | return x + y 133 | }, 134 | "sub": func(x, y uint32) uint32 { 135 | return x - y 136 | }, 137 | "div": func(x, y uint32) uint32 { 138 | if y == 0 { 139 | return 0 140 | } 141 | return x / y 142 | }, 143 | "mod": func(x, y uint32) uint32 { 144 | if y == 0 { 145 | return 0 146 | } 147 | return x % y 148 | }, 149 | }) 150 | -------------------------------------------------------------------------------- /internal/database/op/sub.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/bestruirui/bestsub/internal/database/interfaces" 9 | "github.com/bestruirui/bestsub/internal/models/setting" 10 | subModel "github.com/bestruirui/bestsub/internal/models/sub" 11 | "github.com/bestruirui/bestsub/internal/utils/cache" 12 | ) 13 | 14 | var subRepo interfaces.SubRepository 15 | var subCache = cache.New[uint16, subModel.Data](16) 16 | 17 | func SubRepo() interfaces.SubRepository { 18 | if subRepo == nil { 19 | subRepo = repo.Sub() 20 | } 21 | return subRepo 22 | } 23 | func GetSubList(ctx context.Context) ([]subModel.Data, error) { 24 | subList := subCache.GetAll() 25 | if len(subList) == 0 { 26 | err := refreshSubCache(context.Background()) 27 | if err != nil { 28 | return nil, err 29 | } 30 | subList = subCache.GetAll() 31 | } 32 | var result = make([]subModel.Data, 0, len(subList)) 33 | for _, v := range subList { 34 | result = append(result, v) 35 | } 36 | return result, nil 37 | } 38 | 39 | func GetSubByID(ctx context.Context, id uint16) (*subModel.Data, error) { 40 | if subCache.Len() == 0 { 41 | if err := refreshSubCache(ctx); err != nil { 42 | return nil, err 43 | } 44 | } 45 | if s, ok := subCache.Get(id); ok { 46 | return &s, nil 47 | } 48 | return nil, fmt.Errorf("sub not found") 49 | } 50 | func GetSubNameByID(ctx context.Context, id uint16) string { 51 | sub, err := GetSubByID(ctx, id) 52 | if err != nil { 53 | return "" 54 | } 55 | return sub.Name 56 | } 57 | func GetSubTagsByID(ctx context.Context, id uint16) []string { 58 | sub, err := GetSubByID(ctx, id) 59 | if err != nil { 60 | return []string{} 61 | } 62 | tags := make([]string, 0) 63 | err = json.Unmarshal([]byte(sub.Tags), &tags) 64 | if err != nil { 65 | return []string{} 66 | } 67 | return tags 68 | } 69 | func CreateSub(ctx context.Context, sub *subModel.Data) error { 70 | if subCache.Len() == 0 { 71 | if err := refreshSubCache(ctx); err != nil { 72 | return err 73 | } 74 | } 75 | if err := SubRepo().Create(ctx, sub); err != nil { 76 | return err 77 | } 78 | subCache.Set(sub.ID, *sub) 79 | return nil 80 | } 81 | 82 | func BatchCreateSub(ctx context.Context, subs []*subModel.Data) error { 83 | if subCache.Len() == 0 { 84 | if err := refreshSubCache(ctx); err != nil { 85 | return err 86 | } 87 | } 88 | if err := SubRepo().BatchCreate(ctx, subs); err != nil { 89 | return err 90 | } 91 | for _, sub := range subs { 92 | subCache.Set(sub.ID, *sub) 93 | } 94 | return nil 95 | } 96 | func UpdateSub(ctx context.Context, sub *subModel.Data) error { 97 | if subCache.Len() == 0 { 98 | if err := refreshSubCache(ctx); err != nil { 99 | return err 100 | } 101 | } 102 | oldSub, ok := subCache.Get(sub.ID) 103 | if !ok { 104 | return fmt.Errorf("sub not found") 105 | } 106 | sub.Result = oldSub.Result 107 | sub.CreatedAt = oldSub.CreatedAt 108 | if err := SubRepo().Update(ctx, sub); err != nil { 109 | return err 110 | } 111 | subCache.Set(sub.ID, *sub) 112 | return nil 113 | } 114 | func UpdateSubResult(ctx context.Context, id uint16, result subModel.Result) error { 115 | if subCache.Len() == 0 { 116 | if err := refreshSubCache(ctx); err != nil { 117 | return err 118 | } 119 | } 120 | sub, ok := subCache.Get(id) 121 | if !ok { 122 | return fmt.Errorf("sub not found") 123 | } 124 | var oldStatus subModel.Result 125 | json.Unmarshal([]byte(sub.Result), &oldStatus) 126 | 127 | result.Success += oldStatus.Success 128 | result.Fail += oldStatus.Fail 129 | if result.NodeNullCount != 0 { 130 | result.NodeNullCount += oldStatus.NodeNullCount 131 | } 132 | if (result.NodeNullCount > uint16(GetSettingInt(setting.SUB_DISABLE_AUTO))) && GetSettingInt(setting.SUB_DISABLE_AUTO) != 0 { 133 | sub.Enable = false 134 | } 135 | bytes, err := json.Marshal(result) 136 | if err != nil { 137 | return err 138 | } 139 | sub.Result = string(bytes) 140 | if err := SubRepo().Update(ctx, &sub); err != nil { 141 | return err 142 | } 143 | subCache.Set(id, sub) 144 | return nil 145 | } 146 | func DeleteSub(ctx context.Context, id uint16) error { 147 | if subCache.Len() == 0 { 148 | if err := refreshSubCache(ctx); err != nil { 149 | return err 150 | } 151 | } 152 | if err := SubRepo().Delete(ctx, id); err != nil { 153 | return err 154 | } 155 | subCache.Del(id) 156 | return nil 157 | } 158 | func refreshSubCache(ctx context.Context) error { 159 | subList, err := SubRepo().List(ctx) 160 | if err != nil { 161 | return err 162 | } 163 | for _, s := range *subList { 164 | subCache.Set(s.ID, s) 165 | } 166 | return nil 167 | } 168 | -------------------------------------------------------------------------------- /internal/server/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // Method represents HTTP methods 11 | type Method string 12 | 13 | const ( 14 | GET Method = "GET" 15 | POST Method = "POST" 16 | PUT Method = "PUT" 17 | DELETE Method = "DELETE" 18 | HEAD Method = "HEAD" 19 | OPTIONS Method = "OPTIONS" 20 | PATCH Method = "PATCH" 21 | ANY Method = "ANY" 22 | ) 23 | 24 | // GroupRouter represents a group of routes with shared path prefix and middlewares 25 | type GroupRouter struct { 26 | Path string 27 | Routes []*Route 28 | Middlewares []gin.HandlerFunc 29 | } 30 | 31 | // Global registry for route groups 32 | var registeredRouters []*GroupRouter 33 | 34 | // NewGroupRouter creates a new GroupRouter with the given path and automatically registers it. 35 | func NewGroupRouter(path string) *GroupRouter { 36 | router := &GroupRouter{ 37 | Path: path, 38 | Routes: make([]*Route, 0), 39 | } 40 | registeredRouters = append(registeredRouters, router) 41 | return router 42 | } 43 | 44 | // Use adds middlewares to the group. 45 | func (g *GroupRouter) Use(middlewares ...gin.HandlerFunc) *GroupRouter { 46 | g.Middlewares = append(g.Middlewares, middlewares...) 47 | return g 48 | } 49 | 50 | // AddRoute adds a route to the group. 51 | func (g *GroupRouter) AddRoute(route *Route) *GroupRouter { 52 | g.Routes = append(g.Routes, route) 53 | return g 54 | } 55 | 56 | // Route defines a single endpoint with its handlers and middlewares. 57 | type Route struct { 58 | Path string 59 | Method Method 60 | Handlers []gin.HandlerFunc 61 | Middlewares []gin.HandlerFunc 62 | } 63 | 64 | // NewRoute creates a new Route instance with the given path and method. 65 | func NewRoute(path string, method Method) *Route { 66 | return &Route{ 67 | Path: path, 68 | Method: method, 69 | Handlers: make([]gin.HandlerFunc, 0), 70 | } 71 | } 72 | 73 | // Handle adds handler functions to the route. 74 | func (r *Route) Handle(handlers ...gin.HandlerFunc) *Route { 75 | r.Handlers = append(r.Handlers, handlers...) 76 | return r 77 | } 78 | 79 | // Use adds middlewares to the route. 80 | func (r *Route) Use(middlewares ...gin.HandlerFunc) *Route { 81 | r.Middlewares = append(r.Middlewares, middlewares...) 82 | return r 83 | } 84 | 85 | // Validate checks if the route is valid 86 | func (r *Route) Validate() error { 87 | if len(r.Handlers) == 0 { 88 | return fmt.Errorf("route must have at least one handler") 89 | } 90 | return nil 91 | } 92 | 93 | // GetRouterCount returns the total count of registered routes 94 | func GetRouterCount() int { 95 | count := 0 96 | for _, router := range registeredRouters { 97 | count += len(router.Routes) 98 | } 99 | return count 100 | } 101 | 102 | // RegisterAll registers all globally registered route groups to the Gin engine 103 | func RegisterAll(engine *gin.Engine) error { 104 | for _, router := range registeredRouters { 105 | // Validate all routes in the group first 106 | for _, route := range router.Routes { 107 | if err := route.Validate(); err != nil { 108 | return fmt.Errorf("invalid route in group %s: %w", router.Path, err) 109 | } 110 | } 111 | 112 | // Create the route group 113 | group := engine.Group(router.Path, router.Middlewares...) 114 | 115 | // Register all routes in the group 116 | for _, route := range router.Routes { 117 | handlers := make([]gin.HandlerFunc, 0, len(route.Middlewares)+len(route.Handlers)) 118 | handlers = append(handlers, route.Middlewares...) 119 | handlers = append(handlers, route.Handlers...) 120 | 121 | registerRoute(group, route.Method, route.Path, handlers) 122 | } 123 | } 124 | registeredRouters = nil 125 | return nil 126 | } 127 | 128 | // registerRoute registers a single route to a Gin route group. 129 | func registerRoute(group *gin.RouterGroup, method Method, path string, handlers []gin.HandlerFunc) { 130 | if len(handlers) == 0 { 131 | return 132 | } 133 | 134 | if path != "" { 135 | if !strings.HasPrefix(path, "/") { 136 | path = "/" + path 137 | } 138 | } 139 | 140 | switch method { 141 | case GET: 142 | group.GET(path, handlers...) 143 | case POST: 144 | group.POST(path, handlers...) 145 | case PUT: 146 | group.PUT(path, handlers...) 147 | case DELETE: 148 | group.DELETE(path, handlers...) 149 | case HEAD: 150 | group.HEAD(path, handlers...) 151 | case OPTIONS: 152 | group.OPTIONS(path, handlers...) 153 | case PATCH: 154 | group.PATCH(path, handlers...) 155 | case ANY: 156 | group.Any(path, handlers...) 157 | default: 158 | group.GET(path, handlers...) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /internal/database/client/sqlite/setting.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/bestruirui/bestsub/internal/database/interfaces" 8 | "github.com/bestruirui/bestsub/internal/models/setting" 9 | "github.com/bestruirui/bestsub/internal/utils/log" 10 | ) 11 | 12 | func (db *DB) Setting() interfaces.SettingRepository { 13 | return &SystemConfigRepository{db: db} 14 | } 15 | 16 | type SystemConfigRepository struct { 17 | db *DB 18 | } 19 | 20 | func (r *SystemConfigRepository) Create(ctx context.Context, configs *[]setting.Setting) error { 21 | if configs == nil || len(*configs) == 0 { 22 | return nil 23 | } 24 | tx, err := r.db.db.BeginTx(ctx, nil) 25 | if err != nil { 26 | return fmt.Errorf("failed to begin transaction: %w", err) 27 | } 28 | defer tx.Rollback() 29 | 30 | query := `INSERT INTO setting (key, value) 31 | VALUES (?, ?)` 32 | 33 | stmt, err := tx.PrepareContext(ctx, query) 34 | if err != nil { 35 | return fmt.Errorf("failed to prepare statement: %w", err) 36 | } 37 | defer stmt.Close() 38 | 39 | for _, config := range *configs { 40 | _, err := stmt.ExecContext(ctx, 41 | config.Key, 42 | config.Value, 43 | ) 44 | if err != nil { 45 | return fmt.Errorf("failed to create system config key '%s': %w", config.Key, err) 46 | } 47 | } 48 | 49 | if err = tx.Commit(); err != nil { 50 | return fmt.Errorf("failed to commit transaction: %w", err) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (r *SystemConfigRepository) GetAll(ctx context.Context) (*[]setting.Setting, error) { 57 | log.Debugf("GetAll") 58 | query := `SELECT key, value 59 | FROM setting ORDER BY key` 60 | 61 | rows, err := r.db.db.QueryContext(ctx, query) 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to query all configs: %w", err) 64 | } 65 | defer rows.Close() 66 | 67 | var configs []setting.Setting 68 | for rows.Next() { 69 | var config setting.Setting 70 | if err := rows.Scan( 71 | &config.Key, 72 | &config.Value, 73 | ); err != nil { 74 | return nil, fmt.Errorf("failed to scan config: %w", err) 75 | } 76 | configs = append(configs, config) 77 | } 78 | 79 | if err = rows.Err(); err != nil { 80 | return nil, fmt.Errorf("failed to iterate configs: %w", err) 81 | } 82 | 83 | return &configs, nil 84 | } 85 | 86 | func (r *SystemConfigRepository) GetByKey(ctx context.Context, keys []string) (*[]setting.Setting, error) { 87 | log.Debugf("GetByKey: %v", keys) 88 | if len(keys) == 0 { 89 | return &[]setting.Setting{}, nil 90 | } 91 | 92 | args := make([]interface{}, len(keys)) 93 | inClause := "" 94 | for i, key := range keys { 95 | if i > 0 { 96 | inClause += "," 97 | } 98 | inClause += "?" 99 | args[i] = key 100 | } 101 | query := `SELECT key, value 102 | FROM setting WHERE key IN (` + inClause + `) ORDER BY key` 103 | 104 | rows, err := r.db.db.QueryContext(ctx, query, args...) 105 | if err != nil { 106 | return nil, fmt.Errorf("failed to query configs by keys: %w", err) 107 | } 108 | defer rows.Close() 109 | 110 | var configs []setting.Setting 111 | for rows.Next() { 112 | var config setting.Setting 113 | if err := rows.Scan( 114 | &config.Key, 115 | &config.Value, 116 | ); err != nil { 117 | return nil, fmt.Errorf("failed to scan config: %w", err) 118 | } 119 | configs = append(configs, config) 120 | } 121 | 122 | if err = rows.Err(); err != nil { 123 | return nil, fmt.Errorf("failed to iterate configs: %w", err) 124 | } 125 | 126 | return &configs, nil 127 | } 128 | 129 | func (r *SystemConfigRepository) Update(ctx context.Context, data *[]setting.Setting) error { 130 | log.Debugf("Update: %v", data) 131 | if data == nil || len(*data) == 0 { 132 | return nil 133 | } 134 | 135 | tx, err := r.db.db.BeginTx(ctx, nil) 136 | if err != nil { 137 | return fmt.Errorf("failed to begin transaction: %w", err) 138 | } 139 | defer tx.Rollback() 140 | 141 | query := `UPDATE setting SET value = ? WHERE key = ?` 142 | 143 | stmt, err := tx.PrepareContext(ctx, query) 144 | if err != nil { 145 | return fmt.Errorf("failed to prepare statement: %w", err) 146 | } 147 | defer stmt.Close() 148 | for _, updateData := range *data { 149 | result, err := stmt.ExecContext(ctx, 150 | updateData.Value, 151 | updateData.Key, 152 | ) 153 | if err != nil { 154 | return fmt.Errorf("failed to update system config key '%s': %w", updateData.Key, err) 155 | } 156 | 157 | rowsAffected, err := result.RowsAffected() 158 | if err != nil { 159 | return fmt.Errorf("failed to get rows affected for key '%s': %w", updateData.Key, err) 160 | } 161 | 162 | if rowsAffected == 0 { 163 | return fmt.Errorf("no config found with key '%s'", updateData.Key) 164 | } 165 | } 166 | 167 | if err = tx.Commit(); err != nil { 168 | return fmt.Errorf("failed to commit transaction: %w", err) 169 | } 170 | 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /internal/database/op/share.go: -------------------------------------------------------------------------------- 1 | package op 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/bestruirui/bestsub/internal/database/interfaces" 10 | "github.com/bestruirui/bestsub/internal/models/share" 11 | "github.com/bestruirui/bestsub/internal/utils/cache" 12 | "github.com/bestruirui/bestsub/internal/utils/generic" 13 | "github.com/bestruirui/bestsub/internal/utils/log" 14 | ) 15 | 16 | var shareRepo interfaces.ShareRepository 17 | var shareCache = cache.New[uint16, share.Data](16) 18 | 19 | var pendingUpdates = &generic.MapOf[uint16, bool]{} 20 | var startOnce sync.Once 21 | 22 | func ShareRepo() interfaces.ShareRepository { 23 | if shareRepo == nil { 24 | shareRepo = repo.Share() 25 | } 26 | return shareRepo 27 | } 28 | func GetShareList(ctx context.Context) ([]share.Data, error) { 29 | shareList := shareCache.GetAll() 30 | if len(shareList) == 0 { 31 | err := refreshShareCache(context.Background()) 32 | if err != nil { 33 | return nil, err 34 | } 35 | shareList = shareCache.GetAll() 36 | } 37 | var result = make([]share.Data, 0, len(shareList)) 38 | for _, v := range shareList { 39 | result = append(result, v) 40 | } 41 | return result, nil 42 | } 43 | 44 | func GetShareByID(ctx context.Context, id uint16) (*share.Data, error) { 45 | if shareCache.Len() == 0 { 46 | if err := refreshShareCache(ctx); err != nil { 47 | return nil, err 48 | } 49 | } 50 | if s, ok := shareCache.Get(id); ok { 51 | return &s, nil 52 | } 53 | return nil, fmt.Errorf("share not found") 54 | } 55 | func GetShareByToken(ctx context.Context, token string) (*share.Data, error) { 56 | if shareCache.Len() == 0 { 57 | if err := refreshShareCache(ctx); err != nil { 58 | return nil, err 59 | } 60 | } 61 | for _, s := range shareCache.GetAll() { 62 | if s.Token == token { 63 | return &s, nil 64 | } 65 | } 66 | return nil, fmt.Errorf("share not found") 67 | } 68 | func CreateShare(ctx context.Context, share *share.Data) error { 69 | if shareCache.Len() == 0 { 70 | if err := refreshShareCache(ctx); err != nil { 71 | return err 72 | } 73 | } 74 | if err := ShareRepo().Create(ctx, share); err != nil { 75 | return err 76 | } 77 | shareCache.Set(share.ID, *share) 78 | return nil 79 | } 80 | func UpdateShare(ctx context.Context, share *share.Data) error { 81 | if shareCache.Len() == 0 { 82 | if err := refreshShareCache(ctx); err != nil { 83 | return err 84 | } 85 | } 86 | oldShare, ok := shareCache.Get(share.ID) 87 | if !ok { 88 | return fmt.Errorf("share not found") 89 | } 90 | share.AccessCount = oldShare.AccessCount 91 | if err := ShareRepo().Update(ctx, share); err != nil { 92 | return err 93 | } 94 | shareCache.Set(share.ID, *share) 95 | return nil 96 | } 97 | 98 | func UpdateShareAccessCount(ctx context.Context, id uint16) error { 99 | if shareCache.Len() == 0 { 100 | if err := refreshShareCache(ctx); err != nil { 101 | return err 102 | } 103 | } 104 | share, ok := shareCache.Get(id) 105 | if !ok { 106 | return fmt.Errorf("share not found") 107 | } 108 | share.AccessCount++ 109 | shareCache.Set(id, share) 110 | 111 | pendingUpdates.Store(id, true) 112 | 113 | startScheduleUpdateAccessCount() 114 | 115 | return nil 116 | } 117 | 118 | func DeleteShare(ctx context.Context, id uint16) error { 119 | if shareCache.Len() == 0 { 120 | if err := refreshShareCache(ctx); err != nil { 121 | return err 122 | } 123 | } 124 | if err := ShareRepo().Delete(ctx, id); err != nil { 125 | return err 126 | } 127 | shareCache.Del(id) 128 | return nil 129 | } 130 | 131 | func refreshShareCache(ctx context.Context) error { 132 | shareList, err := ShareRepo().List(ctx) 133 | if err != nil { 134 | return err 135 | } 136 | for _, s := range *shareList { 137 | shareCache.Set(s.ID, s) 138 | } 139 | return nil 140 | } 141 | 142 | func startScheduleUpdateAccessCount() { 143 | startOnce.Do(func() { 144 | ticker := time.NewTicker(60 * time.Second) 145 | go func() { 146 | defer ticker.Stop() 147 | for range ticker.C { 148 | updateAccessCount() 149 | } 150 | }() 151 | }) 152 | } 153 | 154 | var updateDataBuffer []share.UpdateAccessCountDB 155 | 156 | func updateAccessCount() { 157 | updateDataBuffer = updateDataBuffer[:0] 158 | 159 | pendingUpdates.Range(func(id uint16, _ bool) bool { 160 | if shareData, ok := shareCache.Get(id); ok { 161 | updateDataBuffer = append(updateDataBuffer, share.UpdateAccessCountDB{ 162 | ID: id, 163 | AccessCount: shareData.AccessCount, 164 | }) 165 | } 166 | return true 167 | }) 168 | if len(updateDataBuffer) == 0 { 169 | return 170 | } 171 | if err := ShareRepo().UpdateAccessCount(context.Background(), &updateDataBuffer); err != nil { 172 | log.Errorf("failed to update share access count: %v", err) 173 | return 174 | } 175 | for _, data := range updateDataBuffer { 176 | pendingUpdates.Delete(data.ID) 177 | } 178 | } 179 | --------------------------------------------------------------------------------