Refactor(frontend): Refactor frontend using Lit

Refactor(database): use gorm+sqlite instead of bbolt
Feat: Add delete short link functionality
Fix: Load correct configuration template during meta config conversion
This commit is contained in:
2025-10-19 03:13:10 +11:00
parent 1e8a79c2d2
commit 86b74f30e7
33 changed files with 3612 additions and 1064 deletions

View File

@@ -23,6 +23,11 @@ jobs:
with:
install-only: true
- name: setup node
uses: actions/setup-node@v6
with:
node-version: "latest"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:

2
.gitignore vendored
View File

@@ -1,11 +1,9 @@
.idea
.vscode
dist
subs
logs
data
.env
.vscode/settings.json
config.yaml
config.yml
config.json

View File

@@ -15,6 +15,12 @@ builds:
- -s -w -X github.com/bestnite/sub2clash/constant.Version={{ .Version }}
flags:
- -trimpath
hooks:
pre:
- cmd: npm install
dir: server/frontend
- cmd: npm run build
dir: server/frontend
archives:
- formats: ["tar.gz"]
format_overrides:

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "debug",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": [],
"preLaunchTask": "build frontend"
}
]
}

21
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build frontend",
"type": "shell",
"command": "npm",
"args": [
"run",
"build"
],
"group": {
"kind": "build",
"isDefault": true
},
"options": {
"cwd": "${workspaceFolder}/server/frontend"
}
}
]
}

View File

@@ -1,7 +1,16 @@
FROM node:lts-alpine as frontend_builder
WORKDIR /app/server/frontend
COPY server/frontend/package*.json ./
RUN npm install
COPY server/frontend .
ARG version
RUN APP_VERSION=${version} npm run build
FROM golang:1.25 as builder
LABEL authors="nite07"
WORKDIR /app
COPY . .
COPY --from=frontend_builder /app/server/frontend/dist /app/server/frontend/dist
RUN go mod download
ARG version
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/bestnite/sub2clash/constant.Version=${version}" -o sub2clash .
@@ -9,4 +18,4 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X github.com/bestnite/sub
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/sub2clash /app/sub2clash
ENTRYPOINT ["/app/sub2clash"]
ENTRYPOINT ["/app/sub2clash"]

View File

@@ -105,35 +105,6 @@
| rule | string | 规则 |
| prepend | bool | 如果为 `true` 规则将被添加到规则列表顶部,否则添加到规则列表底部 |
#### `POST /short`
获取短链Content-Type 为 `application/json`
具体参考使用可以参考 [api\templates\index.html](api/static/index.html)
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| --------- | ------ | -------- | ------ | ------------------------- |
| url | string | 是 | - | 需要转换的 Query 参数部分 |
| password | string | 否 | - | 短链密码 |
#### `GET /s/:hash`
短链跳转
`hash` 为动态路由参数,可以通过 `/short` 接口获取
| Query 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| ---------- | ------ | -------- | ------ | -------- |
| password | string | 否 | - | 短链密码 |
#### `PUT /short`
更新短链Content-Type 为 `application/json`
| Body 参数 | 类型 | 是否必须 | 默认值 | 说明 |
| --------- | ------ | -------- | ------ | ------------------------- |
| url | string | 是 | - | 需要转换的 Query 参数部分 |
| password | string | 否 | - | 短链密码 |
| hash | string | 是 | - | 短链 hash |
### 模板
可以通过变量自定义模板中的策略组代理节点

View File

@@ -1,72 +1,77 @@
package database
import (
"encoding/json"
"context"
"errors"
"os"
"path/filepath"
"time"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/model"
"go.etcd.io/bbolt"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *bbolt.DB
func ConnectDB() error {
path := filepath.Join("data", "sub2clash.db")
db, err := bbolt.Open(path, 0600, nil)
if err != nil {
return common.NewDatabaseConnectError(err)
}
DB = db
return db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("ShortLinks"))
if err != nil {
return common.NewDatabaseConnectError(err)
}
return nil
})
type Database struct {
db *gorm.DB
}
func FindShortLinkByHash(hash string) (*model.ShortLink, error) {
var shortLink model.ShortLink
err := DB.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("ShortLinks"))
v := b.Get([]byte(hash))
if v == nil {
return common.NewRecordNotFoundError("ShortLink", hash)
}
return json.Unmarshal(v, &shortLink)
})
if err != nil {
func ConnectDB() (*Database, error) {
path := filepath.Join("data", "sub2clash.db")
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return &shortLink, nil
}
func SaveShortLink(shortLink *model.ShortLink) error {
return DB.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("ShortLinks"))
encoded, err := json.Marshal(shortLink)
if err != nil {
return err
}
return b.Put([]byte(shortLink.Hash), encoded)
})
}
func CheckShortLinkHashExists(hash string) (bool, error) {
exists := false
err := DB.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("ShortLinks"))
v := b.Get([]byte(hash))
exists = v != nil
return nil
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
Logger: logger.Discard,
})
if err != nil {
return nil, common.NewDatabaseConnectError(err)
}
if err = db.AutoMigrate(&model.ShortLink{}); err != nil {
return nil, err
}
return &Database{
db: db,
}, nil
}
func (d *Database) FindShortLinkByID(id string) (model.ShortLink, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return gorm.G[model.ShortLink](d.db).Where("id = ?", id).First(ctx)
}
func (d *Database) CreateShortLink(shortLink *model.ShortLink) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return gorm.G[model.ShortLink](d.db).Create(ctx, shortLink)
}
func (d *Database) UpdataShortLink(id string, name string, value any) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Update(ctx, name, value)
return err
}
func (d *Database) CheckShortLinkIDExists(id string) (bool, error) {
_, err := d.FindShortLinkByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return exists, nil
return true, nil
}
func (d *Database) DeleteShortLink(id string) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := gorm.G[model.ShortLink](d.db).Where("id = ?", id).Delete(ctx)
return err
}

29
go.mod
View File

@@ -4,13 +4,14 @@ go 1.25
require (
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
github.com/metacubex/mihomo v1.19.10
github.com/spf13/viper v1.20.1
go.etcd.io/bbolt v1.3.9
go.uber.org/zap v1.27.0
golang.org/x/text v0.22.0
golang.org/x/text v0.30.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.31.0
resty.dev/v3 v3.0.0-beta.3
)
@@ -28,6 +29,7 @@ require (
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.3 // indirect
github.com/enfein/mieru/v3 v3.13.0 // indirect
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
@@ -38,6 +40,7 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -51,9 +54,12 @@ require (
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
@@ -101,6 +107,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.50.0 // indirect
@@ -130,14 +137,18 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.24.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)

68
go.sum
View File

@@ -19,9 +19,6 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@@ -35,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc=
github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/enfein/mieru/v3 v3.13.0 h1:eGyxLGkb+lut9ebmx+BGwLJ5UMbEc/wGIYO0AXEKy98=
@@ -61,6 +60,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
@@ -101,15 +104,20 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/tink/go v1.6.1 h1:t7JHqO8Ath2w2ig5vjwQYJzhGEZymedQc90lQXUBa4I=
github.com/google/tink/go v1.6.1/go.mod h1:IGW53kTgag+st5yPhKKwJ6u2l+SSp5/v9XF7spovjlY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905 h1:q3OEI9RaN/wwcx+qgGo6ZaoJkCiDYe/gjDLfq7lQQF4=
github.com/insomniacslk/dhcp v0.0.0-20250109001534-8abf58130905/go.mod h1:VvGYjkZoJyKqlmT1yzakUs4mfKMNB0XdODP0+rdml6k=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
@@ -226,6 +234,9 @@ github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
@@ -297,8 +308,6 @@ gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 h1:UNrDfkQqiE
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7/go.mod h1:E+rxHvJG9H6PUdzq9NRG6csuLN3XUx98BfGOVWNYnXs=
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiVfzQttTuxanPIT43FtkkCFypIod8LHo=
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
@@ -314,27 +323,26 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e h1:I88y4caeGeuDQxgdoFPUq097j7kNfw6uvuiNxUBfcBk=
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -344,18 +352,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
@@ -368,8 +376,18 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=

View File

@@ -5,7 +5,6 @@ import (
"io"
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/common/database"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/logger"
"github.com/bestnite/sub2clash/server"
@@ -29,10 +28,6 @@ func init() {
logger.Logger.Panic("load config failed", zap.Error(err))
}
err = database.ConnectDB()
if err != nil {
logger.Logger.Panic("database connect failed", zap.Error(err))
}
logger.Logger.Info("database connect success")
}

View File

@@ -1,8 +1,8 @@
package model
type ShortLink struct {
Hash string
Url string
ID string `gorm:"unique"`
Config ConvertConfig `gorm:"serializer:json"`
Password string
LastRequestTime int64
}

24
server/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sub2clash</title>
<link rel="stylesheet" href="./src/index.css" />
<script type="module" src="/src/app.ts"></script>
</head>
<body>
<sub2clash-app>
</sub2clash-app>
</body>
</html>

2030
server/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "sub2clash-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.14",
"axios": "^1.12.2",
"daisyui": "^5.3.7",
"lit": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^7.1.7"
}
}

