├── 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 |
--------------------------------------------------------------------------------