mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 16:54:24 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db3577afa9 | |||
| 7285717e34 | |||
| de6911f219 | |||
| e5ce0501a2 | |||
| 25a87e25c5 | |||
| a628f31859 | |||
| aae138a8cf | |||
| d2645589da | |||
| 6684a3426b | |||
| 7a8595ec87 | |||
| 06f76d918f | |||
| feb357ff17 |
@@ -311,7 +311,7 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if tolerateExists {
|
||||
if strings.Contains(msg, "exists") || strings.Contains(msg, "already") || strings.Contains(msg, "已存在") {
|
||||
if isAlreadyExistsMessage(msg) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
@@ -907,6 +907,17 @@ func isNotFoundError(err error) bool {
|
||||
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
|
||||
}
|
||||
|
||||
func isAlreadyExistsMessage(message string) bool {
|
||||
msg := strings.ToLower(strings.TrimSpace(message))
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(msg, "address already in use") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(msg, "already exists") || strings.Contains(msg, "已存在")
|
||||
}
|
||||
|
||||
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
|
||||
protocols := []string{"tcp", "udp"}
|
||||
services := make([]map[string]interface{}, 0, 2)
|
||||
|
||||
@@ -53,3 +53,15 @@ func TestShouldTryLegacySingleService(t *testing.T) {
|
||||
t.Fatalf("DeleteService should not require legacy fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlreadyExistsMessage(t *testing.T) {
|
||||
if !isAlreadyExistsMessage("service demo already exists") {
|
||||
t.Fatalf("expected already exists message to be tolerated")
|
||||
}
|
||||
if !isAlreadyExistsMessage("服务已存在") {
|
||||
t.Fatalf("expected Chinese already exists message to be tolerated")
|
||||
}
|
||||
if isAlreadyExistsMessage("listen tcp [::]:10001: bind: address already in use") {
|
||||
t.Fatalf("address already in use must not be treated as already exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -72,6 +73,11 @@ type flowItem struct {
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
|
||||
const (
|
||||
pngDataURLPrefix = "data:image/png;base64,"
|
||||
maxBrandAssetDataURLBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
h := &Handler{
|
||||
repo: repo,
|
||||
@@ -747,7 +753,14 @@ func (h *Handler) updateConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.repo.UpsertConfig(key, v, now); err != nil {
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(key, v)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(key, value, now); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -767,16 +780,24 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Value) == "" {
|
||||
|
||||
value, err := normalizeAndValidateConfigValue(name, req.Value)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if value == "" && name != "app_logo" && name != "app_favicon" {
|
||||
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertConfig(strings.TrimSpace(req.Name), req.Value, time.Now().UnixMilli()); err != nil {
|
||||
if err := h.repo.UpsertConfig(name, value, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -784,6 +805,37 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
||||
switch strings.TrimSpace(key) {
|
||||
case "app_logo", "app_favicon":
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(normalized, pngDataURLPrefix) {
|
||||
return "", fmt.Errorf("品牌图片必须通过上传生成 PNG 数据")
|
||||
}
|
||||
|
||||
if len(normalized) > maxBrandAssetDataURLBytes {
|
||||
return "", fmt.Errorf("品牌图片过大,请上传更小图片")
|
||||
}
|
||||
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(normalized, pngDataURLPrefix))
|
||||
if payload == "" {
|
||||
return "", fmt.Errorf("品牌图片数据不能为空")
|
||||
}
|
||||
|
||||
if _, err := base64.StdEncoding.DecodeString(payload); err != nil {
|
||||
return "", fmt.Errorf("品牌图片数据格式无效")
|
||||
}
|
||||
|
||||
return pngDataURLPrefix + payload, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -989,10 +1041,27 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
if cfg == nil || !strings.EqualFold(strings.TrimSpace(cfg.Value), "true") {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
|
||||
siteCfg, err := h.repo.GetConfigByName("cloudflare_site_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if siteCfg == nil || strings.TrimSpace(siteCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||
|
||||
@@ -1183,7 +1183,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.syncForwardServices(createdForward, "AddService", false); err != nil {
|
||||
if err := h.syncForwardServices(createdForward, "UpdateService", true); err != nil {
|
||||
_ = h.deleteForwardByID(forwardID)
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
@@ -2806,7 +2806,7 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
||||
func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[string]interface{}) error {
|
||||
allocated := map[int64]int{}
|
||||
inNodes := asMapSlice(req["inNodeId"])
|
||||
for _, n := range inNodes {
|
||||
for i, n := range inNodes {
|
||||
nodeID := asInt64(n["nodeId"], 0)
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
@@ -2818,13 +2818,13 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
nodeID,
|
||||
sql.NullInt64{},
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
0,
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, n := range asMapSlice(req["outNodeId"]) {
|
||||
for i, n := range asMapSlice(req["outNodeId"]) {
|
||||
nodeID := asInt64(n["nodeId"], 0)
|
||||
if nodeID <= 0 {
|
||||
continue
|
||||
@@ -2844,7 +2844,7 @@ func (h *Handler) replaceTunnelChainsTx(tx *gorm.DB, tunnelID int64, req map[str
|
||||
nodeID,
|
||||
sql.NullInt64{Int64: int64(port), Valid: true},
|
||||
defaultString(asString(n["strategy"]), "round"),
|
||||
0,
|
||||
i+1,
|
||||
defaultString(asString(n["protocol"]), "tls"),
|
||||
); err != nil {
|
||||
return err
|
||||
|
||||
@@ -214,7 +214,7 @@ func (GroupPermissionGrant) TableName() string { return "group_permission_grant"
|
||||
type ViteConfig struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"`
|
||||
Value string `gorm:"type:varchar(200);not null" json:"value"`
|
||||
Value string `gorm:"type:text;not null" json:"value"`
|
||||
Time int64 `gorm:"not null" json:"time"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2482,9 +2482,10 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) {
|
||||
|
||||
// ─── Migration ───────────────────────────────────────────────────────
|
||||
|
||||
const currentSchemaVersion = 2
|
||||
const currentSchemaVersion = 3
|
||||
|
||||
var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults
|
||||
var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType
|
||||
|
||||
func getSchemaVersion(db *gorm.DB) int {
|
||||
var v model.SchemaVersion
|
||||
@@ -2536,10 +2537,55 @@ func migrateSchema(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if ver < 3 {
|
||||
if err := migrateViteConfigValueColumnTypeFn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
setSchemaVersion(db, currentSchemaVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateViteConfigValueColumnType(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("nil db")
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&model.ViteConfig{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
}
|
||||
|
||||
type columnRow struct {
|
||||
DataType string `gorm:"column:data_type"`
|
||||
}
|
||||
|
||||
var row columnRow
|
||||
if err := db.Raw(
|
||||
`SELECT data_type FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = ?
|
||||
AND column_name = ?`,
|
||||
"vite_config", "value",
|
||||
).Scan(&row).Error; err != nil {
|
||||
return fmt.Errorf("inspect vite_config.value type: %w", err)
|
||||
}
|
||||
|
||||
if strings.EqualFold(row.DataType, "text") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec(`ALTER TABLE "vite_config" ALTER COLUMN "value" TYPE TEXT`).Error; err != nil {
|
||||
return fmt.Errorf("alter vite_config.value to text: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensurePostgresIDDefaults(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "postgres" {
|
||||
return nil
|
||||
|
||||
@@ -83,3 +83,95 @@ func TestMigrateSchemaReturnsPostgresIDRepairError(t *testing.T) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaRunsViteConfigValueMigrationForLegacySchema(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
called := 0
|
||||
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||
called++
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||
})
|
||||
|
||||
if err := migrateSchema(db); err != nil {
|
||||
t.Fatalf("migrateSchema: %v", err)
|
||||
}
|
||||
|
||||
if called != 1 {
|
||||
t.Fatalf("expected vite_config migration to run once, got %d", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSchemaReturnsViteConfigMigrationError(t *testing.T) {
|
||||
db, err := gorm.Open(gsqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.Exec(`CREATE TABLE schema_version (version INTEGER NOT NULL DEFAULT 0)`).Error; err != nil {
|
||||
t.Fatalf("create schema_version: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO schema_version(version) VALUES(?)`, 2).Error; err != nil {
|
||||
t.Fatalf("seed schema_version: %v", err)
|
||||
}
|
||||
|
||||
originalIDRepair := ensurePostgresIDDefaultsFn
|
||||
ensurePostgresIDDefaultsFn = func(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ensurePostgresIDDefaultsFn = originalIDRepair
|
||||
})
|
||||
|
||||
wantErr := errors.New("vite config migration failed")
|
||||
originalMigrate := migrateViteConfigValueColumnTypeFn
|
||||
migrateViteConfigValueColumnTypeFn = func(db *gorm.DB) error {
|
||||
return wantErr
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
migrateViteConfigValueColumnTypeFn = originalMigrate
|
||||
})
|
||||
|
||||
err = migrateSchema(db)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", wantErr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,6 +610,96 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateThenPauseResumeContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, repo := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-toggle-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, repo, "forward-toggle-tunnel")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-toggle-node", "forward-toggle-secret", "10.31.0.1", "10.31.0.1", "", "41000-41010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, repo, "forward-toggle-node")
|
||||
|
||||
if err := repo.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 41001, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
stopNode := startMockNodeSession(t, server.URL, "forward-toggle-secret")
|
||||
defer stopNode()
|
||||
|
||||
createPayload := map[string]interface{}{
|
||||
"name": "forward-toggle-target",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
createBody, err := json.Marshal(createPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal create payload: %v", err)
|
||||
}
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody))
|
||||
createReq.Header.Set("Authorization", adminToken)
|
||||
createReq.Header.Set("Content-Type", "application/json")
|
||||
createRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRes, createReq)
|
||||
assertCode(t, createRes, 0)
|
||||
|
||||
forwardID := mustLastInsertID(t, repo, "forward-toggle-target")
|
||||
|
||||
pauseBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pause payload: %v", err)
|
||||
}
|
||||
pauseReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/pause", bytes.NewReader(pauseBody))
|
||||
pauseReq.Header.Set("Authorization", adminToken)
|
||||
pauseReq.Header.Set("Content-Type", "application/json")
|
||||
pauseRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(pauseRes, pauseReq)
|
||||
assertCode(t, pauseRes, 0)
|
||||
|
||||
pausedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||
if pausedStatus != 0 {
|
||||
t.Fatalf("expected status=0 after pause, got %d", pausedStatus)
|
||||
}
|
||||
|
||||
resumeBody, err := json.Marshal(map[string]interface{}{"id": forwardID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal resume payload: %v", err)
|
||||
}
|
||||
resumeReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/resume", bytes.NewReader(resumeBody))
|
||||
resumeReq.Header.Set("Authorization", adminToken)
|
||||
resumeReq.Header.Set("Content-Type", "application/json")
|
||||
resumeRes := httptest.NewRecorder()
|
||||
router.ServeHTTP(resumeRes, resumeReq)
|
||||
assertCode(t, resumeRes, 0)
|
||||
|
||||
resumedStatus := mustQueryInt(t, repo, `SELECT status FROM forward WHERE id = ?`, forwardID)
|
||||
if resumedStatus != 1 {
|
||||
t.Fatalf("expected status=1 after resume, got %d", resumedStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonNumber(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,77 @@ func TestForwardCreateRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardCreateRollbackWhenServiceDispatchReturnsAddressInUseContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "addr-in-use-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "addr-in-use-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "addr-in-use-node", "addr-in-use-secret", "10.20.0.11", "10.20.0.11", "", "32100-32110", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "addr-in-use-node")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||
VALUES(?, 1, ?, 32101, 'round', 1, 'tls')
|
||||
`, tunnelID, nodeID).Error; err != nil {
|
||||
t.Fatalf("insert chain_tunnel: %v", err)
|
||||
}
|
||||
|
||||
stopNode := startMockNodeSessionWithCommandFailures(t, server.URL, "addr-in-use-secret", map[string]string{
|
||||
"updateservice": "listen tcp [::]:32101: bind: address already in use",
|
||||
"addservice": "listen tcp [::]:32101: bind: address already in use",
|
||||
})
|
||||
defer stopNode()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "addr-in-use-forward",
|
||||
"tunnelId": tunnelID,
|
||||
"remoteAddr": "1.1.1.1:443",
|
||||
"strategy": "fifo",
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if out.Code == 0 {
|
||||
t.Fatalf("expected create failure on address-in-use service dispatch, got code=0")
|
||||
}
|
||||
|
||||
forwardCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM forward WHERE name = ?`, "addr-in-use-forward")
|
||||
if forwardCount != 0 {
|
||||
t.Fatalf("expected forward rollback delete on address-in-use failure, got count=%d", forwardCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssignRollbackWhenLimiterDispatchFailsContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
secret := "contract-jwt-secret"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
verifiedToken := ""
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
@@ -34,6 +35,55 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
t.Fatalf("enable captcha: %v", err)
|
||||
}
|
||||
|
||||
t.Run("login allowed when cloudflare keys are missing", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha verify remains compatible without cloudflare secret", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
verifyResp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(verifyResp, verifyReq)
|
||||
|
||||
var verifyOut struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ValidToken string `json:"validToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
|
||||
t.Fatalf("decode captcha verify response: %v", err)
|
||||
}
|
||||
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
|
||||
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
|
||||
}
|
||||
|
||||
verifiedToken = verifyOut.Data.ValidToken
|
||||
})
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "cloudflare_site_key", "test-site-key", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("set cloudflare site key: %v", err)
|
||||
}
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO vite_config(name, value, time)
|
||||
VALUES(?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||
`, "cloudflare_secret_key", "test-secret-key", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("set cloudflare secret key: %v", err)
|
||||
}
|
||||
|
||||
t.Run("login denied without verified captcha token", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
@@ -58,33 +108,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
verifyResp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(verifyResp, verifyReq)
|
||||
|
||||
var verifyOut struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
ValidToken string `json:"validToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(verifyResp.Body).Decode(&verifyOut); err != nil {
|
||||
t.Fatalf("decode captcha verify response: %v", err)
|
||||
}
|
||||
if !verifyOut.Success || verifyOut.Data.ValidToken != "captcha-token-1" {
|
||||
t.Fatalf("unexpected captcha verify payload: success=%v token=%q", verifyOut.Success, verifyOut.Data.ValidToken)
|
||||
if strings.TrimSpace(verifiedToken) == "" {
|
||||
t.Fatalf("expected verified token from compatibility captcha verify")
|
||||
}
|
||||
|
||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
||||
loginBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||
loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", loginBody)
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginResp, loginReq)
|
||||
assertCode(t, loginResp, 0)
|
||||
|
||||
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"captcha-token-1"}`)
|
||||
replayBody := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":"` + verifiedToken + `"}`)
|
||||
replayReq := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", replayBody)
|
||||
replayReq.Header.Set("Content-Type", "application/json")
|
||||
replayResp := httptest.NewRecorder()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
@@ -13,6 +12,76 @@
|
||||
<script>
|
||||
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
|
||||
(function() {
|
||||
// 页面最早阶段应用缓存 favicon,避免默认图标闪烁
|
||||
const defaultFavicon = '/favicon.ico';
|
||||
let cachedFavicon = '';
|
||||
|
||||
try {
|
||||
cachedFavicon = localStorage.getItem('vite_config_app_favicon') || '';
|
||||
} catch (_) {
|
||||
cachedFavicon = '';
|
||||
}
|
||||
|
||||
// 无缓存时,尝试通过公开配置接口同步读取,避免登录页闪烁
|
||||
if (!cachedFavicon.trim()) {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/api/v1/config/get', false);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.send(JSON.stringify({ name: 'app_favicon' }));
|
||||
|
||||
if (xhr.status === 200) {
|
||||
const resp = JSON.parse(xhr.responseText || '{}');
|
||||
const value =
|
||||
resp &&
|
||||
typeof resp === 'object' &&
|
||||
resp.code === 0 &&
|
||||
resp.data &&
|
||||
typeof resp.data.value === 'string'
|
||||
? resp.data.value.trim()
|
||||
: '';
|
||||
|
||||
if (value) {
|
||||
cachedFavicon = value;
|
||||
try {
|
||||
localStorage.setItem('vite_config_app_favicon', value);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const faviconHref = cachedFavicon.trim() || defaultFavicon;
|
||||
let faviconLink = document.head.querySelector('link#app-favicon');
|
||||
|
||||
if (!faviconLink) {
|
||||
faviconLink = document.createElement('link');
|
||||
faviconLink.id = 'app-favicon';
|
||||
faviconLink.rel = 'icon';
|
||||
document.head.appendChild(faviconLink);
|
||||
}
|
||||
|
||||
faviconLink.href = faviconHref;
|
||||
if (faviconHref.startsWith('data:image/png')) {
|
||||
faviconLink.type = 'image/png';
|
||||
} else {
|
||||
faviconLink.removeAttribute('type');
|
||||
}
|
||||
|
||||
let shortcutIconLink = document.head.querySelector('link[rel="shortcut icon"]');
|
||||
if (!shortcutIconLink) {
|
||||
shortcutIconLink = document.createElement('link');
|
||||
shortcutIconLink.rel = 'shortcut icon';
|
||||
document.head.appendChild(shortcutIconLink);
|
||||
}
|
||||
|
||||
shortcutIconLink.href = faviconHref;
|
||||
if (faviconHref.startsWith('data:image/png')) {
|
||||
shortcutIconLink.type = 'image/png';
|
||||
} else {
|
||||
shortcutIconLink.removeAttribute('type');
|
||||
}
|
||||
|
||||
// 立即检测系统主题并应用
|
||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
@@ -156,7 +225,6 @@
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<link href="/favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import AdminLayout from "@/layouts/admin";
|
||||
import H5Layout from "@/layouts/h5";
|
||||
import H5SimpleLayout from "@/layouts/h5-simple";
|
||||
import { isLoggedIn } from "@/utils/auth";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { siteConfig, updateSiteConfig } from "@/config/site";
|
||||
import { useH5Mode } from "@/hooks/useH5Mode";
|
||||
|
||||
// 简化的路由保护组件 - 使用 React Router 导航避免循环
|
||||
@@ -90,23 +90,17 @@ function App() {
|
||||
useEffect(() => {
|
||||
document.title = siteConfig.name;
|
||||
|
||||
// 异步检查是否有配置更新
|
||||
const checkTitleUpdate = async () => {
|
||||
try {
|
||||
// 引入必要的函数
|
||||
const { getCachedConfig } = await import("@/config/site");
|
||||
const cachedAppName = await getCachedConfig("app_name");
|
||||
void updateSiteConfig();
|
||||
|
||||
if (cachedAppName && cachedAppName !== document.title) {
|
||||
document.title = cachedAppName;
|
||||
}
|
||||
} catch {}
|
||||
const handleConfigUpdate = () => {
|
||||
void updateSiteConfig();
|
||||
};
|
||||
|
||||
// 延迟检查,避免阻塞初始渲染
|
||||
const timer = setTimeout(checkTitleUpdate, 100);
|
||||
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => {
|
||||
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { siteConfig, getCachedConfigs } from "@/config/site";
|
||||
import { Logo } from "@/components/icons";
|
||||
|
||||
type BrandLogoProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const BrandLogo = ({
|
||||
size = 24,
|
||||
className,
|
||||
alt = "brand logo",
|
||||
}: BrandLogoProps) => {
|
||||
const [logoUrl, setLogoUrl] = useState<string>(siteConfig.app_logo || "");
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const syncLogo = async () => {
|
||||
try {
|
||||
const configMap = await getCachedConfigs();
|
||||
const nextLogo = (
|
||||
configMap.app_logo ||
|
||||
siteConfig.app_logo ||
|
||||
""
|
||||
).trim();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
siteConfig.app_logo = nextLogo;
|
||||
setLogoUrl(nextLogo);
|
||||
setImageFailed(false);
|
||||
} catch {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setLogoUrl((siteConfig.app_logo || "").trim());
|
||||
}
|
||||
};
|
||||
|
||||
void syncLogo();
|
||||
|
||||
const handleConfigUpdate = () => {
|
||||
const nextLogo = (siteConfig.app_logo || "").trim();
|
||||
|
||||
setLogoUrl(nextLogo);
|
||||
setImageFailed(false);
|
||||
void syncLogo();
|
||||
};
|
||||
|
||||
window.addEventListener("configUpdated", handleConfigUpdate);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.removeEventListener("configUpdated", handleConfigUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!logoUrl || imageFailed) {
|
||||
return <Logo className={className} size={size} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt={alt}
|
||||
className={className}
|
||||
height={size}
|
||||
src={logoUrl}
|
||||
width={size}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
} from "@/shadcn-bridge/heroui/navbar";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig, getCachedConfig } from "@/config/site";
|
||||
import { useWebViewMode } from "@/hooks/useWebViewMode";
|
||||
|
||||
@@ -69,7 +69,7 @@ export const Navbar = () => {
|
||||
color="foreground"
|
||||
href="/"
|
||||
>
|
||||
<Logo size={24} />
|
||||
<BrandLogo size={24} />
|
||||
<p className="font-bold text-inherit truncate">{appName}</p>
|
||||
</Link>
|
||||
</NavbarBrand>
|
||||
|
||||
@@ -6,6 +6,8 @@ export type SiteConfig = typeof siteConfig;
|
||||
const CACHE_PREFIX = "vite_config_";
|
||||
const VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
const APP_VERSION = "1.0.3";
|
||||
const DEFAULT_FAVICON = "/favicon.ico";
|
||||
const FAVICON_LINK_ID = "app-favicon";
|
||||
const GITHUB_REPO =
|
||||
import.meta.env.VITE_GITHUB_REPO || "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
@@ -16,10 +18,15 @@ const getInitialConfig = () => {
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: "",
|
||||
app_favicon: "",
|
||||
};
|
||||
}
|
||||
|
||||
const cachedAppName = localStorage.getItem(CACHE_PREFIX + "app_name");
|
||||
const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || "";
|
||||
const cachedAppFavicon =
|
||||
localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
|
||||
|
||||
if (cachedAppName) {
|
||||
return {
|
||||
@@ -27,6 +34,8 @@ const getInitialConfig = () => {
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +43,9 @@ const getInitialConfig = () => {
|
||||
name: "FLVX",
|
||||
version: VERSION,
|
||||
app_version: APP_VERSION,
|
||||
github_repo: GITHUB_REPO,
|
||||
app_logo: cachedAppLogo,
|
||||
app_favicon: cachedAppFavicon,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,7 +97,11 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
|
||||
const response = await getConfigByName(key);
|
||||
|
||||
if (response.code === 0 && response.data?.value) {
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
configCache.set(key, value);
|
||||
@@ -99,7 +115,7 @@ export const getCachedConfig = async (key: string): Promise<string | null> => {
|
||||
// 获取所有配置(优先从缓存)
|
||||
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
// 尝试从缓存获取所有配置
|
||||
const configKeys = ["app_name"];
|
||||
const configKeys = ["app_name", "app_logo", "app_favicon"];
|
||||
const cachedConfigs: Record<string, string> = {};
|
||||
let hasCachedData = false;
|
||||
|
||||
@@ -112,6 +128,33 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
}
|
||||
});
|
||||
|
||||
const fetchPublicConfigs = async (): Promise<Record<string, string>> => {
|
||||
const publicConfigMap: Record<string, string> = {};
|
||||
|
||||
await Promise.all(
|
||||
configKeys.map(async (key) => {
|
||||
try {
|
||||
const response = await getConfigByName(key);
|
||||
|
||||
if (
|
||||
response.code === 0 &&
|
||||
response.data &&
|
||||
typeof response.data.value === "string"
|
||||
) {
|
||||
const value = response.data.value;
|
||||
|
||||
publicConfigMap[key] = value;
|
||||
configCache.set(key, value);
|
||||
}
|
||||
} catch {
|
||||
// ignore single key fetch error
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return publicConfigMap;
|
||||
};
|
||||
|
||||
// 从API获取最新配置
|
||||
try {
|
||||
const response = await getConfigs();
|
||||
@@ -126,31 +169,116 @@ export const getCachedConfigs = async (): Promise<Record<string, string>> => {
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
if (hasCachedData) {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
return await fetchPublicConfigs();
|
||||
} catch {
|
||||
// API失败时返回缓存的数据
|
||||
if (hasCachedData) {
|
||||
return cachedConfigs;
|
||||
}
|
||||
|
||||
return await fetchPublicConfigs();
|
||||
}
|
||||
};
|
||||
|
||||
const updateDocumentFavicon = (faviconUrl: string) => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
return {};
|
||||
const normalized = faviconUrl.trim() || DEFAULT_FAVICON;
|
||||
|
||||
let iconLink = document.head.querySelector<HTMLLinkElement>(
|
||||
`link#${FAVICON_LINK_ID}`,
|
||||
);
|
||||
|
||||
if (!iconLink) {
|
||||
iconLink = document.createElement("link");
|
||||
iconLink.id = FAVICON_LINK_ID;
|
||||
iconLink.rel = "icon";
|
||||
document.head.appendChild(iconLink);
|
||||
}
|
||||
|
||||
iconLink.rel = "icon";
|
||||
iconLink.href = normalized;
|
||||
if (normalized.startsWith("data:image/png")) {
|
||||
iconLink.type = "image/png";
|
||||
} else {
|
||||
iconLink.removeAttribute("type");
|
||||
}
|
||||
|
||||
let shortcutIconLink = document.head.querySelector<HTMLLinkElement>(
|
||||
'link[rel="shortcut icon"]',
|
||||
);
|
||||
|
||||
if (!shortcutIconLink) {
|
||||
shortcutIconLink = document.createElement("link");
|
||||
shortcutIconLink.rel = "shortcut icon";
|
||||
document.head.appendChild(shortcutIconLink);
|
||||
}
|
||||
|
||||
shortcutIconLink.href = normalized;
|
||||
if (normalized.startsWith("data:image/png")) {
|
||||
shortcutIconLink.type = "image/png";
|
||||
} else {
|
||||
shortcutIconLink.removeAttribute("type");
|
||||
}
|
||||
|
||||
const duplicatedIcons = Array.from(
|
||||
document.head.querySelectorAll<HTMLLinkElement>('link[rel="icon"]'),
|
||||
).filter((link) => link !== iconLink);
|
||||
|
||||
duplicatedIcons.forEach((link) => link.remove());
|
||||
};
|
||||
|
||||
// 动态更新网站配置
|
||||
export const updateSiteConfig = async () => {
|
||||
const appName = await getCachedConfig("app_name");
|
||||
export const updateSiteConfig = async (configMap?: Record<string, string>) => {
|
||||
const resolvedConfigMap = configMap ?? (await getCachedConfigs());
|
||||
|
||||
Object.entries(resolvedConfigMap).forEach(([key, value]) => {
|
||||
configCache.set(key, String(value));
|
||||
});
|
||||
|
||||
const hasAppName = Object.prototype.hasOwnProperty.call(
|
||||
resolvedConfigMap,
|
||||
"app_name",
|
||||
);
|
||||
const hasAppLogo = Object.prototype.hasOwnProperty.call(
|
||||
resolvedConfigMap,
|
||||
"app_logo",
|
||||
);
|
||||
const hasAppFavicon = Object.prototype.hasOwnProperty.call(
|
||||
resolvedConfigMap,
|
||||
"app_favicon",
|
||||
);
|
||||
|
||||
const appName = hasAppName
|
||||
? String(resolvedConfigMap.app_name || "").trim()
|
||||
: siteConfig.name;
|
||||
const appLogo = hasAppLogo
|
||||
? String(resolvedConfigMap.app_logo || "").trim()
|
||||
: (siteConfig.app_logo || "").trim();
|
||||
const appFavicon = hasAppFavicon
|
||||
? String(resolvedConfigMap.app_favicon || "").trim()
|
||||
: (siteConfig.app_favicon || "").trim();
|
||||
|
||||
if (appName && appName !== siteConfig.name) {
|
||||
siteConfig.name = appName;
|
||||
// 更新页面标题
|
||||
document.title = appName;
|
||||
}
|
||||
|
||||
siteConfig.app_logo = appLogo;
|
||||
siteConfig.app_favicon = appFavicon;
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
};
|
||||
|
||||
// 清除配置缓存的工具函数
|
||||
// 缓存清除时机:
|
||||
// 1. 配置更新时:调用此函数清除所有缓存
|
||||
// 2. 退出登录时:safeLogout()中的localStorage.clear()会自动清除
|
||||
// 清除配置缓存的工具函数(用于需要强制重拉配置的场景)
|
||||
export const clearConfigCache = (keys?: string[]) => {
|
||||
if (keys && keys.length > 0) {
|
||||
// 删除指定的配置缓存
|
||||
@@ -163,8 +291,13 @@ export const clearConfigCache = (keys?: string[]) => {
|
||||
|
||||
// 在页面加载时异步更新配置(如果有更新的话)
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.title = siteConfig.name;
|
||||
}
|
||||
updateDocumentFavicon(siteConfig.app_favicon);
|
||||
|
||||
// 延迟执行,避免阻塞初始渲染
|
||||
setTimeout(() => {
|
||||
updateSiteConfig();
|
||||
}, 200);
|
||||
void updateSiteConfig();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
useDisclosure,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { VersionFooter } from "@/components/version-footer";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
@@ -330,7 +330,7 @@ export default function AdminLayout({
|
||||
{/* Logo 区域 */}
|
||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||
<Logo size={28} />
|
||||
<BrandLogo size={28} />
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function H5SimpleLayout({
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Logo size={20} />
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { Logo } from "@/components/icons";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { getAdminFlag } from "@/utils/session";
|
||||
import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange";
|
||||
@@ -102,7 +102,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
{/* 顶部导航栏 */}
|
||||
<header className="bg-white dark:bg-black shadow-sm border-b border-gray-200 dark:border-gray-600 h-14 safe-top flex-shrink-0 flex items-center justify-between px-4 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Logo size={20} />
|
||||
<BrandLogo size={20} />
|
||||
<h1 className="text-sm font-bold text-foreground">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
|
||||
@@ -30,7 +30,7 @@ import { SettingsIcon } from "@/components/icons";
|
||||
import { isAdmin } from "@/utils/auth";
|
||||
import {
|
||||
getCachedConfigs,
|
||||
clearConfigCache,
|
||||
configCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
import {
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
import {
|
||||
convertBrandAssetToPngDataURL,
|
||||
isPngDataURL,
|
||||
type BrandAssetKind,
|
||||
} from "@/utils/brand-asset";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
@@ -67,6 +72,19 @@ interface ConfigItem {
|
||||
dependsValue?: string; // 依赖的配置项值
|
||||
}
|
||||
|
||||
const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const;
|
||||
|
||||
type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
|
||||
|
||||
const isBrandPreviewKey = (key: string): key is BrandPreviewKey =>
|
||||
BRAND_PREVIEW_KEYS.includes(key as BrandPreviewKey);
|
||||
|
||||
const BRAND_FILE_ACCEPT = "image/png,image/jpeg,image/webp,image/svg+xml";
|
||||
|
||||
const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
|
||||
return key === "app_logo" ? "logo" : "favicon";
|
||||
};
|
||||
|
||||
// 网站配置项定义
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{
|
||||
@@ -91,6 +109,20 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
||||
description: "在浏览器标签页和导航栏显示的应用名称",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "app_logo",
|
||||
label: "网页角标 Logo",
|
||||
description:
|
||||
"用于页面左上角导航角标,上传后会自动转换为 PNG 并持久化保存",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "app_favicon",
|
||||
label: "浏览器缩略图标",
|
||||
description:
|
||||
"用于浏览器标签页图标,上传后会自动转换为 PNG 并持久化保存",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
key: "captcha_enabled",
|
||||
label: "启用验证码",
|
||||
@@ -143,6 +175,8 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
"cloudflare_secret_key",
|
||||
"ip",
|
||||
"panel_domain",
|
||||
"app_logo",
|
||||
"app_favicon",
|
||||
];
|
||||
const initialConfigs: Record<string, string> = {};
|
||||
|
||||
@@ -179,7 +213,9 @@ export default function ConfigPage() {
|
||||
const [exportSelectorOpen, setExportSelectorOpen] = useState(false);
|
||||
const [importSelectorOpen, setImportSelectorOpen] = useState(false);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const backupFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const logoFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||
content: "",
|
||||
@@ -190,6 +226,12 @@ export default function ConfigPage() {
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [previewLoadFailed, setPreviewLoadFailed] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
const [brandUploading, setBrandUploading] = useState<
|
||||
Partial<Record<BrandPreviewKey, boolean>>
|
||||
>({});
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
@@ -288,6 +330,10 @@ export default function ConfigPage() {
|
||||
|
||||
setConfigs(newConfigs);
|
||||
|
||||
if (isBrandPreviewKey(key)) {
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
|
||||
const hasChangesNow =
|
||||
Object.keys(newConfigs).some(
|
||||
(k) => newConfigs[k] !== originalConfigs[k],
|
||||
@@ -303,25 +349,40 @@ export default function ConfigPage() {
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await updateConfigs(configs);
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
|
||||
if (changedKeys.length === 0) {
|
||||
setHasChanges(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changedPayload: Record<string, string> = {};
|
||||
|
||||
changedKeys.forEach((key) => {
|
||||
changedPayload[key] = configs[key] || "";
|
||||
});
|
||||
|
||||
const response = await updateConfigs(changedPayload);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("配置保存成功");
|
||||
|
||||
// 清除所有配置缓存,强制下次重新获取
|
||||
clearConfigCache();
|
||||
|
||||
// 获取变更的配置项
|
||||
const changedKeys = Object.keys(configs).filter(
|
||||
(key) => configs[key] !== originalConfigs[key],
|
||||
);
|
||||
Object.entries(configs).forEach(([key, value]) => {
|
||||
configCache.set(key, value);
|
||||
});
|
||||
|
||||
setOriginalConfigs({ ...configs });
|
||||
setHasChanges(false);
|
||||
|
||||
// 如果应用名称发生变化,立即更新网站配置
|
||||
if (changedKeys.includes("app_name")) {
|
||||
await updateSiteConfig();
|
||||
if (
|
||||
changedKeys.some((key) =>
|
||||
["app_name", "app_logo", "app_favicon"].includes(key),
|
||||
)
|
||||
) {
|
||||
await updateSiteConfig(configs);
|
||||
}
|
||||
|
||||
// 触发配置更新事件,通知其他组件
|
||||
@@ -349,6 +410,191 @@ export default function ConfigPage() {
|
||||
return configs[item.dependsOn] === item.dependsValue;
|
||||
};
|
||||
|
||||
const getBrandInputRef = (key: BrandPreviewKey) => {
|
||||
return key === "app_logo" ? logoFileInputRef : faviconFileInputRef;
|
||||
};
|
||||
|
||||
const triggerBrandFilePicker = (key: BrandPreviewKey) => {
|
||||
if (brandUploading[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
getBrandInputRef(key).current?.click();
|
||||
};
|
||||
|
||||
const clearBrandAsset = (key: BrandPreviewKey) => {
|
||||
handleConfigChange(key, "");
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }));
|
||||
};
|
||||
|
||||
const handleBrandFileChange = async (
|
||||
key: BrandPreviewKey,
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBrandUploading((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const pngDataURL = await convertBrandAssetToPngDataURL(
|
||||
file,
|
||||
toBrandAssetKind(key),
|
||||
);
|
||||
|
||||
handleConfigChange(key, pngDataURL);
|
||||
toast.success(key === "app_logo" ? "Logo 上传成功" : "Favicon 上传成功");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "图片处理失败,请重试";
|
||||
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setBrandUploading((prev) => ({ ...prev, [key]: false }));
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderBrandPreview = (key: BrandPreviewKey) => {
|
||||
const previewUrl = (configs[key] || "").trim();
|
||||
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
|
||||
const failed = previewLoadFailed[key] === true;
|
||||
const showImage = previewUrl.length > 0 && !failed;
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-lg border border-default-200 dark:border-default-100/30 bg-default-50/60 dark:bg-default-100/10 p-3">
|
||||
<p className="text-xs text-default-500">实时预览</p>
|
||||
<div className="mt-2 rounded-md border border-default-200 dark:border-default-100/30 bg-white dark:bg-black px-3 py-2">
|
||||
{key === "app_logo" ? (
|
||||
<div className="flex h-10 items-center gap-2">
|
||||
{showImage ? (
|
||||
<img
|
||||
alt="logo preview"
|
||||
className="h-7 w-7 rounded-sm border border-default-200 object-cover dark:border-default-100/30"
|
||||
src={previewUrl}
|
||||
onError={() =>
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||
}
|
||||
onLoad={() =>
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-sm bg-default-200 text-[10px] font-semibold text-default-600 dark:bg-default-700 dark:text-default-200">
|
||||
LOGO
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate text-sm font-semibold text-foreground">
|
||||
{appNamePreview}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-7 max-w-[260px] items-center gap-2 rounded border border-default-200 bg-default-100/70 px-2 dark:border-default-100/30 dark:bg-default-100/20">
|
||||
{showImage ? (
|
||||
<img
|
||||
alt="favicon preview"
|
||||
className="h-4 w-4 rounded-sm object-contain"
|
||||
src={previewUrl}
|
||||
onError={() =>
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: true }))
|
||||
}
|
||||
onLoad={() =>
|
||||
setPreviewLoadFailed((prev) => ({ ...prev, [key]: false }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-sm bg-default-300 dark:bg-default-600" />
|
||||
)}
|
||||
<span className="truncate text-xs text-default-700 dark:text-default-300">
|
||||
{appNamePreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewUrl.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-default-500">
|
||||
上传图片后会实时显示预览
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{previewUrl.length > 0 && failed ? (
|
||||
<p className="mt-2 text-xs text-danger">图片加载失败,请重新上传</p>
|
||||
) : null}
|
||||
|
||||
{previewUrl.length > 0 && !isPngDataURL(previewUrl) ? (
|
||||
<p className="mt-2 text-xs text-warning-600 dark:text-warning-400">
|
||||
当前是旧版 URL 配置,建议重新上传图片以启用无闪烁加载
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBrandAssetUploader = (key: BrandPreviewKey, isChanged: boolean) => {
|
||||
const value = (configs[key] || "").trim();
|
||||
const uploading = brandUploading[key] === true;
|
||||
const isLogo = key === "app_logo";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-3 ${
|
||||
isChanged
|
||||
? "border-warning-300"
|
||||
: "border-default-200 dark:border-default-100/30"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
accept={BRAND_FILE_ACCEPT}
|
||||
className="hidden"
|
||||
ref={getBrandInputRef(key)}
|
||||
type="file"
|
||||
onChange={(event) => {
|
||||
void handleBrandFileChange(key, event);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={uploading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => triggerBrandFilePicker(key)}
|
||||
>
|
||||
{value.length > 0
|
||||
? isLogo
|
||||
? "替换 Logo"
|
||||
: "替换 Favicon"
|
||||
: isLogo
|
||||
? "上传 Logo"
|
||||
: "上传 Favicon"}
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={value.length === 0 || uploading}
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => clearBrandAsset(key)}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
<span className="text-xs text-default-500">仅支持图片文件,自动转换为 PNG</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs text-default-500">
|
||||
{isLogo
|
||||
? "建议上传方形图片,系统会统一转换为 96x96 PNG"
|
||||
: "建议上传方形图片,系统会统一转换为 64x64 PNG"}
|
||||
</p>
|
||||
|
||||
{renderBrandPreview(key)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染不同类型的配置项
|
||||
const renderConfigItem = (item: ConfigItem) => {
|
||||
const isChanged =
|
||||
@@ -356,6 +602,10 @@ export default function ConfigPage() {
|
||||
|
||||
switch (item.type) {
|
||||
case "input":
|
||||
if (isBrandPreviewKey(item.key)) {
|
||||
return renderBrandAssetUploader(item.key, isChanged);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
classNames={{
|
||||
@@ -450,7 +700,7 @@ export default function ConfigPage() {
|
||||
}
|
||||
|
||||
setImportSelectorOpen(false);
|
||||
requestAnimationFrame(() => fileInputRef.current?.click());
|
||||
requestAnimationFrame(() => backupFileInputRef.current?.click());
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -487,8 +737,8 @@ export default function ConfigPage() {
|
||||
toast.error("导入失败,请检查文件格式");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
if (backupFileInputRef.current) {
|
||||
backupFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -830,7 +1080,7 @@ export default function ConfigPage() {
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
ref={backupFileInputRef}
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
type="file"
|
||||
|
||||
@@ -503,10 +503,30 @@ export default function ForwardPage() {
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const availableSpeedLimits = useMemo(() => {
|
||||
return speedLimits;
|
||||
const noLimitSpeedLimitIds = useMemo(() => {
|
||||
return new Set(
|
||||
speedLimits
|
||||
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
|
||||
.map((speedLimit) => speedLimit.id),
|
||||
);
|
||||
}, [speedLimits]);
|
||||
|
||||
const availableSpeedLimits = useMemo(() => {
|
||||
return speedLimits.filter(
|
||||
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||
);
|
||||
}, [speedLimits, noLimitSpeedLimitIds]);
|
||||
|
||||
const normalizeSpeedId = (speedId?: number | null): number | null => {
|
||||
if (speedId === null || speedId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
|
||||
};
|
||||
|
||||
const selectedSpeedId = normalizeSpeedId(form.speedId);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
@@ -591,7 +611,7 @@ export default function ForwardPage() {
|
||||
remoteAddr: forward.remoteAddr.split(",").join("\n"),
|
||||
interfaceName: forward.interfaceName || "",
|
||||
strategy: forward.strategy || "fifo",
|
||||
speedId: forward.speedId ?? null,
|
||||
speedId: normalizeSpeedId(forward.speedId),
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
@@ -671,7 +691,7 @@ export default function ForwardPage() {
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: form.speedId,
|
||||
speedId: normalizeSpeedId(form.speedId),
|
||||
};
|
||||
|
||||
res = await updateForward(updateData);
|
||||
@@ -683,7 +703,7 @@ export default function ForwardPage() {
|
||||
inPort: form.inPort,
|
||||
remoteAddr: processedRemoteAddr,
|
||||
strategy: addressCount > 1 ? form.strategy : "fifo",
|
||||
speedId: form.speedId,
|
||||
speedId: normalizeSpeedId(form.speedId),
|
||||
};
|
||||
|
||||
res = await createForward(createData);
|
||||
@@ -2329,10 +2349,9 @@ export default function ForwardPage() {
|
||||
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="不限速"
|
||||
selectedKeys={
|
||||
form.speedId !== null && form.speedId !== undefined
|
||||
? [form.speedId.toString()]
|
||||
selectedSpeedId !== null
|
||||
? [selectedSpeedId.toString()]
|
||||
: ["null"]
|
||||
}
|
||||
variant="bordered"
|
||||
|
||||
@@ -293,37 +293,10 @@ export default function TunnelPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
// 添加节点到指定的转发链跳数
|
||||
const addNodeToChain = (groupIndex: number, nodeId: number) => {
|
||||
setForm((prev) => {
|
||||
const chainNodes = [...(prev.chainNodes || [])];
|
||||
const group = chainNodes[groupIndex] || [];
|
||||
|
||||
// 获取当前组的策略和协议
|
||||
const strategy = group.length > 0 ? group[0].strategy : "round";
|
||||
const protocol = group.length > 0 ? group[0].protocol : "tls";
|
||||
|
||||
// 添加节点到该组
|
||||
chainNodes[groupIndex] = [
|
||||
...group,
|
||||
{ nodeId, chainType: 2, protocol, strategy },
|
||||
];
|
||||
|
||||
return { ...prev, chainNodes };
|
||||
});
|
||||
};
|
||||
|
||||
// 从某一跳删除指定节点
|
||||
const removeNodeFromChain = (groupIndex: number, nodeId: number) => {
|
||||
setForm((prev) => {
|
||||
const chainNodes = [...(prev.chainNodes || [])];
|
||||
|
||||
chainNodes[groupIndex] = (chainNodes[groupIndex] || []).filter(
|
||||
(node) => node.nodeId !== nodeId,
|
||||
);
|
||||
|
||||
return { ...prev, chainNodes };
|
||||
});
|
||||
const toSelectedNodeIds = (keys: Iterable<unknown>): number[] => {
|
||||
return Array.from(keys)
|
||||
.map((key) => Number.parseInt(String(key), 10))
|
||||
.filter((nodeId) => Number.isFinite(nodeId));
|
||||
};
|
||||
|
||||
// 更新某一跳的所有节点的协议
|
||||
@@ -366,6 +339,51 @@ export default function TunnelPage() {
|
||||
return form.chainNodes || [];
|
||||
};
|
||||
|
||||
const mergeOrderedNodes = (
|
||||
currentNodes: ChainTunnel[],
|
||||
selectedNodeIds: number[],
|
||||
buildDefault: (nodeId: number) => ChainTunnel,
|
||||
): ChainTunnel[] => {
|
||||
const selectedSet = new Set(selectedNodeIds);
|
||||
const kept = currentNodes.filter((node) => selectedSet.has(node.nodeId));
|
||||
const keptIds = new Set(kept.map((node) => node.nodeId));
|
||||
const added = selectedNodeIds
|
||||
.filter((nodeId) => !keptIds.has(nodeId))
|
||||
.map((nodeId) => buildDefault(nodeId));
|
||||
|
||||
return [...kept, ...added];
|
||||
};
|
||||
|
||||
const syncChainGroupNodes = (
|
||||
groupIndex: number,
|
||||
selectedNodeIds: number[],
|
||||
) => {
|
||||
setForm((prev) => {
|
||||
const chainNodes = [...(prev.chainNodes || [])];
|
||||
const currentGroup = chainNodes[groupIndex] || [];
|
||||
const protocol = currentGroup[0]?.protocol || "tls";
|
||||
const strategy = currentGroup[0]?.strategy || "round";
|
||||
const realNodes = currentGroup.filter((node) => node.nodeId !== -1);
|
||||
const mergedNodes = mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedNodeIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 2,
|
||||
protocol,
|
||||
strategy,
|
||||
}),
|
||||
);
|
||||
|
||||
chainNodes[groupIndex] =
|
||||
mergedNodes.length > 0
|
||||
? mergedNodes
|
||||
: [{ nodeId: -1, chainType: 2, protocol, strategy }];
|
||||
|
||||
return { ...prev, chainNodes };
|
||||
});
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return;
|
||||
@@ -1197,6 +1215,7 @@ export default function TunnelPage() {
|
||||
<Select
|
||||
description="当节点同时拥有IPv4和IPv6地址时,选择隧道连接使用的地址类型"
|
||||
label="隧道连接地址偏好"
|
||||
placeholder="自动选择"
|
||||
selectedKeys={[form.ipPreference || ""]}
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
@@ -1208,7 +1227,6 @@ export default function TunnelPage() {
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectItem key="">自动选择</SelectItem>
|
||||
<SelectItem key="v4">优先IPv4</SelectItem>
|
||||
<SelectItem key="v6">优先IPv6</SelectItem>
|
||||
</Select>
|
||||
@@ -1238,21 +1256,16 @@ export default function TunnelPage() {
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = Array.from(keys).map((key) =>
|
||||
parseInt(key as string),
|
||||
);
|
||||
const newInNodeId: ChainTunnel[] = selectedIds.map(
|
||||
(nodeId) => {
|
||||
// 保留已有的端口配置
|
||||
const existing = form.inNodeId.find(
|
||||
(ct) => ct.nodeId === nodeId,
|
||||
);
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
|
||||
return existing || { nodeId, chainType: 1 };
|
||||
},
|
||||
);
|
||||
|
||||
setForm((prev) => ({ ...prev, inNodeId: newInNodeId }));
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
inNodeId: mergeOrderedNodes(
|
||||
prev.inNodeId,
|
||||
selectedIds,
|
||||
(nodeId) => ({ nodeId, chainType: 1 }),
|
||||
),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
@@ -1421,37 +1434,10 @@ export default function TunnelPage() {
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = Array.from(
|
||||
keys,
|
||||
).map((key) => parseInt(key as string));
|
||||
const currentNodes = groupNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
syncChainGroupNodes(
|
||||
groupIndex,
|
||||
toSelectedNodeIds(keys),
|
||||
);
|
||||
|
||||
// 找出新增的节点
|
||||
const currentNodeIds = currentNodes.map(
|
||||
(ct) => ct.nodeId,
|
||||
);
|
||||
const addedIds = selectedIds.filter(
|
||||
(id) => !currentNodeIds.includes(id),
|
||||
);
|
||||
const removedIds =
|
||||
currentNodeIds.filter(
|
||||
(id) => !selectedIds.includes(id),
|
||||
);
|
||||
|
||||
// 添加新节点
|
||||
addedIds.forEach((nodeId) => {
|
||||
addNodeToChain(groupIndex, nodeId);
|
||||
});
|
||||
|
||||
// 删除取消选择的节点
|
||||
removedIds.forEach((nodeId) => {
|
||||
removeNodeFromChain(
|
||||
groupIndex,
|
||||
nodeId,
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
@@ -1642,43 +1628,32 @@ export default function TunnelPage() {
|
||||
selectionMode="multiple"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selectedIds = Array.from(keys).map((key) =>
|
||||
parseInt(key as string),
|
||||
);
|
||||
const currentOutNodes = form.outNodeId || [];
|
||||
const selectedIds = toSelectedNodeIds(keys);
|
||||
|
||||
let protocol = "tls";
|
||||
let strategy = "round";
|
||||
setForm((prev) => {
|
||||
const currentOutNodes = prev.outNodeId || [];
|
||||
const protocol =
|
||||
currentOutNodes[0]?.protocol || "tls";
|
||||
const strategy =
|
||||
currentOutNodes[0]?.strategy || "round";
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
|
||||
if (currentOutNodes.length > 0) {
|
||||
protocol = currentOutNodes[0].protocol || "tls";
|
||||
strategy =
|
||||
currentOutNodes[0].strategy || "round";
|
||||
}
|
||||
|
||||
const realNodes = currentOutNodes.filter(
|
||||
(ct) => ct.nodeId !== -1,
|
||||
);
|
||||
const newOutNodeId: ChainTunnel[] =
|
||||
selectedIds.map((nodeId) => {
|
||||
const existing = realNodes.find(
|
||||
(ct) => ct.nodeId === nodeId,
|
||||
);
|
||||
|
||||
return (
|
||||
existing || {
|
||||
return {
|
||||
...prev,
|
||||
outNodeId: mergeOrderedNodes(
|
||||
realNodes,
|
||||
selectedIds,
|
||||
(nodeId) => ({
|
||||
nodeId,
|
||||
chainType: 3,
|
||||
protocol,
|
||||
strategy,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
outNodeId: newOutNodeId,
|
||||
}));
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
@@ -219,6 +219,22 @@ export default function UserPage() {
|
||||
const [speedLimits, setSpeedLimits] = useState<SpeedLimit[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
|
||||
const noLimitSpeedLimitIds = useMemo(() => {
|
||||
return new Set(
|
||||
speedLimits
|
||||
.filter((speedLimit) => speedLimit.name.trim() === "不限速")
|
||||
.map((speedLimit) => speedLimit.id),
|
||||
);
|
||||
}, [speedLimits]);
|
||||
|
||||
const normalizeSpeedId = (speedId?: number | null): number | null => {
|
||||
if (speedId === null || speedId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return noLimitSpeedLimitIds.has(speedId) ? null : speedId;
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -432,7 +448,10 @@ export default function UserPage() {
|
||||
try {
|
||||
const tunnelsToAssign: TunnelAssignItem[] = Array.from(
|
||||
batchTunnelSelections.entries(),
|
||||
).map(([tunnelId, speedId]) => ({ tunnelId, speedId }));
|
||||
).map(([tunnelId, speedId]) => ({
|
||||
tunnelId,
|
||||
speedId: normalizeSpeedId(speedId),
|
||||
}));
|
||||
|
||||
const response = await batchAssignUserTunnel({
|
||||
userId: currentUser.id,
|
||||
@@ -456,6 +475,7 @@ export default function UserPage() {
|
||||
const handleEditTunnel = (userTunnel: UserTunnel) => {
|
||||
setEditTunnelForm({
|
||||
...userTunnel,
|
||||
speedId: normalizeSpeedId(userTunnel.speedId),
|
||||
expTime: userTunnel.expTime,
|
||||
});
|
||||
onEditTunnelModalOpen();
|
||||
@@ -472,7 +492,7 @@ export default function UserPage() {
|
||||
num: editTunnelForm.num,
|
||||
expTime: editTunnelForm.expTime,
|
||||
flowResetTime: editTunnelForm.flowResetTime,
|
||||
speedId: editTunnelForm.speedId,
|
||||
speedId: normalizeSpeedId(editTunnelForm.speedId),
|
||||
status: editTunnelForm.status,
|
||||
});
|
||||
|
||||
@@ -582,12 +602,18 @@ export default function UserPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const editAvailableSpeedLimits = speedLimits;
|
||||
const editAvailableSpeedLimits = speedLimits.filter(
|
||||
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||
);
|
||||
|
||||
const getSpeedLimitsForTunnel = (_tunnelId: number) => {
|
||||
return speedLimits;
|
||||
return speedLimits.filter(
|
||||
(speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id),
|
||||
);
|
||||
};
|
||||
|
||||
const editTunnelSelectedSpeedId = normalizeSpeedId(editTunnelForm?.speedId);
|
||||
|
||||
const toggleTunnelSelection = (tunnelId: number) => {
|
||||
setBatchTunnelSelections((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
@@ -1144,7 +1170,7 @@ export default function UserPage() {
|
||||
classNames={{
|
||||
trigger: "min-h-10 h-10",
|
||||
}}
|
||||
placeholder="限速规则"
|
||||
placeholder="请选择限速规则"
|
||||
selectedKeys={
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
null &&
|
||||
@@ -1403,9 +1429,10 @@ export default function UserPage() {
|
||||
|
||||
<Select
|
||||
label="限速规则"
|
||||
placeholder="请选择限速规则"
|
||||
selectedKeys={
|
||||
editTunnelForm.speedId
|
||||
? [editTunnelForm.speedId.toString()]
|
||||
editTunnelSelectedSpeedId !== null
|
||||
? [editTunnelSelectedSpeedId.toString()]
|
||||
: ["null"]
|
||||
}
|
||||
onSelectionChange={(keys) => {
|
||||
|
||||
@@ -228,11 +228,14 @@ export function Select<T>({
|
||||
|
||||
const selectedArray = Array.from(selected);
|
||||
const singleValue = selectedArray[0] ?? "";
|
||||
const selectedLabels = options
|
||||
.filter((option) => selected.has(option.key))
|
||||
.map((option) => option.label);
|
||||
const resolvedSelectedValues =
|
||||
selectedLabels.length > 0 ? selectedLabels : selectedArray;
|
||||
const optionLabelMap = React.useMemo(() => {
|
||||
return new Map(options.map((option) => [option.key, option.label]));
|
||||
}, [options]);
|
||||
const resolvedSelectedValues = selectedArray.map((key) => {
|
||||
const keyText = String(key);
|
||||
|
||||
return optionLabelMap.get(keyText) ?? keyText;
|
||||
});
|
||||
const selectedFullText = resolvedSelectedValues.join("、");
|
||||
const selectedText =
|
||||
selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择");
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
export type BrandAssetKind = "logo" | "favicon";
|
||||
|
||||
const MAX_BRAND_UPLOAD_BYTES = 2 * 1024 * 1024;
|
||||
const PNG_DATA_URL_PREFIX = "data:image/png;base64,";
|
||||
|
||||
const OUTPUT_SIZE: Record<BrandAssetKind, number> = {
|
||||
logo: 96,
|
||||
favicon: 64,
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== "string") {
|
||||
reject(new Error("读取图片失败"));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(reader.result);
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("图片解析失败"));
|
||||
image.src = src;
|
||||
});
|
||||
};
|
||||
|
||||
const drawContainedPNG = (
|
||||
image: HTMLImageElement,
|
||||
size: number,
|
||||
): string | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
const sourceWidth = image.naturalWidth || image.width;
|
||||
const sourceHeight = image.naturalHeight || image.height;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scale = Math.min(size / sourceWidth, size / sourceHeight);
|
||||
const drawWidth = sourceWidth * scale;
|
||||
const drawHeight = sourceHeight * scale;
|
||||
const drawX = (size - drawWidth) / 2;
|
||||
const drawY = (size - drawHeight) / 2;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
};
|
||||
|
||||
export const isPngDataURL = (value: string): boolean => {
|
||||
return value.startsWith(PNG_DATA_URL_PREFIX);
|
||||
};
|
||||
|
||||
export const convertBrandAssetToPngDataURL = async (
|
||||
file: File,
|
||||
kind: BrandAssetKind,
|
||||
): Promise<string> => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("仅支持上传图片文件");
|
||||
}
|
||||
|
||||
if (file.size > MAX_BRAND_UPLOAD_BYTES) {
|
||||
throw new Error("图片过大,请上传 2MB 以内的文件");
|
||||
}
|
||||
|
||||
const sourceDataURL = await readFileAsDataURL(file);
|
||||
const image = await loadImage(sourceDataURL);
|
||||
const output = drawContainedPNG(image, OUTPUT_SIZE[kind]);
|
||||
|
||||
if (!output) {
|
||||
throw new Error("图片处理失败,请重试");
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
Reference in New Issue
Block a user