712
server/frontend/src/app.ts Normal file
View File

@@ -0,0 +1,712 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import globalStyles from "./index.css?inline";
import { type Config, type Rule, type RuleProvider } from "./interface.js";
import axios, { AxiosError } from "axios";
import { base64EncodeUnicode, base64decodeUnicode } from "./utils.js";
import "./components/rule-provider-input.js";
import "./components/rule-input.js";
import "./components/rename-input.js";
@customElement("sub2clash-app")
export class Sub2clashApp extends LitElement {
static styles = [unsafeCSS(globalStyles)];
private _config: Config = {
clashType: 2,
subscriptions: [],
proxies: [],
refresh: false,
autoTest: false,
lazy: false,
ignoreCountryGroup: false,
useUDP: false,
template: "",
sort: "nameasc",
remove: "",
nodeList: false,
ruleProviders: [],
replace: undefined,
rules: [],
};
@state()
set config(value: Config) {
console.log(JSON.stringify(value));
if (
(value.subscriptions == null || value.subscriptions.length == 0) &&
(value.proxies == null || value.proxies.length == 0)
) {
this.configUrl = "";
return;
}
const oldValue = this._config;
this.configUrl = `${
window.location.origin
}${window.location.pathname.replace(
/\/$/,
""
)}/convert/${base64EncodeUnicode(JSON.stringify(value))
.replace(/\+/g, "-")
.replace(/\//g, "_")}`;
this._config = value;
this.requestUpdate("config", oldValue);
}
get config(): Config {
return this._config;
}
@state({
hasChanged(value: boolean) {
localStorage.setItem("theme", value ? "dark" : "light");
document
.querySelector("html")
?.setAttribute("data-theme", value ? "dark" : "light");
return true;
},
})
darkTheme: boolean = this.initTheme();
initTheme(): boolean {
const savedTheme = localStorage.getItem("theme");
if (savedTheme != null) {
return savedTheme === "dark" ? true : false;
}
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
return prefersDark;
}
@state()
reverseUrl: string = "";
@state()
dialogMessage: string = "";
@state()
dialogTitle: string = "";
@query("dialog#my_modal")
dialog!: HTMLDialogElement;
showDialog(title: string, message: string): void {
if (title.trim() === "") {
title = "警告";
}
this.dialogTitle = title;
this.dialogMessage = message;
this.dialog.showModal();
}
@state()
configUrl: string = "";
@state()
shortLinkID: string = "";
@state()
shortLinkPasswd: string = "";
async copyToClipboard(content: string, e: HTMLButtonElement) {
try {
await navigator.clipboard.writeText(content);
let text = e.textContent;
e.addEventListener("mouseout", function () {
e.textContent = text;
});
e.textContent = "复制成功";
} catch (err) {
console.error("复制到剪贴板失败:", err);
}
}
generateShortLink() {
if (this.configUrl === "") {
this.showDialog("", "还未填写配置");
return;
}
axios
.post(
"./short",
{
config: this.config,
password: this.shortLinkPasswd,
id: this.shortLinkID,
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
// 设置返回的短链ID和密码
this.shortLinkID = response.data.id;
this.shortLinkPasswd = response.data.password;
})
.catch((error) => {
if (error.response && error.response.data) {
this.showDialog("", "生成短链失败:" + error.response.data);
} else {
this.showDialog("", "生成短链失败");
}
});
}
updateShortLink() {
if (this.shortLinkID.trim() === "") {
this.showDialog("", "请输入ID");
return;
}
if (this.shortLinkPasswd.trim() === "") {
this.showDialog("", "请输入密码");
return;
}
if (this.configUrl === "") {
this.showDialog("", "还未填写配置");
return;
}
axios
.put(
"./short",
{
id: this.shortLinkID,
config: this.config,
password: this.shortLinkPasswd,
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then(() => {
this.showDialog("成功", "更新成功");
})
.catch((error) => {
if (error.response && error.response.status === 401) {
this.showDialog("", "密码错误");
} else if (error.response && error.response.data) {
this.showDialog("", "更新短链失败:" + error.response.data);
} else {
this.showDialog("", "更新短链失败");
}
});
}
deleteShortLink() {
if (this.shortLinkID.trim() === "") {
this.showDialog("", "请输入ID");
return;
}
if (this.shortLinkPasswd.trim() === "") {
this.showDialog("", "请输入密码");
return;
}
const params = new URLSearchParams();
params.append("password", this.shortLinkPasswd);
axios
.delete(`./short/${this.shortLinkID}?${params.toString()}`, {
headers: {
"Content-Type": "application/json",
},
})
.then(() => {
this.showDialog("成功", "删除成功");
})
.catch((error) => {
if (error.response && error.response.status === 401) {
this.showDialog("", "短链不存在或密码错误");
} else if (error.response && error.response.data) {
this.showDialog("", "删除短链失败:" + error.response.data);
} else {
this.showDialog("", "删除短链失败");
}
});
}
getRawConfigFromShortLink() {
const s = this.reverseUrl.split("/s/");
if (s.length != 2) {
this.showDialog("", "解析失败");
return;
}
axios
.get(`./short/${s[1]}`)
.then((resp) => {
this.config = resp.data;
})
.catch((err: AxiosError) => {
if (err.response && err.response.status == 401) {
this.showDialog("", "短链不存在或密码错误");
} else if (err.response && err.response.data) {
this.showDialog("", "获取配置失败:" + err.response.data);
} else {
this.showDialog("", "获取配置失败");
}
});
}
parseConfig() {
if (this.reverseUrl.trim() === "") {
this.showDialog("", "无法解析,链接为空");
}
if (this.reverseUrl.indexOf("/s/") != -1) {
this.getRawConfigFromShortLink();
return;
}
let url = new URL(this.reverseUrl);
const pathSections = url.pathname.split("/");
if (pathSections.length < 2) {
this.showDialog("", "无法解析,链接格式错误");
}
if (pathSections[pathSections.length - 2] == "convert") {
let base64Data = pathSections[pathSections.length - 1];
base64Data = base64Data.replace(/-/g, "+").replace(/_/g, "/");
try {
const configData = base64decodeUnicode(base64Data);
this.config = JSON.parse(configData) as Config;
} catch (e: any) {
this.showDialog("", "无法解析 Base64配置格式错误");
return;
}
} else {
this.showDialog("", "无法解析,链接格式错误");
}
}
render() {
return html`
<dialog id="my_modal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">${this.dialogTitle}</h3>
<p class="py-4">${this.dialogMessage}</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">关闭</button>
</form>
</div>
</div>
</dialog>
<div class="max-w-4xl mx-auto p-4 flex flex-col items-center">
<form class="w-full max-w-2xl bg-base-100">
<fieldset class="fieldset mb-6">
<div class="flex flex-row justify-between items-center my-6">
<legend
class="fieldset-legend text-2xl font-semibold inline-block m-0 p-0">
sub2clash
</legend>
<label class="swap swap-rotate h-7 w-7">
<!-- this hidden checkbox controls the state -->
<input
type="checkbox"
class="theme-controller"
.checked="${!this.darkTheme}"
@change="${() => (this.darkTheme = !this.darkTheme)}" />
<!-- sun icon -->
<svg
class="swap-off h-7 w-7 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<!-- moon icon -->
<svg
class="swap-on h-7 w-7 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>
</div>
<!-- Input URL -->
<div class="form-control mb-5">
<label class="label mb-1 pl-1">解析链接</label>
<div class="join w-full">
<input
class="input input-bordered w-full join-item"
type="text"
@change="${(e: Event) => {
this.reverseUrl = (e.target as HTMLInputElement).value;
}}"
placeholder="通过生成的链接重新填写下方设置" />
<button
class="btn btn-primary join-item"
@click="${this.parseConfig}"
type="button">
解析
</button>
</div>
</div>
<!-- API Endpoint -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="endpoint">客户端类型</label>
<select
class="select select-bordered w-full"
name="endpoint"
.value="${this.config.clashType == 1 ? "1" : "2"}"
@change="${(e: Event) => {
this.config = {
...this.config,
clashType: Number((e.target as HTMLInputElement).value),
};
}}">
<option value="1">Clash</option>
<option value="2" selected>Clash.Meta</option>
</select>
</div>
<!-- Template -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="template">模板链接</label>
<input
class="input input-bordered w-full"
name="template"
placeholder="输入模板链接"
type="text"
.value="${this.config.template ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
template: (e.target as HTMLInputElement).value,
};
}}" />
</div>
<!-- Subscription Link -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="sub">订阅链接</label>
<div>
<textarea
class="textarea textarea-bordered h-24 w-full"
name="sub"
placeholder="每行输入一个订阅链接"
.value="${this.config.subscriptions
? this.config.subscriptions.join("\n")
: ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
subscriptions: (e.target as HTMLInputElement).value
.split("\n")
.filter((e) => e.trim() !== ""),
};
}}"></textarea>
</div>
</div>
<!-- Proxy Link -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="proxy">节点分享链接</label>
<div>
<textarea
class="textarea textarea-bordered h-24 w-full"
name="proxy"
placeholder="每行输入一个节点分享链接"
.value="${this.config.proxies
? this.config.proxies.join("\n")
: ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
proxies: (e.target as HTMLInputElement).value
.split("\n")
.filter((e) => e.trim() !== ""),
};
}}"></textarea>
</div>
</div>
<!-- User Agent -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="user-agent">UA 标识</label>
<div>
<textarea
class="textarea textarea-bordered h-20 w-full"
name="user-agent"
placeholder="用于获取订阅的 http 请求中的 User-Agent 标识"
.value="${this.config.userAgent ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
userAgent: (e.target as HTMLInputElement).value,
};
}}"></textarea>
</div>
</div>
<!-- Sort -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="sort">
国家策略组排序规则
</label>
<select
class="select select-bordered w-full"
name="sort"
.value="${this.config.sort ?? "nameasc"}"
@change="${(e: Event) => {
this.config = {
...this.config,
sort: (e.target as HTMLInputElement).value,
};
}}">
<option value="nameasc">名称(升序)</option>
<option value="namedesc">名称(降序)</option>
<option value="sizeasc">节点数量(升序)</option>
<option value="sizedesc">节点数量(降序)</option>
</select>
</div>
<!-- Remove -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1" for="remove">
<span class="label-text">排除节点</span>
</label>
<input
class="input input-bordered w-full"
type="text"
name="remove"
placeholder="正则表达式"
.value="${this.config.remove ?? ""}"
@change="${(e: Event) => {
this.config = {
...this.config,
remove: (e.target as HTMLInputElement).value,
};
}}" />
</div>
<!-- Checkboxes -->
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="refresh"
class="checkbox"
.checked="${this.config.refresh ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
refresh: (e.target as HTMLInputElement).checked,
};
}}" />
强制重新获取订阅
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="nodeList"
class="checkbox"
.checked="${this.config.nodeList ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
nodeList: (e.target as HTMLInputElement).checked,
};
}}" />
输出为 Node List
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="autoTest"
class="checkbox"
.checked="${this.config.autoTest ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
autoTest: (e.target as HTMLInputElement).checked,
};
}}" />
国家策略组自动测速
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="lazy"
class="checkbox"
.checked="${this.config.lazy ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
lazy: (e.target as HTMLInputElement).checked,
};
}}" />
自动测速启用 lazy 模式
</label>
</div>
<div class="form-control mb-3">
<label class="label cursor-pointer">
<input
type="checkbox"
name="igcg"
class="checkbox"
.checked="${this.config.ignoreCountryGroup ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
ignoreCountryGroup: (e.target as HTMLInputElement)
.checked,
};
}}" />
不输出国家策略组
</label>
</div>
<div class="form-control mb-5">
<label class="label cursor-pointer">
<input
type="checkbox"
name="useUDP"
class="checkbox"
.checked="${this.config.useUDP ?? false}"
@change="${(e: Event) => {
this.config = {
...this.config,
useUDP: (e.target as HTMLInputElement).checked,
};
}}" />
使用 UDP
</label>
</div>
<rule-provider-input
@change="${(e: CustomEvent<Array<RuleProvider>>) => {
this.config = {
...this.config,
ruleProviders: e.detail,
};
}}"></rule-provider-input>
<rule-input
@change="${(e: CustomEvent<Array<Rule>>) => {
this.config = {
...this.config,
rules: e.detail,
};
}}"></rule-input>
<rename-input
@change="${(e: CustomEvent<{ [key: string]: string }>) => {
this.config = {
...this.config,
replace: e.detail,
};
}}"></rename-input>
</fieldset>
<fieldset class="fieldset mb-8">
<legend
class="fieldset-legend text-2xl font-semibold mb-4 text-center">
输出配置
</legend>
<!-- Display the API Link -->
<div class="form-control mb-5">
<div class="join w-full mb-2">
<input
class="input input-bordered w-full join-item cursor-not-allowed"
type="text"
placeholder="链接"
.value="${this.configUrl}"
readonly />
<button
class="btn btn-primary join-item"
@click="${(e: Event) => {
this.copyToClipboard(
this.configUrl,
e.target as HTMLButtonElement
);
}}"
type="button">
复制链接
</button>
</div>
</div>
<div class="form-control mb-2">
<div class="join w-full">
<input
class="input input-bordered w-1/2 join-item"
type="text"
placeholder="ID可选"
.value="${this.shortLinkID}"
@change="${(e: Event) => {
this.shortLinkID = (e.target as HTMLInputElement).value;
}}" />
<input
class="input input-bordered w-1/2 join-item"
type="text"
placeholder="密码"
.value="${this.shortLinkPasswd}"
@change="${(e: Event) => {
this.shortLinkPasswd = (e.target as HTMLInputElement).value;
}}" />
<button
class="btn btn-primary join-item"
type="button"
@click="${this.generateShortLink}">
生成短链
</button>
<button
class="btn btn-primary join-item"
@click="${this.updateShortLink}"
type="button">
更新短链
</button>
<button
class="btn btn-primary join-item"
@click="${this.deleteShortLink}"
type="button">
删除短链
</button>
<button
class="btn btn-primary join-item"
type="button"
@click="${(e: Event) => {
this.copyToClipboard(
`${window.location.origin}${window.location.pathname}s/${this.shortLinkID}?password=${this.shortLinkPasswd}`,
e.target as HTMLButtonElement
);
}}">
复制短链
</button>
</div>
</div>
</fieldset>
</form>
</div>
<footer class="footer footer-horizontal footer-center mb-8">
<aside>
<p>
Powered by
<a class="link" href="https://github.com/bestnite/sub2clash"
>sub2clash</a
>
</p>
<p>Version: ${import.meta.env.APP_VERSION ?? "dev"}</p>
</aside>
</footer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"sub2clash-app": Sub2clashApp;
}
}

View File

@@ -0,0 +1,101 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import globalStyles from "../index.css?inline";
import type { Rename } from "../interface";
@customElement("rename-input")
export class RenameInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
private _replaceArray: Array<Rename> = [];
@state()
set replaceArray(value: Array<Rename>) {
this._replaceArray = value;
let updatedReplaceMap: { [key: string]: string } = {};
value.forEach((e) => {
updatedReplaceMap[e.old] = e.new;
});
this.dispatchEvent(
new CustomEvent("change", {
detail: updatedReplaceMap,
})
);
}
get replaceArray(): Array<Rename> {
return this._replaceArray;
}
render() {
return html`<!-- Rename -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">节点名称替换</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray.push({ old: "", new: "" });
this.replaceArray = updatedReplaceArray;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.replaceArray.map((_, i) => this.RenameTemplate(i))}
</div>`;
}
RenameTemplate(index: number) {
const replaceItem = this.replaceArray[index];
return html`<div class="join mb-1">
<input
class="input join-item"
placeholder="旧名称 (正则表达式)"
.value="${replaceItem?.old ?? ""}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray[index] = {
...updatedReplaceArray[index],
old: target.value,
};
this.replaceArray = updatedReplaceArray;
}}" />
<input
class="input join-item"
placeholder="新名称"
.value="${replaceItem?.new ?? ""}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedReplaceArray = [...this.replaceArray];
updatedReplaceArray[index] = {
...updatedReplaceArray[index],
new: target.value,
};
this.replaceArray = updatedReplaceArray;
}}" />
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedReplaceArray = this.replaceArray.filter(
(_, i) => i !== index
);
this.replaceArray = updatedReplaceArray;
}}">
删除
</button>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rename-input": RenameInput;
}
}

