فهرست منبع

init from backup-db

jie 3 سال پیش
کامیت
de997656aa

+ 44 - 0
.github/workflows/docker-image.yml

@@ -0,0 +1,44 @@
+name: docker hub release
+
+# build master with multi-arch to docker hub
+
+on:
+  push:
+    # Sequence of patterns matched against refs/tags
+    tags:
+    - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+ 
+jobs:
+  buildx-dockerhub:
+    runs-on: ubuntu-latest
+    env:
+      DOCKER_REPO: jeessy/backup-x
+      DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
+      DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
+      DOCKER_PLATFORMS: linux/amd64,linux/arm,linux/arm64
+      DOCKER_REGISTRY: ""
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+      - name: Set up Docker buildx
+        id: buildx
+        uses: crazy-max/ghaction-docker-buildx@v3
+      - name: Prepare arguments
+        id: prepare
+        run: |
+          DOCKER_TAGS="--tag ${DOCKER_REPO}:edge"
+          if [[ $GITHUB_REF == refs/tags/v* ]]; then
+            DOCKER_TAGS="--tag ${DOCKER_REPO}:latest --tag ${DOCKER_REPO}:${GITHUB_REF#refs/tags/}"
+          fi
+          echo ::set-output name=buildx_args:: --output "type=image,push=true" --platform ${DOCKER_PLATFORMS} ${DOCKER_TAGS} .
+      - name: Docker login
+        run: |
+          echo "${DOCKER_PASSWORD}" | docker login "${DOCKER_REGISTRY}"  \
+            --username "${DOCKER_USERNAME}" \
+            --password-stdin
+      - name: Run buildx and push
+        if: success()
+        run: docker buildx build ${{ steps.prepare.outputs.buildx_args }}
+      - name: Docker Hub logout
+        if: always()
+        run: docker logout

+ 32 - 0
.github/workflows/release.yml

@@ -0,0 +1,32 @@
+name: release
+
+on:
+  push:
+    # Sequence of patterns matched against refs/tags
+    tags:
+    - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+
+jobs:
+  goreleaser:
+    name: Build
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.17
+
+      - name: Run GoReleaser
+        uses: goreleaser/goreleaser-action@v2
+        if: startsWith(github.ref, 'refs/tags/')
+        with:
+          distribution: goreleaser
+          version: latest
+          args: release --rm-dist
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+*.exe*
+__debug_bin
+backup-x-files
+.DS_Store

+ 37 - 0
.goreleaser.yml

@@ -0,0 +1,37 @@
+# This is an example goreleaser.yaml file with some sane defaults.
+# Make sure to check the documentation at http://goreleaser.com
+before:
+  hooks:
+    # You may remove this if you don't use go modules.
+    - go mod download
+    # you may remove this if you don't need go generate
+    - go generate ./...
+builds:
+  - env:
+      - CGO_ENABLED=0
+    goos:
+      - linux
+      - windows
+      - darwin
+    goarch:
+      - 386
+      - amd64
+      - arm
+      - arm64
+archives:
+  - replacements:
+      darwin: Darwin
+      linux: Linux
+      windows: Windows
+      386: i386
+      amd64: x86_64
+checksum:
+  name_template: 'checksums.txt'
+snapshot:
+  name_template: "{{ .Tag }}-next"
+changelog:
+  sort: asc
+  filters:
+    exclude:
+      - '^docs:'
+      - '^test:'

+ 17 - 0
.vscode/launch.json

@@ -0,0 +1,17 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "client",
+            "type": "go",
+            "request": "launch",
+            "mode": "auto",
+            "program": "${workspaceFolder}/main.go",
+            "env": {},
+            "args": []
+        }
+    ]
+}

+ 27 - 0
Dockerfile

@@ -0,0 +1,27 @@
+# build stage
+FROM golang:1.17 AS builder
+
+WORKDIR /app
+COPY . .
+RUN go env -w GO111MODULE=on \
+    && go env -w GOPROXY=https://goproxy.cn,direct \
+    && make clean build
+
+# final stage
+FROM debian:stable-slim
+
+LABEL name=backup-x
+LABEL url=https://github.com/jeessy2/backup-x
+
+VOLUME /app/backup-x-files
+
+WORKDIR /app
+RUN apt-get -y update  \
+    && apt-get install -y ca-certificates curl  \
+    && apt-get install -y postgresql-client \
+    && apt-get install -y default-mysql-client
+
+ENV TZ=Asia/Shanghai
+COPY --from=builder /app/backup-x /app/backup-x
+EXPOSE 9977
+ENTRYPOINT ["/app/backup-x"]

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 jeessy
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 30 - 0
Makefile

