mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-05 17:46:37 +08:00
Merge pull request #102 from Sagit-chu/opencode/quick-comet
feat: 导入导出备份
This commit is contained in:
@@ -172,6 +172,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||||
|
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||||
|
|
||||||
mux.HandleFunc("/flow/test", h.flowTest)
|
mux.HandleFunc("/flow/test", h.flowTest)
|
||||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||||
mux.HandleFunc("/flow/upload", h.flowUpload)
|
mux.HandleFunc("/flow/upload", h.flowUpload)
|
||||||
@@ -1141,3 +1144,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
|||||||
}
|
}
|
||||||
return body.Success
|
return body.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupExportRequest struct {
|
||||||
|
Types []string `json:"types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backupExportRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(req.Types) == 0 {
|
||||||
|
backup, err = h.repo.ExportAll()
|
||||||
|
} else {
|
||||||
|
backup, err = h.repo.ExportPartial(req.Types)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(backup); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupImportRequest struct {
|
||||||
|
Types []string `json:"types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backupImportRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Types) == 0 {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoBackup, err := h.repo.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup sqlite.BackupData
|
||||||
|
if err := decodeJSON(r.Body, &backup); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.repo.Import(&backup, req.Types)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.AutoBackup = autoBackup
|
||||||
|
response.WriteJSON(w, response.OK(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1713,10 +1713,19 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
previousUserIDs, err := queryInt64ListTx(tx, `SELECT user_id FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||||
for _, uid := range req.UserIDs {
|
for _, uid := range req.UserIDs {
|
||||||
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
if err := revokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1748,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tx, err := h.repo.DB().Begin()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
var ug, tg int64
|
var ug, tg int64
|
||||||
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
err = tx.QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission WHERE id = ?`, id)
|
if err != nil && err != sql.ErrNoRows {
|
||||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM group_permission WHERE id = ?`, id); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err := revokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1908,6 +1942,144 @@ func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryInt64ListTx(tx *store.Tx, q string, args ...interface{}) ([]int64, error) {
|
||||||
|
rows, err := tx.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]int64, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeGroupGrantsForRemovedUsersTx(tx *store.Tx, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||||
|
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||||
|
for _, uid := range currentUserIDs {
|
||||||
|
if uid > 0 {
|
||||||
|
currentSet[uid] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removedUserIDs := make([]int64, 0)
|
||||||
|
for _, uid := range previousUserIDs {
|
||||||
|
if uid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := currentSet[uid]; !ok {
|
||||||
|
removedUserIDs = append(removedUserIDs, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(removedUserIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range removedUserIDs {
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT g.user_tunnel_id, g.created_by_group
|
||||||
|
FROM group_permission_grant g
|
||||||
|
JOIN user_tunnel ut ON ut.id = g.user_tunnel_id
|
||||||
|
WHERE g.user_group_id = ? AND ut.user_id = ?
|
||||||
|
`, userGroupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var userTunnelID int64
|
||||||
|
var createdByGroup int
|
||||||
|
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdByGroup == 1 && userTunnelID > 0 {
|
||||||
|
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
DELETE FROM group_permission_grant
|
||||||
|
WHERE user_group_id = ?
|
||||||
|
AND user_tunnel_id IN (SELECT id FROM user_tunnel WHERE user_id = ?)
|
||||||
|
`, userGroupID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
|
var remaining int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeGroupPermissionPairTx(tx *store.Tx, userGroupID, tunnelGroupID int64) error {
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT user_tunnel_id, created_by_group
|
||||||
|
FROM group_permission_grant
|
||||||
|
WHERE user_group_id = ? AND tunnel_group_id = ?
|
||||||
|
`, userGroupID, tunnelGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var userTunnelID int64
|
||||||
|
var createdByGroup int
|
||||||
|
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdByGroup == 1 && userTunnelID > 0 {
|
||||||
|
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
|
var remaining int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now); err != nil {
|
||||||
|
t.Fatalf("insert test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelRes, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||||
|
`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID, err := tunnelRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert user_group: %v", err)
|
||||||
|
}
|
||||||
|
userGroupID, err := ugRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group: %v", err)
|
||||||
|
}
|
||||||
|
tunnelGroupID, err := tgRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
|
||||||
|
t.Fatalf("insert group_permission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
|
||||||
|
bindReq.Header.Set("Authorization", adminToken)
|
||||||
|
bindRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(bindRes, bindReq)
|
||||||
|
assertCode(t, bindRes, 0)
|
||||||
|
|
||||||
|
var userTunnelID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after bind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after bind: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount == 0 {
|
||||||
|
t.Fatalf("expected non-zero grants after bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
|
||||||
|
unbindReq.Header.Set("Authorization", adminToken)
|
||||||
|
unbindRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(unbindRes, unbindReq)
|
||||||
|
assertCode(t, unbindRes, 0)
|
||||||
|
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after unbind: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount != 0 {
|
||||||
|
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after unbind: %v", err)
|
||||||
|
}
|
||||||
|
if userTunnelCount != 0 {
|
||||||
|
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now); err != nil {
|
||||||
|
t.Fatalf("insert test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelRes, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||||
|
`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID, err := tunnelRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert user_group: %v", err)
|
||||||
|
}
|
||||||
|
userGroupID, err := ugRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group: %v", err)
|
||||||
|
}
|
||||||
|
tunnelGroupID, err := tgRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
|
||||||
|
assignTunnelReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignTunnelRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
|
||||||
|
assertCode(t, assignTunnelRes, 0)
|
||||||
|
|
||||||
|
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
|
||||||
|
assignUserReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignUserRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignUserRes, assignUserReq)
|
||||||
|
assertCode(t, assignUserRes, 0)
|
||||||
|
|
||||||
|
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
|
||||||
|
assignPermissionReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignPermissionRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||||
|
assertCode(t, assignPermissionRes, 0)
|
||||||
|
|
||||||
|
var permissionID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
|
||||||
|
t.Fatalf("query group_permission id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after assign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after assign: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount == 0 {
|
||||||
|
t.Fatalf("expected non-zero grants after permission assign")
|
||||||
|
}
|
||||||
|
|
||||||
|
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
|
||||||
|
removeReq.Header.Set("Authorization", adminToken)
|
||||||
|
removeRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(removeRes, removeReq)
|
||||||
|
assertCode(t, removeRes, 0)
|
||||||
|
|
||||||
|
var permissionCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission after remove: %v", err)
|
||||||
|
}
|
||||||
|
if permissionCount != 0 {
|
||||||
|
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after remove: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount != 0 {
|
||||||
|
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after permission remove: %v", err)
|
||||||
|
}
|
||||||
|
if userTunnelCount != 0 {
|
||||||
|
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -228,3 +228,46 @@ export const importRemoteNode = (data: {
|
|||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
}) => Network.post("/federation/node/import", data);
|
}) => Network.post("/federation/node/import", data);
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface BackupTypes {
|
||||||
|
users?: boolean;
|
||||||
|
nodes?: boolean;
|
||||||
|
tunnels?: boolean;
|
||||||
|
forwards?: boolean;
|
||||||
|
userTunnels?: boolean;
|
||||||
|
speedLimits?: boolean;
|
||||||
|
tunnelGroups?: boolean;
|
||||||
|
userGroups?: boolean;
|
||||||
|
permissions?: boolean;
|
||||||
|
configs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportBackup = async (types: string[] = []) => {
|
||||||
|
const token = window.localStorage.getItem("token");
|
||||||
|
const baseURL = axios.defaults.baseURL || "/api/v1/";
|
||||||
|
|
||||||
|
const response = await axios.post(`${baseURL}/backup/export`, { types }, {
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
|
||||||
|
link.setAttribute("download", `backup_${timestamp}.json`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importBackup = (data: {
|
||||||
|
types: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
}) => Network.post("/backup/import", data);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
|
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { updateConfigs } from "@/api";
|
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||||
import { SettingsIcon } from "@/components/icons";
|
import { SettingsIcon } from "@/components/icons";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
@@ -130,12 +131,19 @@ export default function ConfigPage() {
|
|||||||
useState<Record<string, string>>(initialConfigs);
|
useState<Record<string, string>>(initialConfigs);
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
Object.keys(initialConfigs).length === 0,
|
Object.keys(initialConfigs).length === 0,
|
||||||
); // 如果有缓存数据,不显示loading
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [originalConfigs, setOriginalConfigs] =
|
const [originalConfigs, setOriginalConfigs] =
|
||||||
useState<Record<string, string>>(initialConfigs);
|
useState<Record<string, string>>(initialConfigs);
|
||||||
|
|
||||||
|
const [exportTypes, setExportTypes] = useState<string[]>([]);
|
||||||
|
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importFileName, setImportFileName] = useState("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
@@ -331,6 +339,60 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (exportTypes.length === 0) {
|
||||||
|
toast.error("请至少选择一种数据类型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportBackup(exportTypes);
|
||||||
|
toast.success("导出成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("导出失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (importTypes.length === 0) {
|
||||||
|
toast.error("请先选择要导入的数据类型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportFileName(file.name);
|
||||||
|
setImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
const response = await importBackup({
|
||||||
|
types: importTypes,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code === 0) {
|
||||||
|
toast.success(`导入成功: ${JSON.stringify(response.data)}`);
|
||||||
|
setImportTypes([]);
|
||||||
|
setImportFileName("");
|
||||||
|
} else {
|
||||||
|
toast.error("导入失败: " + response.msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("导入失败,请检查文件格式");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -427,6 +489,143 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 备份与恢复 */}
|
||||||
|
<Card className="mt-6 shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
导出或导入系统数据,支持选择特定数据类型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<CardBody className="space-y-6 pt-6">
|
||||||
|
{/* 导出部分 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">导出数据</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
选择要导出的数据类型,导出为 JSON 格式文件
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxGroup
|
||||||
|
label="选择导出内容"
|
||||||
|
orientation="horizontal"
|
||||||
|
classNames={{
|
||||||
|
wrapper: "gap-4",
|
||||||
|
}}
|
||||||
|
value={exportTypes}
|
||||||
|
onValueChange={(values) => setExportTypes(values as string[])}
|
||||||
|
>
|
||||||
|
<Checkbox value="users">用户</Checkbox>
|
||||||
|
<Checkbox value="nodes">节点</Checkbox>
|
||||||
|
<Checkbox value="tunnels">隧道</Checkbox>
|
||||||
|
<Checkbox value="forwards">转发</Checkbox>
|
||||||
|
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||||
|
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||||
|
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||||
|
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||||
|
<Checkbox value="permissions">分组权限</Checkbox>
|
||||||
|
<Checkbox value="configs">系统配置</Checkbox>
|
||||||
|
</CheckboxGroup>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={exporting}
|
||||||
|
onPress={handleExport}
|
||||||
|
>
|
||||||
|
{exporting ? "导出中..." : "导出数据"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onPress={() => {
|
||||||
|
setExportTypes([
|
||||||
|
"users",
|
||||||
|
"nodes",
|
||||||
|
"tunnels",
|
||||||
|
"forwards",
|
||||||
|
"userTunnels",
|
||||||
|
"speedLimits",
|
||||||
|
"tunnelGroups",
|
||||||
|
"userGroups",
|
||||||
|
"permissions",
|
||||||
|
"configs",
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => setExportTypes([])}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 导入部分 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">导入数据</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
选择要导入的数据类型,支持从备份文件恢复数据
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxGroup
|
||||||
|
label="选择导入内容"
|
||||||
|
orientation="horizontal"
|
||||||
|
classNames={{
|
||||||
|
wrapper: "gap-4",
|
||||||
|
}}
|
||||||
|
value={importTypes}
|
||||||
|
onValueChange={(values) => setImportTypes(values as string[])}
|
||||||
|
>
|
||||||
|
<Checkbox value="users">用户</Checkbox>
|
||||||
|
<Checkbox value="nodes">节点</Checkbox>
|
||||||
|
<Checkbox value="tunnels">隧道</Checkbox>
|
||||||
|
<Checkbox value="forwards">转发</Checkbox>
|
||||||
|
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||||
|
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||||
|
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||||
|
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||||
|
<Checkbox value="permissions">分组权限</Checkbox>
|
||||||
|
<Checkbox value="configs">系统配置</Checkbox>
|
||||||
|
</CheckboxGroup>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
isLoading={importing}
|
||||||
|
onPress={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{importing ? "导入中..." : "选择文件导入"}
|
||||||
|
</Button>
|
||||||
|
{importFileName && (
|
||||||
|
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
已选择: {importFileName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user