View File

@@ -0,0 +1,92 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { Rule } from "../interface";
import globalStyles from "../index.css?inline";
@customElement("rule-input")
export class RuleInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
_rules: Array<Rule> = [];
@state()
set rules(value: Array<Rule>) {
this.dispatchEvent(
new CustomEvent("change", {
detail: value,
})
);
this._rules = value;
}
get rules() {
return this._rules;
}
render() {
return html`<!-- Rule -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">规则</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedRules = this.rules ? [...this.rules] : [];
updatedRules?.push({
rule: "",
prepend: false,
});
this.rules = updatedRules;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.rules?.map((_, i) => this.RuleTemplate(i))}
</div>`;
}
RuleTemplate(index: number) {
return html`<div class="join mb-1">
<input
class="input join-item"
placeholder="规则"
.value="${this.rules![index].rule}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRules = this.rules;
updatedRules![index].rule = target.value;
this.rules = updatedRules;
}}" />
<div class="tooltip" data-tip="是否置于规则列表最前">
<select
class="select join-item w-fit"
.value="${String(this.rules![index].prepend)}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRules = this.rules;
updatedRules![index].prepend = Boolean(target.value);
this.rules = updatedRules;
}}">
<option value="true">是</option>
<option value="false" selected>否</option>
</select>
</div>
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedRules = this.rules?.filter((_, i) => i !== index);
this.rules = updatedRules;
}}">
删除
</button>
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rule-input": RuleInput;
}
}

View File

@@ -0,0 +1,143 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { RuleProvider } from "../interface";
import globalStyles from "../index.css?inline";
@customElement("rule-provider-input")
export class RuleProviderInput extends LitElement {
static styles = [unsafeCSS(globalStyles)];
_ruleProviders: Array<RuleProvider> = [];
@state()
set ruleProviders(value) {
this.dispatchEvent(
new CustomEvent("change", {
detail: value,
})
);
this._ruleProviders = value;
}
get ruleProviders() {
return this._ruleProviders;
}
RuleProviderTemplate(index: number) {
return html`
<div class="join mb-1">
<div class="tooltip" data-tip="不能重复">
<input
class="input join-item"
placeholder="名称"
.value="${this.ruleProviders![index].name}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].name = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
</div>
<div class="tooltip" data-tip="类型">
<select
class="select join-item w-fit"
.value="${this.ruleProviders![index].behavior}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].behavior = target.value;
this.ruleProviders = updatedRuleProviders;
}}">
<option value="classical" selected>classical</option>
<option value="domain">domain</option>
<option value="ipcidr">ipcidr</option>
</select>
</div>
<div>
<input
class="input join-item"
placeholder="Url"
.value="${this.ruleProviders![index].url}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].url = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
</div>
<input
class="input join-item"
placeholder="出站策略组"
.value="${this.ruleProviders![index].group}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].group = target.value;
this.ruleProviders = updatedRuleProviders;
}}" />
<div class="tooltip" data-tip="是否置于规则列表最前">
<select
class="select join-item w-fit"
.value="${String(this.ruleProviders![index].prepend)}"
@change="${(e: Event) => {
const target = e.target as HTMLInputElement;
let updatedRuleProviders = this.ruleProviders;
updatedRuleProviders![index].prepend = Boolean(target.value);
this.ruleProviders = updatedRuleProviders;
}}">
<option value="true">是</option>
<option value="false" selected>否</option>
</select>
</div>
<button
class="btn join-item bg-error"
type="button"
@click="${() => {
let updatedRuleProviders = this.ruleProviders?.filter(
(_, i) => i !== index
);
this.ruleProviders = updatedRuleProviders;
}}">
删除
</button>
</div>
`;
}
render() {
return html` <!-- Rule Provider -->
<div class="form-control mb-3">
<label class="label mb-1 pl-1">
<span class="label-text">Rule Provider</span>
<button
class="btn btn-primary btn-xs"
type="button"
@click="${() => {
let updatedRuleProviders = this.ruleProviders
? [...this.ruleProviders]
: [];
updatedRuleProviders.push({
behavior: "classical",
url: "",
name: "",
prepend: false,
group: "",
});
this.ruleProviders = updatedRuleProviders;
}}">
+
</button>
</label>
</div>
<div class="mb-3">
${this.ruleProviders?.map((_, i) => this.RuleProviderTemplate(i))}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"rule-provider-input": RuleProviderInput;
}
}

