功能: - 前台导航: 分类Tab切换、实时搜索、健康状态指示、响应式适配 - 后台管理: 服务/分类CRUD、系统设置、登录认证(bcrypt) - 健康检查: 定时检测(5min)、独立检查URL、三态指示(在线/离线/未检测) - 云端备份: WebDAV上传/下载/恢复/删除、定时自动备份、本地备份管理 技术栈: Go + Gin + GORM + SQLite
260 lines
5.8 KiB
Go
260 lines
5.8 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type WebDAVClient struct {
|
|
URL string
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
// BackupInfo 备份文件信息
|
|
type BackupInfo struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ModTime string `json:"mod_time"`
|
|
}
|
|
|
|
func NewWebDAVClient(url, user, pass string) *WebDAVClient {
|
|
if !strings.HasSuffix(url, "/") {
|
|
url += "/"
|
|
}
|
|
return &WebDAVClient{URL: url, Username: user, Password: pass}
|
|
}
|
|
|
|
func (w *WebDAVClient) Upload(localPath, remoteName string) error {
|
|
file, err := os.Open(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
req, err := http.NewRequest("PUT", w.URL+remoteName, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(w.Username, w.Password)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("upload failed: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Download 从 WebDAV 下载文件
|
|
func (w *WebDAVClient) Download(remoteName, localPath string) error {
|
|
req, err := http.NewRequest("GET", w.URL+remoteName, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(w.Username, w.Password)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("download failed: %s", resp.Status)
|
|
}
|
|
|
|
out, err := os.Create(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// Delete 删除 WebDAV 上的文件
|
|
func (w *WebDAVClient) Delete(remoteName string) error {
|
|
req, err := http.NewRequest("DELETE", w.URL+remoteName, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.SetBasicAuth(w.Username, w.Password)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("delete failed: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WebDAV PROPFIND XML 结构
|
|
type multiStatus struct {
|
|
Responses []davResponse `xml:"response"`
|
|
}
|
|
|
|
type davResponse struct {
|
|
Href string `xml:"href"`
|
|
PropStat []propStat `xml:"propstat"`
|
|
}
|
|
|
|
type propStat struct {
|
|
Prop davProp `xml:"prop"`
|
|
Status string `xml:"status"`
|
|
}
|
|
|
|
type davProp struct {
|
|
ContentLength int64 `xml:"getcontentlength"`
|
|
LastModified string `xml:"getlastmodified"`
|
|
}
|
|
|
|
// List 列出云端备份,返回详细信息
|
|
func (w *WebDAVClient) List() ([]BackupInfo, error) {
|
|
req, err := http.NewRequest("PROPFIND", w.URL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Depth", "1")
|
|
req.SetBasicAuth(w.Username, w.Password)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
// 尝试 XML 解析获取详细信息
|
|
var ms multiStatus
|
|
re := regexp.MustCompile(`tonav_backup_[0-9_]+\.db`)
|
|
|
|
if xml.Unmarshal(body, &ms) == nil && len(ms.Responses) > 0 {
|
|
var list []BackupInfo
|
|
seen := make(map[string]bool)
|
|
for _, r := range ms.Responses {
|
|
matches := re.FindAllString(r.Href, -1)
|
|
for _, name := range matches {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
info := BackupInfo{Name: name}
|
|
if len(r.PropStat) > 0 {
|
|
info.Size = r.PropStat[0].Prop.ContentLength
|
|
info.ModTime = r.PropStat[0].Prop.LastModified
|
|
}
|
|
// 从文件名解析时间
|
|
if info.ModTime == "" {
|
|
info.ModTime = parseTimeFromName(name)
|
|
}
|
|
list = append(list, info)
|
|
}
|
|
}
|
|
// 按名称倒序(最新的在前)
|
|
sort.Slice(list, func(i, j int) bool {
|
|
return list[i].Name > list[j].Name
|
|
})
|
|
return list, nil
|
|
}
|
|
|
|
// 回退:正则匹配
|
|
matches := re.FindAllString(string(body), -1)
|
|
unique := make(map[string]bool)
|
|
var list []BackupInfo
|
|
for _, m := range matches {
|
|
if !unique[m] {
|
|
unique[m] = true
|
|
list = append(list, BackupInfo{
|
|
Name: m,
|
|
ModTime: parseTimeFromName(m),
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(list, func(i, j int) bool {
|
|
return list[i].Name > list[j].Name
|
|
})
|
|
return list, nil
|
|
}
|
|
|
|
// parseTimeFromName 从备份文件名解析时间
|
|
func parseTimeFromName(name string) string {
|
|
re := regexp.MustCompile(`(\d{8})_(\d{6})`)
|
|
m := re.FindStringSubmatch(name)
|
|
if len(m) == 3 {
|
|
t, err := time.Parse("20060102_150405", m[1]+"_"+m[2])
|
|
if err == nil {
|
|
return t.Format("2006-01-02 15:04:05")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CreateBackup 创建本地备份,存放到 backups/ 目录
|
|
func CreateBackup(dbPath string) (string, error) {
|
|
// 确保 backups 目录存在
|
|
backupDir := "backups"
|
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
|
return "", fmt.Errorf("创建备份目录失败: %v", err)
|
|
}
|
|
|
|
timestamp := time.Now().Format("20060102_150405")
|
|
backupName := fmt.Sprintf("tonav_backup_%s.db", timestamp)
|
|
backupPath := filepath.Join(backupDir, backupName)
|
|
|
|
input, err := os.ReadFile(dbPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("读取数据库失败: %v", err)
|
|
}
|
|
err = os.WriteFile(backupPath, input, 0644)
|
|
if err != nil {
|
|
return "", fmt.Errorf("写入备份文件失败: %v", err)
|
|
}
|
|
return backupPath, nil
|
|
}
|
|
|
|
// CleanOldBackups 清理本地旧备份,保留最近 keep 份
|
|
func CleanOldBackups(keep int) {
|
|
backupDir := "backups"
|
|
entries, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
re := regexp.MustCompile(`^tonav_backup_\d{8}_\d{6}\.db$`)
|
|
var backups []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && re.MatchString(e.Name()) {
|
|
backups = append(backups, e.Name())
|
|
}
|
|
}
|
|
|
|
// 按名称排序(时间戳命名,字母序即时间序)
|
|
sort.Strings(backups)
|
|
|
|
// 删除多余的旧备份
|
|
if len(backups) > keep {
|
|
for _, name := range backups[:len(backups)-keep] {
|
|
os.Remove(filepath.Join(backupDir, name))
|
|
}
|
|
}
|
|
}
|