@@ -0,0 +1,30 @@
+.PHONY: build clean test test-race
+
+VERSION=v2.0.0
+BIN=backup-x
+DIR_SRC=.
+DOCKER_CMD=docker
+
+GO_ENV=CGO_ENABLED=0
+GO_FLAGS=-ldflags="-X main.version=$(VERSION) -X 'main.buildTime=`date`' -extldflags -static"
+GO=$(GO_ENV) $(shell which go)
+GOROOT=$(shell `which go` env GOROOT)
+GOPATH=$(shell `which go` env GOPATH)
+
+build: $(DIR_SRC)/main.go
+	@$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
+
+build_image:
+	@$(DOCKER_CMD) build -f ./Dockerfile -t backup-x:$(VERSION) .
+
+test:
+	@$(GO) test ./...
+
+test-race:
+	@$(GO) test -race ./...
+
+# clean all build result
+clean:
+	@$(GO) clean ./...
+	@rm -f $(BIN)
+	@rm -rf ./dist/*

+ 20 - 0
README-EN.md

@@ -0,0 +1,20 @@
+# backup-x
+  A database backup tool with web interfaces.
+  - [x] Support custom commands.
+  - [x] Obsolete files will be deleted automatically.
+  - [x] Support the backup files copy to simple data storage(s3).
+  - [x] Automatic backup in everyday night.
+  - [x] Webhook support
+
+## use in docker
+  ```
+    docker run -d \
+    --name backup-x \
+    --restart=always \
+    -p 9977:9977 \
+    -v /opt/backup-x-files:/app/backup-x-files \
+    jeessy/backup-x
+  ```
+
+  ![avatar](https://raw.githubusercontent.com/jeessy2/backup-x/master/backup-x-web.png)
+

+ 68 - 0
README.md

@@ -0,0 +1,68 @@
+<a href="https://github.com/jeessy2/backup-x/releases/latest"><img alt="GitHub release" src="https://img.shields.io/github/release/jeessy2/backup-x.svg?logo=github&style=flat-square"></a>
+# backup-x
+  原理:执行自定义shell命令输出文件,并增强备份功能。支持能通过shell命令的备份的数据库(mysql/postgres/mariadb...), 同时也支持通过shell打包 [English](README-EN.md)
+  - [x] 支持自定义命令
+  - [x] 网页中配置,简单又方便
+  - [x] 支持多个项目备份,最多16个
+  - [x] 支持备份后的文件另存到对象存储(在也怕硬盘坏了)
+  - [x] 每日凌晨自动备份
+  - [x] 可设置备份文件最大保存天数
+  - [x] 可设置登陆用户名密码,默认为空
+  - [x] webhook通知
+
+## docker中使用
+- 运行docker容器
+  ```
+  docker run -d \
+    --name backup-x \
+    --restart=always \
+    -p 9977:9977 \
+    -v /opt/backup-x-files:/app/backup-x-files \
+    jeessy/backup-x
+  ```
+- 登录 http://your_docker_ip:9977 并配置
+  ![avatar](https://raw.githubusercontent.com/jeessy2/backup-x/master/backup-x-web.png)
+
+
+## 备份脚本参考
+ - postgres
+
+    |  说明   | 备份脚本  |
+    |  ----  | ----  |
+    | 备份单个  | PGPASSWORD="password" pg_dump --host 192.168.1.11 --port 5432 --dbname db-name --user postgres --clean --create --file #{DATE}.sql |
+    | 备份全部  | PGPASSWORD="password" pg_dumpall --host 192.168.1.11 --port 5432 --user postgres --clean --file #{DATE}.sql |
+    | 还原  | psql -U postgres -f 2021-11-12_10_29.sql |
+
+ -  mysql/mariadb
+
+    |  说明   | 备份脚本  |
+    |  ----  | ----  |
+    | 备份单个  | mysqldump -h192.168.1.11 -uroot -p123456 db-name > #{DATE}.sql |
+    | 备份全部  | mysqldump -h192.168.1.11 -uroot -p123456 --all-databases > #{DATE}.sql |
+    | 还原  | mysql -uroot -p123456 db-name <2021-11-12_10_29.sql |
+
+## webhook
+- 支持webhook, 备份更新成功或不成功时, 会回调填写的URL
+- 支持的变量
+
+  |  变量名   | 说明  |
+  |  ----  | ----  |
+  | #{projectName}  | 项目名称 |
+  | #{fileName}  | 备份后的文件名称 |
+  | #{fileSize}  | 文件大小 (MB) |
+  | #{result}  | 备份结果(成功/失败) |
+
+- RequestBody为空GET请求,不为空POST请求
+- Server酱: `https://sc.ftqq.com/[SCKEY].send?text=#{projectName}项目备份#{result},文件名:#{fileName},文件大小:#{fileSize}`
+- Bark: `https://api.day.app/[YOUR_KEY]/#{projectName}项目备份#{result},文件名:#{fileName},文件大小:#{fileSize}`
+- 钉钉:
+  - 钉钉电脑端 -> 群设置 -> 智能群助手 -> 添加机器人 -> 自定义
+  - 只勾选 `自定义关键词`, 输入的关键字必须包含在RequestBody的content中, 如:`项目备份`
+  - URL中输入钉钉给你的 `Webhook地址`
+  - RequestBody中输入 `{"msgtype": "text","text": {"content": "#{projectName}项目备份#{result},文件名:#{fileName},文件大小:#{fileSize}"}}`
+
+## 说明
+  - v1版本开始发生重要变化,不兼容0.0.x
+  - v1后开始使用web方式来配置
+  - 如要加入https,可通过nginx代理
+  - v2版本后,一个镜像同时支持postgres/mysql

BIN
backup-x-web.png


+ 138 - 0
client/backup.go

@@ -0,0 +1,138 @@
+package client
+
+import (
+	"backup-x/entity"
+	"backup-x/util"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// StartBackup start backup db
+func StartBackup() {
+	for {
+		RunOnce()
+		// sleep to tomorrow night
+		sleep()
+	}
+}
+
+// RunOnce 运行一次
+func RunOnce() {
+	conf, err := entity.GetConfigCache()
+	if err != nil {
+		return
+	}
+	// 迭代所有项目
+	for _, backupConf := range conf.BackupConfig {
+		if backupConf.NotEmptyProject() {
+			err := prepare(backupConf)
+			if err != nil {
+				log.Println(err)
+				continue
+			}
+			// backup
+			outFileName, err := backup(backupConf)
+			result := entity.BackupResult{ProjectName: backupConf.ProjectName, Result: "失败"}
+			if err == nil {
+				// webhook
+				result.FileName = outFileName.Name()
+				result.FileSize = fmt.Sprintf("%d MB", outFileName.Size()/1000/1000)
+				result.Result = "成功"
+				// send file to s3
+				go conf.UploadFile(backupConf.GetProjectPath() + string(os.PathSeparator) + outFileName.Name())
+			}
+			conf.ExecWebhook(result)
+		}
+	}
+}
+
+// prepare
+func prepare(backupConf entity.BackupConfig) (err error) {
+	// create floder
+	os.MkdirAll(backupConf.GetProjectPath(), 0750)
+
+	if !strings.Contains(backupConf.Command, "#{DATE}") {
+		err = errors.New("项目: " + backupConf.ProjectName + "的备份脚本须包含#{DATE}")
+	}
+
+	return
+}
+
+func backup(backupConf entity.BackupConfig) (outFileName os.FileInfo, err error) {
+	projectName := backupConf.ProjectName
+	log.Printf("正在备份项目: %s ...", projectName)
+
+	todayString := time.Now().Format("2006-01-02_03_04")
+	shellString := strings.ReplaceAll(backupConf.Command, "#{DATE}", todayString)
+
+	// create shell file
+	shellName := time.Now().Format("shell-2006-01-02-03-04-") + "backup.sh"
+
+	shellFile, err := os.Create(backupConf.GetProjectPath() + string(os.PathSeparator) + shellName)
+	shellFile.Chmod(0700)
+	if err == nil {
+		shellFile.WriteString(shellString)
+		shellFile.Close()
+	} else {
+		log.Println("Create file with error: ", err)
+	}
+
+	// run shell file
+	shell := exec.Command("bash", shellName)
+	shell.Dir = backupConf.GetProjectPath()
+	outputBytes, err := shell.CombinedOutput()
+	if len(outputBytes) > 0 {
+		log.Printf("<span title=\"%s\">执行shell的输出:鼠标移动此处查看</span>", util.EscapeShell(string(outputBytes)))
+	} else {
+		log.Printf("执行shell的输出为空")
+	}
+
+	// execute shell success
+	if err == nil {
+		// find backup file by todayString
+		outFileName, err = findBackupFile(backupConf, todayString)
+
+		// check file size
+		if err != nil {
+			log.Println(err)
+		} else if outFileName.Size() >= 500 {
+			log.Printf("成功备份项目: %s, 文件名: %s\n", projectName, outFileName.Name())
+			// success, remove shell file
+			os.Remove(shellFile.Name())
+		} else {
+			err = errors.New(projectName + " 备份后的文件大小小于500字节, 当前大小:" + strconv.Itoa(int(outFileName.Size())))
+			log.Println(err)
+		}
+	} else {
+		err = fmt.Errorf("执行备份shell失败: %s", util.EscapeShell(string(outputBytes)))
+		log.Println(err)
+	}
+
+	return
+}
+
+// find backup file by todayString
+func findBackupFile(backupConf entity.BackupConfig, todayString string) (backupFile os.FileInfo, err error) {
+	files, err := ioutil.ReadDir(backupConf.GetProjectPath())
+	for _, file := range files {
+		if strings.Contains(file.Name(), todayString) {
+			backupFile = file
+			return
+		}
+	}
+	err = errors.New("不能找到备份后的文件,没有找到包含 " + todayString + " 的文件名")
+	return
+}
+
+func sleep() {
+	sleepHours := 24 - time.Now().Hour()
+	log.Println("下次运行时间:", sleepHours, "hours")
+	time.Sleep(time.Hour * time.Duration(sleepHours))
+}

+ 54 - 0
client/delete_old_file.go

@@ -0,0 +1,54 @@
+package client
+
+import (
+	"backup-x/entity"
+	"backup-x/util"
+	"io/ioutil"
+	"log"
+	"os"
+	"strconv"
+	"time"
+)
+
+// DeleteOldBackup for client
+func DeleteOldBackup() {
+	// sleep 30 minutes
+	time.Sleep(30 * time.Minute)
+	for {
+		conf, err := entity.GetConfigCache()
+		if err == nil {
+			for _, backupConf := range conf.BackupConfig {
+				// read from current path
+				backupFiles, err := ioutil.ReadDir(backupConf.GetProjectPath())
+				if err != nil {
+					log.Println("Read dir with error :", err)
+					continue
+				}
+
+				// delete client files
+				ago := time.Now()
+				for _, conf := range conf.BackupConfig {
+					lastDay, _ := time.ParseDuration("-" + strconv.Itoa(conf.SaveDays*24) + "h")
+					ago = ago.Add(lastDay)
+
+					// delete older file when file numbers gt MaxSaveDays
+					for _, backupFile := range backupFiles {
+						if backupFile.ModTime().Before(ago) {
+							filepath := backupConf.GetProjectPath() + "/" + backupFile.Name()
+							err := os.Remove(filepath)
+							if err != nil {
+								log.Printf("删除过期的文件 %s 失败", filepath)
+							} else {
+								log.Printf("删除过期的文件 %s 成功", filepath)
+							}
+						}
+					}
+				}
+			}
+
+		}
+		// sleep
+		util.SleepForFileDelete()
+	}
+
+}

+ 9 - 0
client/index.go

@@ -0,0 +1,9 @@
+package client
+
+// RunCycle 周期运行
+func RunCycle() {
+	// delete old backup
+	go DeleteOldBackup()
+	// start client
+	go StartBackup()
+}

+ 102 - 0
entity/config.go

@@ -0,0 +1,102 @@
+package entity
+
+import (
+	"io/ioutil"
+	"log"
+	"os"
+	"sync"
+
+	"gopkg.in/yaml.v2"
+)
+
+// ParentSavePath Parent Save Path
+const ParentSavePath = "backup-x-files"
+
+func init() {
+	_, err := os.Stat(ParentSavePath)
+	if err != nil {
+		os.Mkdir(ParentSavePath, 0750)
+	}
+}
+
+// Config yml格式的配置文件
+// go的实体需大写对应config.yml的key, key全部小写
+type Config struct {
+	User
+	BackupConfig []BackupConfig
+	Webhook
+	S3Config
+}
+
+// ConfigCache ConfigCache
+type cacheType struct {
+	ConfigSingle *Config
+	Err          error
+	Lock         sync.Mutex
+}
+
+var cache = &cacheType{}
+
+// GetConfigCache 获得配置
+func GetConfigCache() (conf Config, err error) {
+
+	if cache.ConfigSingle != nil {
+		return *cache.ConfigSingle, cache.Err
+	}
+
+	cache.Lock.Lock()
+	defer cache.Lock.Unlock()
+
+	// init config
+	cache.ConfigSingle = &Config{}
+
+	configFilePath := getConfigFilePath()
+	_, err = os.Stat(configFilePath)
+	if err != nil {
+		log.Println("没有找到配置文件!请在网页中输入")
+		cache.Err = err
+		return *cache.ConfigSingle, err
+	}
+
+	byt, err := ioutil.ReadFile(configFilePath)
+	if err != nil {
+		log.Println("config.yaml读取失败")
+		cache.Err = err
+		return *cache.ConfigSingle, err
+	}
+
+	err = yaml.Unmarshal(byt, cache.ConfigSingle)
+	if err != nil {
+		log.Println("反序列化配置文件失败", err)
+		cache.Err = err
+		return *cache.ConfigSingle, err
+	}
+	// remove err
+	cache.Err = nil
+	return *cache.ConfigSingle, err
+}
+
+// SaveConfig 保存配置
+func (conf *Config) SaveConfig() (err error) {
+	byt, err := yaml.Marshal(conf)
+	if err != nil {
+		log.Println(err)
+		return err
+	}
+
+	err = ioutil.WriteFile(getConfigFilePath(), byt, 0600)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	// 清空配置缓存
+	cache.ConfigSingle = nil
+
+	return
+}
+
+// GetConfigFilePath 获得配置文件路径, 保存到备份目录下
+func getConfigFilePath() string {
+	return ParentSavePath + string(os.PathSeparator) + ".backup_x_config.yaml"
+}

+ 18 - 0
entity/config_backup.go

@@ -0,0 +1,18 @@
+package entity
+
+// BackupConfig 备份配置
+type BackupConfig struct {
+	ProjectName string // 项目名称
+	Command     string // 命令
+	SaveDays    int
+}
+
+// GetProjectPath 获得项目路径
+func (backupConfig *BackupConfig) GetProjectPath() string {
+	return ParentSavePath + "/" + backupConfig.ProjectName
+}
+
+// NotEmptyProject 是不是空的项目
+func (backupConfig *BackupConfig) NotEmptyProject() bool {
+	return backupConfig.Command != "" && backupConfig.ProjectName != ""
+}

+ 9 - 0
entity/result.go

@@ -0,0 +1,9 @@
+package entity
+
+// BackupResult BackupResult
+type BackupResult struct {
+	ProjectName string // 项目名称
+	FileName    string
+	FileSize    string
+	Result      string
+}

+ 101 - 0
entity/s3_config.go

@@ -0,0 +1,101 @@
+package entity
+
+import (
+	"errors"
+	"log"
+	"os"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/s3"
+	"github.com/aws/aws-sdk-go/service/s3/s3manager"
+)
+
+// S3Config S3Config
+type S3Config struct {
+	Endpoint   string
+	AccessKey  string
+	SecretKey  string
+	BucketName string
+}
+
+func (s3Config S3Config) checkNotEmpty() bool {
+	return s3Config.Endpoint != "" && s3Config.AccessKey != "" &&
+		s3Config.SecretKey != "" && s3Config.BucketName != ""
+}
+
+func (s3Config S3Config) getSession() (*session.Session, error) {
+
+	if !s3Config.checkNotEmpty() {
+		return nil, errors.New("s3 config is empty")
+	}
+
+	creds := credentials.NewStaticCredentials(s3Config.AccessKey, s3Config.SecretKey, "")
+	_, err := creds.Get()
+	if err != nil {
+		log.Println(err)
+	}
+
+	config := &aws.Config{
+		Region:           aws.String("cn-north-1"),
+		Endpoint:         aws.String(s3Config.Endpoint),
+		DisableSSL:       aws.Bool(false),
+		Credentials:      creds,
+		S3ForcePathStyle: aws.Bool(true),
+	}
+
+	mySession, err := session.NewSession(config)
+	return mySession, err
+}
+
+func (s3Config S3Config) CreateBucketIfNotExist() {
+	mySession, err := s3Config.getSession()
+	if err != nil {
+		return
+	}
+	client := s3.New(mySession)
+
+	head := &s3.HeadBucketInput{
+		Bucket: aws.String(s3Config.BucketName),
+	}
+	_, err = client.HeadBucket(head)
+
+	if err != nil {
+		create := &s3.CreateBucketInput{
+			Bucket: aws.String(s3Config.BucketName),
+		}
+		_, err = client.CreateBucket(create)
+		if err != nil {
+			log.Printf("创建bucket: %s 失败, ERR: %s\n", s3Config.BucketName, err)
+		} else {
+			log.Printf("创建bucket: %s 成功\n", s3Config.BucketName)
+		}
+	}
+}
+
+func (s3Config S3Config) UploadFile(fileName string) {
+	mySession, err := s3Config.getSession()
+	if err != nil {
+		return
+	}
+
+	file, err := os.Open(fileName)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+	defer file.Close()
+
+	uploader := s3manager.NewUploader(mySession)
+	_, err = uploader.Upload(&s3manager.UploadInput{
+		Bucket: aws.String(s3Config.BucketName),
+		Key:    aws.String(fileName),
+		Body:   file,
+	})
+	if err != nil {
+		log.Printf("%s 上传到对象存储失败. ERR: %s \n", fileName, err)
+	} else {
+		log.Printf("%s 上传到对象存储成功\n", fileName)
+	}
+}

+ 7 - 0
entity/user.go

@@ -0,0 +1,7 @@
+package entity
+
+// User 服务端配置
+type User struct {
+	Username string
+	Password string
+}

+ 78 - 0
entity/webhook.go

@@ -0,0 +1,78 @@
+package entity
+
+import (
+	"backup-x/util"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+)
+
+// Webhook Webhook
+type Webhook struct {
+	WebhookURL         string
+	WebhookRequestBody string
+}
+
+// ExecWebhook 添加或更新IPv4/IPv6记录
+func (webhook Webhook) ExecWebhook(result BackupResult) {
+
+	if webhook.WebhookURL != "" {
+		// 成功和失败都要触发webhook
+		method := "GET"
+		postPara := ""
+		contentType := "application/x-www-form-urlencoded"
+		if webhook.WebhookRequestBody != "" {
+			method = "POST"
+			postPara = webhook.replaceBody(result)
+			if json.Valid([]byte(postPara)) {
+				contentType = "application/json"
+			}
+		}
+		requestURL := webhook.replaceURL(result)
+		u, err := url.Parse(requestURL)
+		if err != nil {
+			log.Println("Webhook配置中的URL不正确")
+			return
+		}
+		req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s?%s", u.Scheme, u.Host, u.Path, u.Query().Encode()), strings.NewReader(postPara))
+		if err != nil {
+			log.Println("创建Webhook请求异常, Err:", err)
+			return
+		}
+		req.Header.Add("content-type", contentType)
+
+		clt := http.Client{}
+		clt.Timeout = 30 * time.Second
+		resp, err := clt.Do(req)
+		body, err := util.GetHTTPResponseOrg(resp, requestURL, err)
+		if err == nil {
+			log.Println(fmt.Sprintf("Webhook调用成功, 返回数据: %s", string(body)))
+		} else {
+			log.Println(fmt.Sprintf("Webhook调用失败,Err:%s", err))
+		}
+	}
+}
+
+// replaceURL 替换url
+func (webhook Webhook) replaceURL(result BackupResult) (newBody string) {
+	newBody = strings.ReplaceAll(webhook.WebhookURL, "#{projectName}", result.ProjectName)
+	newBody = strings.ReplaceAll(newBody, "#{fileName}", result.FileName)
+	newBody = strings.ReplaceAll(newBody, "#{fileSize}", result.FileSize)
+	newBody = strings.ReplaceAll(newBody, "#{result}", result.Result)
+
+	return newBody
+}
+
+// replaceBody 替换body
+func (webhook Webhook) replaceBody(result BackupResult) (newURL string) {
+	newURL = strings.ReplaceAll(webhook.WebhookRequestBody, "#{projectName}", result.ProjectName)
+	newURL = strings.ReplaceAll(newURL, "#{fileName}", result.FileName)
+	newURL = strings.ReplaceAll(newURL, "#{fileSize}", result.FileSize)
+	newURL = strings.ReplaceAll(newURL, "#{result}", result.Result)
+
+	return newURL
+}

BIN
favicon.ico


+ 10 - 0
go.mod

@@ -0,0 +1,10 @@
+module backup-x
+
+go 1.17
+
+require (
+	github.com/aws/aws-sdk-go v1.42.3
+	gopkg.in/yaml.v2 v2.4.0
+)
+
+require github.com/jmespath/go-jmespath v0.4.0 // indirect

+ 25 - 0
go.sum

@@ -0,0 +1,25 @@
+github.com/aws/aws-sdk-go v1.42.3 h1:lBKr3tQ06m1uykiychMNKLK1bRfOzaIEQpsI/S3QiNc=
+github.com/aws/aws-sdk-go v1.42.3/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

+ 45 - 0
main.go

@@ -0,0 +1,45 @@
+package main
+
+import (
+	"backup-x/web"
+	"embed"
+	"os"
+
+	"log"
+	"net/http"
+	"time"
+)
+
+var defaultPort = "9977"
+
+//go:embed static
+var staticEmbededFiles embed.FS
+
+//go:embed favicon.ico
+var faviconEmbededFile embed.FS
+
+func main() {
+	// 启动静态文件服务
+	http.Handle("/static/", http.FileServer(http.FS(staticEmbededFiles)))
+	http.Handle("/favicon.ico", http.FileServer(http.FS(faviconEmbededFile)))
+
+	http.HandleFunc("/", web.BasicAuth(web.WritingConfig))
+	http.HandleFunc("/save", web.BasicAuth(web.Save))
+	http.HandleFunc("/logs", web.BasicAuth(web.Logs))
+	http.HandleFunc("/webhookTest", web.BasicAuth(web.WebhookTest))
+
+	// 运行
+	go web.Run()
+
+	if os.Getenv("port") != "" {
+		defaultPort = os.Getenv("port")
+	}
+
+	err := http.ListenAndServe(":"+defaultPort, nil)
+
+	if err != nil {
+		log.Println("启动端口发生异常, 请检查端口是否被占用", err)
+		time.Sleep(time.Minute)
+	}
+
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
static/bootstrap.min.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 5 - 0
static/bootstrap.min.js


+ 46 - 0
static/common.css

@@ -0,0 +1,46 @@
+body {
+    background-color: #f2f3f8;
+}
+
+.portlet {
+    display: -webkit-box;
+    display: flex;
+    -webkit-box-flex: 1;
+    flex-grow: 1;
+    -webkit-box-orient: vertical;
+    -webkit-box-direction: normal;
+    flex-direction: column;
+    box-shadow: 0px 0px 13px 3px rgba(82, 63, 105, 0.05);
+    background-color: #ffffff;
+    margin-bottom: 20px;
+    border-radius: 4px;
+}
+
+.portlet .portlet__head {
+    display: flex;
+    -webkit-box-align: stretch;
+    -webkit-box-pack: justify;
+    justify-content: space-between;
+    position: relative;
+    padding: 0 20px;
+    margin: 0;
+    border-bottom: 1px solid #ebedf2;
+    min-height: 60px;
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+    align-items: center;
+    font-size: 1.2rem;
+    font-weight: 540;
+    color: #48465b;
+}
+
+.portlet .portlet__body {
+    display: -webkit-box;
+    display: -ms-flexbox;
+    -webkit-box-orient: vertical;
+    -webkit-box-direction: normal;
+    -ms-flex-direction: column;
+    flex-direction: column;
+    padding: 20px;
+    border-radius: 4px;
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/jquery-3.5.1.min.js


+ 26 - 0
util/file_util.go

@@ -0,0 +1,26 @@
+package util
+
+import (
+	"log"
+	"os"
+	"time"
+)
+
+// PathExists Get path exist
+func PathExists(path string) bool {
+	_, err := os.Stat(path)
+	if err == nil {
+		return true
+	}
+	if os.IsNotExist(err) {
+		return false
+	}
+	return false
+}
+
+// SleepForFileDelete Sleep For File Delete
+func SleepForFileDelete() {
+	sleepHours := 24 - time.Now().Hour()
+	log.Printf("%d小时后再次运行:删除过期的备份文件", sleepHours)
+	time.Sleep(time.Hour * time.Duration(sleepHours))
+}

+ 50 - 0
util/http_util.go

@@ -0,0 +1,50 @@
+package util
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net/http"
+)
+
+// GetHTTPResponse 处理HTTP结果,返回序列化的json
+func GetHTTPResponse(resp *http.Response, url string, err error, result interface{}) error {
+	body, err := GetHTTPResponseOrg(resp, url, err)
+
+	if err == nil {
+		// log.Println(string(body))
+		err = json.Unmarshal(body, &result)
+
+		if err != nil {
+			log.Printf("请求接口%s解析json结果失败! ERROR: %s\n", url, err)
+		}
+	}
+
+	return err
+
+}
+
+// GetHTTPResponseOrg 处理HTTP结果,返回byte
+func GetHTTPResponseOrg(resp *http.Response, url string, err error) ([]byte, error) {
+	if err != nil {
+		log.Printf("请求接口%s失败! ERROR: %s\n", url, err)
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	body, err := ioutil.ReadAll(resp.Body)
+
+	if err != nil {
+		log.Printf("请求接口%s失败! ERROR: %s\n", url, err)
+	}
+
+	// 300及以上状态码都算异常
+	if resp.StatusCode >= 300 {
+		errMsg := fmt.Sprintf("请求接口 %s 失败! 返回内容: %s ,返回状态码: %d\n", url, string(body), resp.StatusCode)
+		log.Println(errMsg)
+		err = fmt.Errorf(errMsg)
+	}
+
+	return body, err
+}

+ 11 - 0
util/string_util.go

@@ -0,0 +1,11 @@
+package util
+
+import (
+	"strings"
+)
+
+// EscapeShell 转义shell输出
+func EscapeShell(org string) (dst string) {
+	// 双引号使用单引号替换
+	return strings.ReplaceAll(org, "\"", "'")
+}

+ 58 - 0
web/basic_auth.go

@@ -0,0 +1,58 @@
+package web
+
+import (
+	"backup-x/entity"
+	"bytes"
+	"encoding/base64"
+	"log"
+	"net/http"
+	"strings"
+)
+
+// ViewFunc func
+type ViewFunc func(http.ResponseWriter, *http.Request)
+
+// BasicAuth basic auth
+func BasicAuth(f ViewFunc) ViewFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		conf, _ := entity.GetConfigCache()
+
+		// 帐号或密码为空。跳过
+		if conf.Username == "" && conf.Password == "" {
+			// 执行被装饰的函数
+			f(w, r)
+			return
+		}
+
+		// 认证帐号密码
+		basicAuthPrefix := "Basic "
+
+		// 获取 request header
+		auth := r.Header.Get("Authorization")
+		// 如果是 http basic auth
+		if strings.HasPrefix(auth, basicAuthPrefix) {
+			// 解码认证信息
+			payload, err := base64.StdEncoding.DecodeString(
+				auth[len(basicAuthPrefix):],
+			)
+			if err == nil {
+				pair := bytes.SplitN(payload, []byte(":"), 2)
+				if len(pair) == 2 &&
+					bytes.Equal(pair[0], []byte(conf.Username)) &&
+					bytes.Equal(pair[1], []byte(conf.Password)) {
+					// 执行被装饰的函数
+					f(w, r)
+					return
+				}
+			}
+			log.Printf("%s 登陆失败!\n", r.RemoteAddr)
+		}
+
+		// 认证失败,提示 401 Unauthorized
+		// Restricted 可以改成其他的值
+		w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
+		// 401 状态码
+		w.WriteHeader(http.StatusUnauthorized)
+		log.Printf("%s 请求登陆!\n", r.RemoteAddr)
+	}
+}

+ 39 - 0
web/logs.go

@@ -0,0 +1,39 @@
+package web
+
+import (
+	"io"
+	"log"
+	"net/http"
+	"os"
+)
+
+// MemoryLogs 内存中的日志
+type MemoryLogs struct {
+	MaxNum int      // 保存最大条数
+	Logs   []string // 日志
+}
+
+func (mlogs *MemoryLogs) Write(p []byte) (n int, err error) {
+	mlogs.Logs = append(mlogs.Logs, string(p))
+	// 处理日志数量
+	if len(mlogs.Logs) > mlogs.MaxNum {
+		mlogs.Logs = mlogs.Logs[len(mlogs.Logs)-mlogs.MaxNum:]
+	}
+	return len(p), nil
+}
+
+var mlogs = &MemoryLogs{MaxNum: 50}
+
+// 初始化日志
+func init() {
+	log.SetOutput(io.MultiWriter(os.Stdout, mlogs))
+	// log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
+}
+
+// Logs web
+func Logs(writer http.ResponseWriter, request *http.Request) {
+	for _, log := range mlogs.Logs {
+		writer.Write([]byte(log))
+		writer.Write([]byte("<br/>"))
+	}
+}

+ 15 - 0
web/run.go

@@ -0,0 +1,15 @@
+package web
+
+import (
+	"backup-x/client"
+)
+
+// Run run
+func Run() {
+	client.RunCycle()
+}
+
+// RunOnce run
+func RunOnce() {
+	client.RunOnce()
+}

+ 57 - 0
web/save.go

@@ -0,0 +1,57 @@
+package web
+
+import (
+	"backup-x/entity"
+	"net/http"
+	"strconv"
+	"strings"
+)
+
+// Save 保存
+func Save(writer http.ResponseWriter, request *http.Request) {
+	conf := &entity.Config{}
+
+	// 覆盖以前的配置
+	conf.Username = strings.TrimSpace(request.FormValue("Username"))
+	conf.Password = request.FormValue("Password")
+
+	forms := request.PostForm
+	for index, projectName := range forms["ProjectName"] {
+		saveDays, _ := strconv.Atoi(forms["SaveDays"][index])
+		conf.BackupConfig = append(
+			conf.BackupConfig,
+			entity.BackupConfig{
+				ProjectName: projectName,
+				Command:     forms["Command"][index],
+				SaveDays:    saveDays,
+			},
+		)
+	}
+
+	// Webhook
+	conf.WebhookURL = strings.TrimSpace(request.FormValue("WebhookURL"))
+	conf.WebhookRequestBody = strings.TrimSpace(request.FormValue("WebhookRequestBody"))
+
+	// S3
+	conf.Endpoint = strings.TrimSpace(request.FormValue("Endpoint"))
+	conf.AccessKey = strings.TrimSpace(request.FormValue("AccessKey"))
+	conf.SecretKey = strings.TrimSpace(request.FormValue("SecretKey"))
+	conf.BucketName = strings.TrimSpace(request.FormValue("BucketName"))
+
+	// 保存到用户目录
+	err := conf.SaveConfig()
+
+	// 没有错误,运行一次
+	if err == nil {
+		conf.CreateBucketIfNotExist()
+		go RunOnce()
+	}
+
+	// 回写错误信息
+	if err == nil {
+		writer.Write([]byte("ok"))
+	} else {
+		writer.Write([]byte(err.Error()))
+	}
+
+}

+ 21 - 0
web/webhookTest.go

@@ -0,0 +1,21 @@
+package web
+
+import (
+	"backup-x/entity"
+	"log"
+	"net/http"
+	"strings"
+)
+
+// WebhookTest 测试webhook
+func WebhookTest(writer http.ResponseWriter, request *http.Request) {
+	url := strings.TrimSpace(request.FormValue("URL"))
+	requestBody := strings.TrimSpace(request.FormValue("RequestBody"))
+	if url != "" {
+		wb := entity.Webhook{WebhookURL: url, WebhookRequestBody: requestBody}
+		wb.ExecWebhook(entity.BackupResult{ProjectName: "模拟测试", FileName: "2021-11-11_01_01.sql", FileSize: "100 MB", Result: "成功"})
+	} else {
+		log.Println("请输入Webhook的URL")
+	}
+
+}

+ 39 - 0
web/writing.go

@@ -0,0 +1,39 @@
+package web
+
+import (
+	"backup-x/entity"
+	"embed"
+	"html/template"
+	"log"
+	"net/http"
+)
+
+//go:embed writing.html
+var writingEmbedFile embed.FS
+
+// WritingConfig 填写配置信息
+func WritingConfig(writer http.ResponseWriter, request *http.Request) {
+	tmpl, err := template.ParseFS(writingEmbedFile, "writing.html")
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	conf, err := entity.GetConfigCache()
+	if err == nil {
+		tmpl.Execute(writer, conf)
+		return
+	}
+
+	// default config
+	// 获得环境变量
+	backupConf := []entity.BackupConfig{}
+	for i := 0; i < 16; i++ {
+		backupConf = append(backupConf, entity.BackupConfig{SaveDays: 30})
+	}
+	conf = entity.Config{
+		BackupConfig: backupConf,
+	}
+
+	tmpl.Execute(writer, conf)
+}

+ 270 - 0
web/writing.html

@@ -0,0 +1,270 @@
+<html lang="zh">
+
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta name="author" content="jie">
+  <title>backup-x</title>
+  <!-- Bootstrap CSS -->
+  <link rel="stylesheet" href="/static/bootstrap.min.css">
+  <link rel="stylesheet" href="/static/common.css">
+  <script src="/static/jquery-3.5.1.min.js"></script>
+  <script src="/static/bootstrap.min.js"></script>
+</head>
+
+<body>
+  <header>
+    <div class="navbar navbar-dark bg-dark shadow-sm">
+      <div class="container d-flex justify-content-between">
+        <a href="/" class="navbar-brand d-flex align-items-center">
+          <strong>backup-x</strong>
+        </a>
+      </div>
+    </div>
+  </header>
+  
+  <main role="main" style="margin-top: 30px">
+    <div class="row">
+      <div class="col-md-6 offset-md-3">
+        <form>
+
+          <button class="btn btn-primary submit_btn" style="margin-bottom: 15px;">Save</button>
+
+          <div class="alert alert-success" style="display: none;">
+            <strong id="resultMsg">保存成功</strong>
+          </div>
+
+          <div class="portlet">
+            <h5 class="portlet__head">备份设置</h5>
+            <div class="portlet__body">
+              <nav>
+                <div class="nav nav-tabs" id="nav-tab" role="tablist">
+                  {{range $i, $v := .BackupConfig}}
+                  <a class="nav-item nav-link {{if eq $i 0}}active{{end}}" id="id_{{$i}}" data-toggle="tab" href="#content_{{$i}}" role="tab">
+                    {{if eq $v.ProjectName ""}}
+                    {{$i}}
+                    {{else}}
+                    {{$v.ProjectName}}
+                    {{end}}
+                  </a>
+                  {{end}}
+                </div>
+              </nav>
+              <div class="tab-content" id="nav-tabContent">
+                {{range $i, $v := .BackupConfig}}
+                <div class="tab-pane fade {{if eq $i 0}}show active{{end}}" id="content_{{$i}}" role="tabpanel">
+                  <br/>
+                  <div class="form-group row">
+                    <label for="ProjectName_{{$i}}" class="col-sm-2 col-form-label">项目名称</label>
+                    <div class="col-sm-10">
+                      <input class="form-control" name="ProjectName" id="ProjectName_{{$i}}" rows="3" value="{{$v.ProjectName}}" onchange="projectNameChange(this)" aria-describedby="ProjectName_help">
+                      <small id="ProjectName_help" class="form-text text-muted">请输入项目名称,一般取数据库名称,并确保名称不重复</small>
+                    </div>
+                  </div>
+            
+                  <div class="form-group row">
+                    <label for="Command_{{$i}}" class="col-sm-2 col-form-label">备份脚本</label>
+                    <div class="col-sm-10">
+                      <textarea class="form-control" name="Command" id="Command_{{$i}}" rows="3" aria-describedby="Command_help">{{$v.Command}}</textarea>
+                      <small id="Command_help" class="form-text text-muted">
+                        须包含#{DATE}变量 <a target="blank" href="https://github.com/jeessy2/backup-x#备份脚本参考">备份脚本参考</a> 
+                        <br/>例:mysqldump -h192.168.1.11 -uroot -p123456 db-name > #{DATE}.sql
+                      </small>
+                    </div>
+                  </div>
+
+                  <div class="form-group row">
+                    <label for="SaveDays_{{$i}}" class="col-sm-2 col-form-label">保存天数</label>
+                    <div class="col-sm-10">
+                      <input type="number" class="form-control" name="SaveDays" id="SaveDays_{{$i}}" value="{{$v.SaveDays}}" min="1">
+                    </div>
+                  </div>
+
+                </div>
+                {{end}}
+              </div>
+
+            </div>
+          </div>
+
+          <div class="portlet">
+            <h5 class="portlet__head">服务配置</h5>
+            <div class="portlet__body">
+
+              <div class="form-group row">
+                <label for="Username" class="col-sm-2 col-form-label">登录用户名</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="Username" id="Username" value="{{.Username}}" aria-describedby="Username_help">
+                  <small id="Username_help" class="form-text text-muted">强烈建议输入</small>
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label for="Password" class="col-sm-2 col-form-label">登录密码</label>
+                <div class="col-sm-10">
+                  <input class="form-control" type="password" name="Password" id="Password" value="{{.Password}}" aria-describedby="password_help">
+                  <small id="password_help" class="form-text text-muted">强烈建议输入</small>
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+          <div class="portlet">
+            <h5 class="portlet__head">Webhook通知</h5>
+            <div class="portlet__body">
+
+              <div class="form-group row">
+                <label for="WebhookURL" class="col-sm-2 col-form-label">URL</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="WebhookURL" id="WebhookURL" value="{{.WebhookURL}}" aria-describedby="WebhookURL_help">
+                  <small id="WebhookURL_help" class="form-text text-muted">
+                    <a target="blank" href="https://github.com/jeessy2/backup-x#webhook">点击参考官方Webhook说明</a><br/>
+                    支持的变量#{projectName}, #{fileName}, #{fileSize}, #{result}, 
+                  </small>
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label for="WebhookRequestBody" class="col-sm-2 col-form-label">RequestBody</label>
+                <div class="col-sm-10">
+                  <textarea class="form-control" id="WebhookRequestBody" name="WebhookRequestBody" rows="3" aria-describedby="WebhookRequestBody_help">
+{{- .WebhookRequestBody -}}
+                  </textarea>
+                  <small id="WebhookRequestBody_help" class="form-text text-muted">
+                    RequestBody为空GET请求,不为空POST请求。支持的变量同上
+                  </small>
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label class="col-sm-2 col-form-label"></label>
+                <div class="col-sm-10">
+                  <button class="btn btn-primary btn-sm" id="webhookTestBtn" aria-describedby="webhookTestBtn_help">模拟测试Webhook</button>
+                  <small id="webhookTestBtn_help" class="form-text text-muted"></small>
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+          <div class="portlet">
+            <h5 class="portlet__head">对象存储配置</h5>
+            <div class="portlet__body">
+
+              <div class="form-group row">
+                <label for="Endpoint" class="col-sm-2 col-form-label">Endpoint</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="Endpoint" id="Endpoint" value="{{.Endpoint}}" aria-describedby="Endpoint_help">
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label for="AccessKey" class="col-sm-2 col-form-label">AccessKey</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="AccessKey" id="AccessKey" value="{{.AccessKey}}" aria-describedby="AccessKey_help">
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label for="SecretKey" class="col-sm-2 col-form-label">SecretKey</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="SecretKey" id="SecretKey" value="{{.SecretKey}}" aria-describedby="SecretKey_help">
+                </div>
+              </div>
+
+              <div class="form-group row">
+                <label for="BucketName" class="col-sm-2 col-form-label">BucketName</label>
+                <div class="col-sm-10">
+                  <input class="form-control" name="BucketName" id="BucketName" value="{{.BucketName}}" aria-describedby="BucketName_help">
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+          <button class="btn btn-primary submit_btn" style="margin-bottom: 15px;">Save</button>
+
+        </form>
+      </div>
+
+      <div class="col-md-3">
+        <p class="font-weight-light text-break" style="margin-top: 115px;font-size: 13px;" id="logs"></p>
+      </div>
+    </div>
+
+  </main>
+
+  <script>
+
+    $(function(){
+      $(".submit_btn").on('click',function(e) {
+        e.preventDefault();
+        $('body').animate({ scrollTop: 0 }, 300);
+        $.ajax({
+          method: "POST",
+          url: "/save",
+          data: $('form').serialize(),
+          success: function (result) {
+            $('.alert').css("display", "block");
+            if (result !== "ok") {
+              $('.alert').addClass("alert-danger")
+              $('#resultMsg').html(result)
+            } else {
+              // ok
+              setTimeout(function(){
+                $('.alert').css("display", "none");
+              }, 3000)
+            }
+          },
+          error: function(jqXHR) {
+            alert(jqXHR.statusText);
+          }
+        })
+      })
+
+    })
+
+    // projectNameChange
+    function projectNameChange(that) {
+      let id = $(that).attr("id").split("_")[1]
+      console.log($(that).val())
+      $("#id_"+id).html($(that).val())
+    }
+
+  </script>
+
+  <script>
+    function getLogs() {
+      $.get("/logs", function(result){
+        $("#logs").html(result)
+      })
+    }
+    getLogs()
+    setInterval(getLogs, 5 * 1000)
+  </script>
+
+  <script>
+    $(function(){
+      $("#webhookTestBtn").on("click", function(e) {
+        e.preventDefault();
+        $.ajax({
+            method: "POST",
+            url: "/webhookTest",
+            data: {"URL": $("#WebhookURL").val(), "RequestBody": $("#WebhookRequestBody").val()},
+            success: function() {
+              $("#webhookTestBtn_help").text("提交模拟测试成功, 如修改记得保存配置")
+              setTimeout(function(){
+                $("#webhookTestBtn_help").text("")
+              }, 5000)
+            },
+            error: function(jqXHR) {
+              alert(jqXHR.statusText);
+            }
+          })
+      })
+    })
+  </script>
+
+</html>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است