View File

@@ -0,0 +1,72 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.001 106.423);
--color-base-200: oklch(97% 0.001 106.424);
--color-base-300: oklch(92% 0.003 48.717);
--color-base-content: oklch(21% 0.006 56.043);
--color-primary: oklch(54% 0.281 293.009);
--color-primary-content: oklch(96% 0.016 293.756);
--color-secondary: oklch(57% 0.245 27.325);
--color-secondary-content: oklch(97% 0.013 17.38);
--color-accent: oklch(59% 0.249 0.584);
--color-accent-content: oklch(97% 0.014 343.198);
--color-neutral: oklch(14% 0.004 49.25);
--color-neutral-content: oklch(98% 0.001 106.423);
--color-info: oklch(78% 0.154 211.53);
--color-info-content: oklch(30% 0.056 229.695);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(26% 0.065 152.934);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1.5px;
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(21% 0.006 285.885);
--color-base-200: oklch(21% 0.006 285.885);
--color-base-300: oklch(27% 0.006 286.033);
--color-base-content: oklch(96% 0.001 286.375);
--color-primary: oklch(55% 0.288 302.321);
--color-primary-content: oklch(97% 0.014 308.299);
--color-secondary: oklch(44% 0.03 256.802);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(59% 0.249 0.584);
--color-accent-content: oklch(97% 0.014 343.198);
--color-neutral: oklch(37% 0.013 285.805);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(54% 0.245 262.881);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(64% 0.2 131.684);
--color-success-content: oklch(98% 0.031 120.757);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 1;
}

View File

@@ -0,0 +1,36 @@
export interface RuleProvider {
behavior: string;
url: string;
group: string;
prepend: boolean;
name: string;
}
export interface Rule {
rule: string;
prepend: boolean;
}
export interface Rename {
old: string;
new: string;
}
export interface Config {
clashType: number;
subscriptions?: string[];
proxies?: string[];
userAgent?: string;
refresh?: boolean;
autoTest?: boolean;
lazy?: boolean;
nodeList?: boolean;
ignoreCountryGroup?: boolean;
useUDP?: boolean;
template?: string;
ruleProviders?: RuleProvider[];
rules?: Rule[];
sort?: string;
remove?: string;
replace?: { [key: string]: string };
}

View File

@@ -0,0 +1,15 @@
export function base64EncodeUnicode(str: string) {
const bytes = new TextEncoder().encode(str);
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary);
}
export function base64decodeUnicode(str: string) {
const binaryString = atob(str);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});

View File

@@ -6,19 +6,27 @@ import (
"github.com/bestnite/sub2clash/common"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/model"
M "github.com/bestnite/sub2clash/model"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
func ConvertHandler(template string) func(c *gin.Context) {
func ConvertHandler() func(c *gin.Context) {
return func(c *gin.Context) {
query, err := M.ParseConvertQuery(c)
if err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
template := ""
switch query.ClashType {
case model.Clash:
template = config.GlobalConfig.ClashTemplate
case model.ClashMeta:
template = config.GlobalConfig.MetaTemplate
}
sub, err := common.BuildSub(query.ClashType, query, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())

View File

@@ -1,9 +1,10 @@
package handler
import (
"fmt"
"io"
"encoding/json"
"log"
"net/http"
"os"
"strings"
"time"
@@ -11,60 +12,64 @@ import (
"github.com/bestnite/sub2clash/common/database"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/model"
M "github.com/bestnite/sub2clash/model"
"gopkg.in/yaml.v3"
"github.com/gin-gonic/gin"
)
type shortLinkGenRequset struct {
Url string `form:"url" binding:"required"`
Password string `form:"password"`
CustomID string `form:"customId"`
Config model.ConvertConfig `form:"config" binding:"required"`
Password string `form:"password"`
ID string `form:"id"`
}
type shortLinkUpdateRequest struct {
Hash string `form:"hash" binding:"required"`
Url string `form:"url" binding:"required"`
Password string `form:"password" binding:"required"`
Config model.ConvertConfig `form:"config" binding:"required"`
Password string `form:"password" binding:"required"`
ID string `form:"id" binding:"required"`
}
func respondWithError(c *gin.Context, code int, message string) {
c.String(code, message)
c.Abort()
var DB *database.Database
func init() {
var err error
DB, err = database.ConnectDB()
if err != nil {
log.Printf("failed to connect to database: %v", err)
os.Exit(1)
}
}
func GenerateLinkHandler(c *gin.Context) {
var params shortLinkGenRequset
if err := c.ShouldBind(&params); err != nil {
respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if strings.TrimSpace(params.Url) == "" {
respondWithError(c, http.StatusBadRequest, "URL 不能为空")
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
var hash string
var id string
var password string
var err error
if params.CustomID != "" {
if params.ID != "" {
// 检查自定义ID是否已存在
exists, err := database.CheckShortLinkHashExists(params.CustomID)
exists, err := DB.CheckShortLinkIDExists(params.ID)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误")
c.String(http.StatusInternalServerError, "数据库错误")
return
}
if exists {
respondWithError(c, http.StatusBadRequest, "短链已存在")
c.String(http.StatusBadRequest, "短链已存在")
return
}
hash = params.CustomID
id = params.ID
password = params.Password
} else {
// 自动生成短链ID和密码
hash, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength)
id, err = generateUniqueHash(config.GlobalConfig.ShortLinkLength)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "生成短链失败")
c.String(http.StatusInternalServerError, "生成短链失败")
return
}
if params.Password == "" {
@@ -75,19 +80,19 @@ func GenerateLinkHandler(c *gin.Context) {
}
shortLink := model.ShortLink{
Hash: hash,
Url: params.Url,
ID: id,
Config: params.Config,
Password: password,
}
if err := database.SaveShortLink(&shortLink); err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误")
if err := DB.CreateShortLink(&shortLink); err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
// 返回生成的短链ID和密码
response := map[string]string{
"hash": hash,
"id": id,
"password": password,
}
c.JSON(http.StatusOK, response)
@@ -96,7 +101,7 @@ func GenerateLinkHandler(c *gin.Context) {
func generateUniqueHash(length int) (string, error) {
for {
hash := common.RandomString(length)
exists, err := database.CheckShortLinkHashExists(hash)
exists, err := DB.CheckShortLinkIDExists(hash)
if err != nil {
return "", err
}
@@ -109,112 +114,140 @@ func generateUniqueHash(length int) (string, error) {
func UpdateLinkHandler(c *gin.Context) {
var params shortLinkUpdateRequest
if err := c.ShouldBindJSON(&params); err != nil {
respondWithError(c, http.StatusBadRequest, "参数错误: "+err.Error())
c.String(http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 先获取原有的短链
existingLink, err := database.FindShortLinkByHash(params.Hash)
// 先获取原有的短链
existingLink, err := DB.FindShortLinkByID(params.ID)
if err != nil {
respondWithError(c, http.StatusNotFound, "未找到短链接")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
// 验证密码
if existingLink.Password != params.Password {
respondWithError(c, http.StatusUnauthorized, "密码错误")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
// 更新URL但保持原密码不变
shortLink := model.ShortLink{
Hash: params.Hash,
Url: params.Url,
Password: existingLink.Password, // 保持原密码不变
jsonData, err := json.Marshal(params.Config)
if err != nil {
c.String(http.StatusBadRequest, "配置格式错误")
return
}
if err := database.SaveShortLink(&shortLink); err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误")
if err := DB.UpdataShortLink(params.ID, "config", jsonData); err != nil {
c.String(http.StatusInternalServerError, "数据库错误")
return
}
c.String(http.StatusOK, "短链更新成功")
c.String(http.StatusOK, "短链更新成功")
}
func GetRawConfHandler(c *gin.Context) {
hash := c.Param("hash")
id := c.Param("id")
password := c.Query("password")
if strings.TrimSpace(hash) == "" {
if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误")
return
}
shortLink, err := database.FindShortLinkByHash(hash)
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusNotFound, "未找到短链接或密码错误")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusNotFound, "未找到短链接或密码错误")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
shortLink.LastRequestTime = time.Now().Unix()
err = database.SaveShortLink(shortLink)
err = DB.UpdataShortLink(shortLink.ID, "last_request_time", time.Now().Unix())
if err != nil {
respondWithError(c, http.StatusInternalServerError, "数据库错误")
c.String(http.StatusInternalServerError, "数据库错误")
return
}
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
template := ""
switch shortLink.Config.ClashType {
case model.Clash:
template = config.GlobalConfig.ClashTemplate
case model.ClashMeta:
template = config.GlobalConfig.MetaTemplate
}
host := c.Request.Host
targetPath := strings.TrimPrefix(shortLink.Url, "/")
requestURL := fmt.Sprintf("%s://%s/%s", scheme, host, targetPath)
client := &http.Client{
Timeout: 30 * time.Second, // 30秒超时
}
response, err := client.Get(requestURL)
sub, err := common.BuildSub(shortLink.Config.ClashType, shortLink.Config, template, config.GlobalConfig.CacheExpire, config.GlobalConfig.RequestRetryTimes)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "请求错误: "+err.Error())
c.String(http.StatusInternalServerError, err.Error())
return
}
defer response.Body.Close()
all, err := io.ReadAll(response.Body)
if len(shortLink.Config.Subs) == 1 {
userInfoHeader, err := common.FetchSubscriptionUserInfo(shortLink.Config.Subs[0], "clash", config.GlobalConfig.RequestRetryTimes)
if err == nil {
c.Header("subscription-userinfo", userInfoHeader)
}
}
if shortLink.Config.NodeListMode {
nodelist := M.NodeList{}
nodelist.Proxy = sub.Proxy
marshal, err := yaml.Marshal(nodelist)
if err != nil {
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(marshal))
return
}
marshal, err := yaml.Marshal(sub)
if err != nil {
respondWithError(c, http.StatusInternalServerError, "读取错误: "+err.Error())
c.String(http.StatusInternalServerError, "YAML序列化失败: "+err.Error())
return
}
c.String(http.StatusOK, string(all))
c.String(http.StatusOK, string(marshal))
}
func GetRawConfUriHandler(c *gin.Context) {
hash := c.Query("hash")
id := c.Param("id")
password := c.Query("password")
if strings.TrimSpace(hash) == "" {
if strings.TrimSpace(id) == "" {
c.String(http.StatusBadRequest, "参数错误")
return
}
shortLink, err := database.FindShortLinkByHash(hash)
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusNotFound, "未找到短链接或密码错误")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
if shortLink.Password != "" && shortLink.Password != password {
c.String(http.StatusNotFound, "未找到短链接或密码错误")
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
c.String(http.StatusOK, shortLink.Url)
c.JSON(http.StatusOK, shortLink.Config)
}
func DeleteShortLinkHandler(c *gin.Context) {
id := c.Param("id")
password := c.Query("password")
shortLink, err := DB.FindShortLinkByID(id)
if err != nil {
c.String(http.StatusBadRequest, "短链不存在或密码错误")
return
}
if shortLink.Password != password {
c.String(http.StatusUnauthorized, "短链不存在或密码错误")
return
}
err = DB.DeleteShortLink(id)
if err != nil {
c.String(http.StatusInternalServerError, "删除失败", err)
}
}

View File

@@ -2,46 +2,31 @@ package server
import (
"embed"
"html/template"
"log"
"net/http"
"github.com/bestnite/sub2clash/config"
"github.com/bestnite/sub2clash/constant"
"github.com/bestnite/sub2clash/server/handler"
"github.com/bestnite/sub2clash/server/middleware"
"github.com/gin-gonic/gin"
)
//go:embed static
//go:embed frontend/dist
var staticFiles embed.FS
func SetRoute(r *gin.Engine) {
tpl, err := template.ParseFS(staticFiles, "static/*")
if err != nil {
log.Fatalf("Error parsing templates: %v", err)
}
r.SetHTMLTemplate(tpl)
r.GET(
"/static/*filepath", func(c *gin.Context) {
c.FileFromFS("static/"+c.Param("filepath"), http.FS(staticFiles))
},
)
r.GET(
"/", func(c *gin.Context) {
version := constant.Version
c.HTML(
200, "index.html", gin.H{
"Version": version,
},
)
},
)
r.GET("/convert/:config", middleware.ZapLogger(), handler.ConvertHandler(config.GlobalConfig.ClashTemplate))
r.GET("/s/:hash", middleware.ZapLogger(), handler.GetRawConfHandler)
r.GET("/convert/:config", middleware.ZapLogger(), handler.ConvertHandler())
r.GET("/s/:id", middleware.ZapLogger(), handler.GetRawConfHandler)
r.POST("/short", middleware.ZapLogger(), handler.GenerateLinkHandler)
r.PUT("/short", middleware.ZapLogger(), handler.UpdateLinkHandler)
r.GET("/short", middleware.ZapLogger(), handler.GetRawConfUriHandler)
r.GET("/short/:id", middleware.ZapLogger(), handler.GetRawConfUriHandler)
r.DELETE("/short/:id", middleware.ZapLogger(), handler.DeleteShortLinkHandler)
r.GET("/", func(c *gin.Context) {
c.FileFromFS("frontend/dist/", http.FS(staticFiles))
})
r.GET(
"/assets/*filepath", func(c *gin.Context) {
c.FileFromFS("frontend/dist/assets/"+c.Param("filepath"), http.FS(staticFiles))
},
)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,219 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="light">
<head>
<meta charset="UTF-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>sub2clash</title>
<!-- Bootstrap CSS -->
<link href="./static/bootstrap.min.css" rel="stylesheet" />
<!-- Bootstrap JS -->
<script src="./static/bootstrap.bundle.min.js"></script>
<!-- Axios -->
<script src="./static/axios.min.js"></script>
<style>
.container {
max-width: 800px;
}
.btn-xs {
padding: 2px 2px;
/* 调整内边距以减小按钮大小 */
font-size: 10px;
/* 设置字体大小 */
line-height: 1.2;
/* 调整行高 */
border-radius: 3px;
/* 可选的边框半径调整 */
height: 25px;
width: 25px;
}
/* 主题切换按钮样式 */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
</style>
</head>
<body>
<!-- 主题切换按钮 -->
<button class="theme-toggle btn btn-outline-secondary" onclick="toggleTheme()" title="切换深色/浅色模式">
<span id="theme-icon">🌙</span>
</button>
<div class="container mt-5">
<div class="mb-4">
<h2>sub2clash</h2>
<span class="text-muted fst-italic">通用订阅链接转 Clash(Meta) 配置工具
<a href="https://github.com/bestnite/sub2clash#clash-meta" target="_blank">使用文档</a></span><br /><span
class="text-muted fst-italic">注意:本程序非纯前端程序,输入的订阅将被后端缓存,请确保您信任当前站点</span>
</div>
<!-- Input URL -->
<div class="form-group mb-5">
<label for="apiLink">解析链接:</label>
<div class="input-group mb-2">
<input class="form-control" id="urlInput" type="text" placeholder="通过生成的链接重新填写下方设置" />
<button class="btn btn-primary" onclick="parseInputURL()" type="button">
解析
</button>
</div>
</div>
<!-- API Endpoint -->
<div class="form-group mb-3">
<label for="endpoint">客户端类型:</label>
<select class="form-control" id="endpoint" name="endpoint">
<option value="1">Clash</option>
<option value="2" selected>Clash.Meta</option>
</select>
</div>
<!-- Template -->
<div class="form-group mb-3">
<label for="template">模板链接或名称:</label>
<input class="form-control" id="template" name="template" placeholder="输入模板链接(可选)" type="text" />
</div>
<!-- Subscription Link -->
<div class="form-group mb-3">
<label for="sub">订阅链接:</label>
<textarea class="form-control" id="sub" name="sub" placeholder="每行输入一个订阅链接" rows="5"></textarea>
</div>
<!-- Proxy Link -->
<div class="form-group mb-3">
<label for="proxy">节点分享链接:</label>
<textarea class="form-control" id="proxy" name="proxy" placeholder="每行输入一个节点分享链接" rows="5"></textarea>
</div>
<!-- User Agent -->
<div class="form-group mb-3">
<label for="user-agent">UA 标识:</label>
<textarea class="form-control" id="user-agent" name="user-agent"
placeholder="用于获取订阅的 http 请求中的 User-Agent 标识(可选)" rows="3"></textarea>
</div>
<!-- Refresh -->
<div class="form-check mb-3">
<input class="form-check-input" id="refresh" name="refresh" type="checkbox" />
<label class="form-check-label" for="refresh">强制重新获取订阅</label>
</div>
<!-- Node List -->
<div class="form-check mb-3">
<input class="form-check-input" id="nodeList" name="nodeList" type="checkbox" />
<label class="form-check-label" for="nodeList">输出为 Node List</label>
</div>
<!-- Auto Test -->
<div class="form-check mb-3">
<input class="form-check-input" id="autoTest" name="autoTest" type="checkbox" />
<label class="form-check-label" for="autoTest">国家策略组自动测速</label>
</div>
<!-- Lazy -->
<div class="form-check mb-3">
<input class="form-check-input" id="lazy" name="lazy" type="checkbox" />
<label class="form-check-label" for="lazy">自动测速启用 lazy 模式</label>
</div>
<!-- IgnoreCountryGroup -->
<div class="form-check mb-3">
<input class="form-check-input" id="igcg" name="igcg" type="checkbox" />
<label class="form-check-label" for="igcg">不输出国家策略组</label>
</div>
<!-- Use UDP -->
<div class="form-check mb-3">
<input class="form-check-input" id="useUDP" name="useUDP" type="checkbox" />
<label class="form-check-label" for="useUDP">使用 UDP</label>
</div>
<!-- Rule Provider -->
<div class="form-group mb-3" id="ruleProviderGroup">
<label>Rule Provider:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addRuleProvider()" type="button">
+
</button>
</div>
<!-- Rule -->
<div class="form-group mb-3" id="ruleGroup">
<label>规则:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addRule()" type="button">
+
</button>
</div>
<!-- Sort -->
<div class="form-group mb-3">
<label for="sort">国家策略组排序规则:</label>
<select class="form-control" id="sort" name="sort">
<option value="nameasc">名称(升序)</option>
<option value="namedesc">名称(降序)</option>
<option value="sizeasc">节点数量(升序)</option>
<option value="sizedesc">节点数量(降序)</option>
</select>
</div>
<!-- Remove -->
<div class="form-group mb-3">
<label for="remove">排除节点:</label>
<input class="form-control" type="text" name="remove" id="remove" placeholder="正则表达式" />
</div>
<!-- Rename -->
<div class="form-group mb-3" id="replaceGroup">
<label>节点名称替换:</label>
<button class="btn btn-primary mb-1 btn-xs" onclick="addReplace()" type="button">
+
</button>
</div>
<!-- Display the API Link -->
<div class="form-group mb-5">
<label for="apiLink">配置链接:</label>
<div class="input-group mb-2">
<input class="form-control" id="apiLink" type="text" placeholder="链接" readonly
style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="generateURL()" type="button">生成配置</button>
<button class="btn btn-primary" onclick="copyToClipboard('apiLink',this)" type="button">
复制链接
</button>
</div>
<div class="input-group mb-2">
<input class="form-control" id="customId" type="text" placeholder="短链ID可选" />
<input class="form-control" id="password" type="text" placeholder="密码(可选)" />
<button class="btn btn-primary" onclick="generateShortLink()" type="button">
生成短链
</button>
<button class="btn btn-primary" onclick="copyToClipboard('apiShortLink',this)" type="button">
复制短链
</button>
</div>
<div class="input-group">
<input class="form-control" id="apiShortLink" type="text" placeholder="短链接" readonly
style="cursor: not-allowed;" />
<button class="btn btn-primary" onclick="updateShortLink()" type="button">
更新短链
</button>
</div>
</div>
<!-- footer-->
<footer>
<p class="text-center">
Powered by
<a class="link-primary" href="https://github.com/bestnite/sub2clash">sub2clash</a>
</p>
<p class="text-center">Version {{.Version}}</p>
</footer>
</div>
</body>
<script src="./static/index.js"></script>
</html>

View File

@@ -1,597 +0,0 @@
function setInputReadOnly(input, readonly) {
if (readonly) {
input.readOnly = true;
input.style.cursor = 'not-allowed';
} else {
input.readOnly = false;
input.style.cursor = 'auto';
}
}
function clearExistingValues() {
// 清除简单输入框和复选框的值
document.getElementById("endpoint").value = "clash";
document.getElementById("sub").value = "";
document.getElementById("proxy").value = "";
document.getElementById("refresh").checked = false;
document.getElementById("autoTest").checked = false;
document.getElementById("lazy").checked = false;
document.getElementById("igcg").checked = false;
document.getElementById("useUDP").checked = false;
document.getElementById("template").value = "";
document.getElementById("sort").value = "nameasc";
document.getElementById("remove").value = "";
document.getElementById("apiLink").value = "";
document.getElementById("apiShortLink").value = "";
// 恢复短链ID和密码输入框状态
const customIdInput = document.getElementById("customId");
const passwordInput = document.getElementById("password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = "";
setInputReadOnly(customIdInput, false);
passwordInput.value = "";
setInputReadOnly(passwordInput, false);
// 恢复生成短链按钮状态
generateButton.disabled = false;
generateButton.classList.remove('btn-secondary');
generateButton.classList.add('btn-primary');
document.getElementById("nodeList").checked = false;
// 清除由 createRuleProvider, createReplace, 和 createRule 创建的所有额外输入组
clearInputGroup("ruleProviderGroup");
clearInputGroup("replaceGroup");
clearInputGroup("ruleGroup");
}
function generateURI() {
const config = {};
config.clashType = parseInt(document.getElementById("endpoint").value);
let subLines = document
.getElementById("sub")
.value.split("\n")
.filter((line) => line.trim() !== "");
if (subLines.length > 0) {
config.subscriptions = subLines;
}
let proxyLines = document
.getElementById("proxy")
.value.split("\n")
.filter((line) => line.trim() !== "");
if (proxyLines.length > 0) {
config.proxies = proxyLines;
}
if (
(config.subscriptions === undefined || config.subscriptions.length === 0) &&
(config.proxies === undefined || config.proxies.length === 0)
) {
return "";
}
config.userAgent = document.getElementById("user-agent").value;
config.refresh = document.getElementById("refresh").checked;
config.autoTest = document.getElementById("autoTest").checked;
config.lazy = document.getElementById("lazy").checked;
config.nodeList = document.getElementById("nodeList").checked;
config.ignoreCountryGroup = document.getElementById("igcg").checked;
config.useUDP = document.getElementById("useUDP").checked;
const template = document.getElementById("template").value;
if (template.trim() !== "") {
config.template = template;
}
const ruleProvidersElements = document.getElementsByName("ruleProvider");
if (ruleProvidersElements.length > 0) {
const ruleProviders = [];
for (let i = 0; i < ruleProvidersElements.length / 5; i++) {
let baseIndex = i * 5;
let behavior = ruleProvidersElements[baseIndex].value;
let url = ruleProvidersElements[baseIndex + 1].value;
let group = ruleProvidersElements[baseIndex + 2].value;
let prepend = ruleProvidersElements[baseIndex + 3].value;
let name = ruleProvidersElements[baseIndex + 4].value;
if (
behavior.trim() === "" ||
url.trim() === "" ||
group.trim() === "" ||
prepend.trim() === "" ||
name.trim() === ""
) {
return "";
}
ruleProviders.push({
behavior: behavior,
url: url,
group: group,
prepend: prepend.toLowerCase() === "true",
name: name,
});
}
if (ruleProviders.length > 0) {
config.ruleProviders = ruleProviders;
}
}
const rulesElements = document.getElementsByName("rule");
if (rulesElements.length > 0) {
const rules = [];
for (let i = 0; i < rulesElements.length / 2; i++) {
if (rulesElements[i * 2].value.trim() !== "") {
let rule = rulesElements[i * 2].value;
let prepend = rulesElements[i * 2 + 1].value;
if (rule.trim() === "" || prepend.trim() === "") {
return "";
}
rules.push({
rule: rule,
prepend: prepend.toLowerCase() === "true",
});
}
}
if (rules.length > 0) {
config.rules = rules;
}
}
config.sort = document.getElementById("sort").value;
const remove = document.getElementById("remove").value;
if (remove.trim() !== "") {
config.remove = remove;
}
const replacesElements = document.getElementsByName("replace");
if (replacesElements.length > 0) {
const replace = {};
for (let i = 0; i < replacesElements.length / 2; i++) {
let replaceStr = replacesElements[i * 2].value;
let replaceTo = replacesElements[i * 2 + 1].value;
if (replaceStr.trim() === "") {
return "";
}
replace[replaceStr] = replaceTo;
}
if (Object.keys(replace).length > 0) {
config.replace = replace;
}
}
const jsonString = JSON.stringify(config);
// 解决 btoa 中文报错,使用 TextEncoder 进行 UTF-8 编码再 base64
function base64EncodeUnicode(str) {
const bytes = new TextEncoder().encode(str);
let binary = '';
bytes.forEach((b) => binary += String.fromCharCode(b));
return btoa(binary);
}
const encoded = base64EncodeUnicode(jsonString);
const urlSafeBase64 = encoded
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `convert/${urlSafeBase64}`;
}
// 将输入框中的 URL 解析为参数
async function parseInputURL() {
// 获取输入框中的 URL
const inputURL = document.getElementById("urlInput").value;
// 清除现有的输入框值
clearExistingValues();
if (!inputURL) {
alert("请输入有效的链接!");
return;
}
let url;
try {
url = new URL(inputURL);
} catch (_) {
alert("无效的链接!");
return;
}
if (url.pathname.includes("/s/")) {
let hash = url.pathname.substring(url.pathname.lastIndexOf("/s/") + 3);
let q = new URLSearchParams();
let password = url.searchParams.get("password");
if (password === null) {
alert("仅可解析加密短链");
return;
}
q.append("hash", hash);
q.append("password", password);
try {
const response = await axios.get("./short?" + q.toString());
url = new URL(response.data, window.location.href);
// 回显配置链接
const apiLinkInput = document.querySelector("#apiLink");
apiLinkInput.value = url.href;
setInputReadOnly(apiLinkInput, true);
// 回显短链相关信息
const apiShortLinkInput = document.querySelector("#apiShortLink");
apiShortLinkInput.value = inputURL;
setInputReadOnly(apiShortLinkInput, true);
// 设置短链ID和密码并设置为只读
const customIdInput = document.querySelector("#customId");
const passwordInput = document.querySelector("#password");
const generateButton = document.querySelector('button[onclick="generateShortLink()"]');
customIdInput.value = hash;
setInputReadOnly(customIdInput, true);
passwordInput.value = password;
setInputReadOnly(passwordInput, true);
// 禁用生成短链按钮
generateButton.disabled = true;
generateButton.classList.add('btn-secondary');
generateButton.classList.remove('btn-primary');
} catch (error) {
console.log(error);
alert("获取短链失败,请检查密码!");
}
}
const pathSections = url.pathname.split("/");
const convertIndex = pathSections.findIndex((s) => s === "convert");
if (convertIndex === -1 || convertIndex + 1 >= pathSections.length) {
alert("无效的配置链接,请确认链接为新版格式。");
return;
}
const base64Config = pathSections[convertIndex + 1];
let config;
try {
const regularBase64 = base64Config.replace(/-/g, "+").replace(/_/g, "/");
const decodedStr = atob(regularBase64);
config = JSON.parse(decodeURIComponent(escape(decodedStr)));
} catch (e) {
alert("解析配置失败!");
console.error(e);
return;
}
document.getElementById("endpoint").value = config.clashType || "1";
if (config.subscriptions) {
document.getElementById("sub").value = config.subscriptions.join("\n");
}
if (config.proxies) {
document.getElementById("proxy").value = config.proxies.join("\n");
}
if (config.refresh) {
document.getElementById("refresh").checked = config.refresh;
}
if (config.autoTest) {
document.getElementById("autoTest").checked = config.autoTest;
}
if (config.lazy) {
document.getElementById("lazy").checked = config.lazy;
}
if (config.template) {
document.getElementById("template").value = config.template;
}
if (config.sort) {
document.getElementById("sort").value = config.sort;
}
if (config.remove) {
document.getElementById("remove").value = config.remove;
}
if (config.userAgent) {
document.getElementById("user-agent").value = config.userAgent;
}
if (config.ignoreCountryGroup) {
document.getElementById("igcg").checked = config.ignoreCountryGroup;
}
if (config.replace) {
const replaceGroup = document.getElementById("replaceGroup");
for (const original in config.replace) {
const div = createReplace();
div.children[0].value = original;
div.children[1].value = config.replace[original];
replaceGroup.appendChild(div);
}
}
if (config.ruleProviders) {
const ruleProviderGroup = document.getElementById("ruleProviderGroup");
for (const p of config.ruleProviders) {
const div = createRuleProvider();
div.children[0].value = p.behavior;
div.children[1].value = p.url;
div.children[2].value = p.group;
div.children[3].value = p.prepend;
div.children[4].value = p.name;
ruleProviderGroup.appendChild(div);
}
}
if (config.rules) {
const ruleGroup = document.getElementById("ruleGroup");
for (const r of config.rules) {
const div = createRule();
div.children[0].value = r.rule;
div.children[1].value = r.prepend;
ruleGroup.appendChild(div);
}
}
if (config.nodeList) {
document.getElementById("nodeList").checked = config.nodeList;
}
if (config.useUDP) {
document.getElementById("useUDP").checked = config.useUDP;
}
}
function clearInputGroup(groupId) {
// 清空第二个之后的child
const group = document.getElementById(groupId);
while (group.children.length > 2) {
group.removeChild(group.lastChild);
}
}
async function copyToClipboard(elem, e) {
const apiLinkInput = document.querySelector(`#${elem}`).value;
try {
await navigator.clipboard.writeText(apiLinkInput);
let text = e.textContent;
e.addEventListener("mouseout", function () {
e.textContent = text;
});
e.textContent = "复制成功";
} catch (err) {
console.error("复制到剪贴板失败:", err);
}
}
function createRuleProvider() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="ruleProvider" placeholder="Behavior">
<input type="text" class="form-control" name="ruleProvider" placeholder="Url">
<input type="text" class="form-control" name="ruleProvider" placeholder="Group">
<input type="text" class="form-control" name="ruleProvider" placeholder="Prepend">
<input type="text" class="form-control" name="ruleProvider" placeholder="Name">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function createReplace() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="replace" placeholder="原字符串(正则表达式)">
<input type="text" class="form-control" name="replace" placeholder="替换为(可为空)">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function createRule() {
const div = document.createElement("div");
div.classList.add("input-group", "mb-2");
div.innerHTML = `
<input type="text" class="form-control" name="rule" placeholder="Rule">
<input type="text" class="form-control" name="rule" placeholder="Prepend">
<button type="button" class="btn btn-danger" onclick="removeElement(this)">删除</button>
`;
return div;
}
function listenInput() {
let selectElements = document.querySelectorAll("select");
let inputElements = document.querySelectorAll("input");
let textAreaElements = document.querySelectorAll("textarea");
inputElements.forEach(function (element) {
element.addEventListener("input", function () {
generateURL();
});
});
textAreaElements.forEach(function (element) {
element.addEventListener("input", function () {
generateURL();
});
});
selectElements.forEach(function (element) {
element.addEventListener("change", function () {
generateURL();
});
});
}
function addRuleProvider() {
const div = createRuleProvider();
document.getElementById("ruleProviderGroup").appendChild(div);
listenInput();
}
function addRule() {
const div = createRule();
document.getElementById("ruleGroup").appendChild(div);
listenInput();
}
function addReplace() {
const div = createReplace();
document.getElementById("replaceGroup").appendChild(div);
listenInput();
}
function removeElement(button) {
button.parentElement.remove();
}
function generateURL() {
const apiLink = document.getElementById("apiLink");
let uri = generateURI();
if (uri === "") {
return;
}
apiLink.value = `${window.location.origin}${window.location.pathname}${uri}`;
setInputReadOnly(apiLink, true);
}
function generateShortLink() {
const apiShortLink = document.getElementById("apiShortLink");
const password = document.getElementById("password");
const customId = document.getElementById("customId");
let uri = generateURI();
if (uri === "") {
return;
}
axios
.post(
"./short",
{
url: uri,
password: password.value.trim(),
customId: customId.value.trim()
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
// 设置返回的短链ID和密码
customId.value = response.data.hash;
password.value = response.data.password;
// 生成完整的短链接
const shortLink = `${window.location.origin}${window.location.pathname}s/${response.data.hash}?password=${response.data.password}`;
apiShortLink.value = shortLink;
})
.catch((error) => {
console.log(error);
if (error.response && error.response.data) {
alert(error.response.data);
} else {
alert("生成短链失败,请重试!");
}
});
}
function updateShortLink() {
const password = document.getElementById("password");
const apiShortLink = document.getElementById("apiShortLink");
let hash = apiShortLink.value;
if (hash.startsWith("http")) {
let u = new URL(hash);
hash = u.pathname.substring(u.pathname.lastIndexOf("/s/") + 3);
}
if (password.value.trim() === "") {
alert("请输入原密码进行验证!");
return;
}
let uri = generateURI();
if (uri === "") {
return;
}
axios
.put(
"./short",
{
hash: hash,
url: uri,
password: password.value.trim(),
},
{
headers: {
"Content-Type": "application/json",
},
}
)
.then((response) => {
alert(`短链 ${hash} 更新成功!`);
})
.catch((error) => {
console.log(error);
if (error.response && error.response.status === 401) {
alert("密码错误,请输入正确的原密码!");
} else if (error.response && error.response.data) {
alert(error.response.data);
} else {
alert("更新短链失败,请重试!");
}
});
}
// 主题切换功能
function initTheme() {
const html = document.querySelector('html');
const themeIcon = document.getElementById('theme-icon');
let theme;
// 从localStorage获取用户偏好的主题
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
// 如果用户之前设置过主题,使用保存的主题
theme = savedTheme;
} else {
// 如果没有设置过,检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme = prefersDark ? 'dark' : 'light';
}
// 设置主题
html.setAttribute('data-bs-theme', theme);
// 更新图标
if (theme === 'dark') {
themeIcon.textContent = '☀️';
} else {
themeIcon.textContent = '🌙';
}
}
function toggleTheme() {
const html = document.querySelector('html');
const currentTheme = html.getAttribute('data-bs-theme');
// 切换主题
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-bs-theme', newTheme);
// 更新图标
if (newTheme === 'dark') {
themeIcon.textContent = '☀️';
} else {
themeIcon.textContent = '🌙';
}
// 保存用户偏好到localStorage
localStorage.setItem('theme', newTheme);
}
listenInput();
initTheme();