siyuan/kernel/model/backup.go

591 lines
16 KiB
Go

// SiYuan - Build Your Eternal Digital Garden
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package model
import (
"bytes"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/88250/gulu"
"github.com/dustin/go-humanize"
"github.com/siyuan-note/encryption"
"github.com/siyuan-note/siyuan/kernel/filesys"
"github.com/siyuan-note/siyuan/kernel/util"
)
type Backup struct {
Size int64 `json:"size"`
HSize string `json:"hSize"`
Updated string `json:"updated"`
SaveDir string `json:"saveDir"` // 本地备份数据存放目录路径
}
type Sync struct {
Size int64 `json:"size"`
HSize string `json:"hSize"`
Updated string `json:"updated"`
CloudName string `json:"cloudName"` // 云端同步数据存放目录名
SaveDir string `json:"saveDir"` // 本地同步数据存放目录路径
}
func RemoveCloudBackup() (err error) {
err = removeCloudDirPath("backup")
return
}
func getCloudAvailableBackupSize() (size int64, err error) {
var sync map[string]interface{}
var assetSize int64
sync, _, assetSize, err = getCloudSpaceOSS()
if nil != err {
return
}
var syncSize int64
if nil != sync {
syncSize = int64(sync["size"].(float64))
}
size = int64(Conf.User.UserSiYuanRepoSize) - syncSize - assetSize
return
}
func GetCloudSpace() (s *Sync, b *Backup, hSize, hAssetSize, hTotalSize string, err error) {
var sync, backup map[string]interface{}
var assetSize int64
sync, backup, assetSize, err = getCloudSpaceOSS()
if nil != err {
return nil, nil, "", "", "", errors.New(Conf.Language(30) + " " + err.Error())
}
var totalSize, syncSize, backupSize int64
var syncUpdated, backupUpdated string
if nil != sync {
syncSize = int64(sync["size"].(float64))
syncUpdated = sync["updated"].(string)
}
s = &Sync{
Size: syncSize,
HSize: humanize.Bytes(uint64(syncSize)),
Updated: syncUpdated,
}
if nil != backup {
backupSize = int64(backup["size"].(float64))
backupUpdated = backup["updated"].(string)
}
b = &Backup{
Size: backupSize,
HSize: humanize.Bytes(uint64(backupSize)),
Updated: backupUpdated,
}
totalSize = syncSize + backupSize + assetSize
hAssetSize = humanize.Bytes(uint64(assetSize))
hSize = humanize.Bytes(uint64(totalSize))
hTotalSize = byteCountSI(int64(Conf.User.UserSiYuanRepoSize))
return
}
func byteCountSI(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
}
func GetLocalBackup() (ret *Backup, err error) {
backupDir := Conf.Backup.GetSaveDir()
if err = os.MkdirAll(backupDir, 0755); nil != err {
return
}
backup, err := os.Stat(backupDir)
ret = &Backup{
Updated: backup.ModTime().Format("2006-01-02 15:04:05"),
SaveDir: Conf.Backup.GetSaveDir(),
}
return
}
func RecoverLocalBackup() (err error) {
if "" == Conf.E2EEPasswd {
return errors.New(Conf.Language(11))
}
data := util.AESDecrypt(Conf.E2EEPasswd)
data, _ = hex.DecodeString(string(data))
passwd := string(data)
CloseWatchAssets()
defer WatchAssets()
// 使用备份恢复时自动暂停同步,避免刚刚恢复后的数据又被同步覆盖 https://github.com/siyuan-note/siyuan/issues/4773
syncEnabled := Conf.Sync.Enabled
Conf.Sync.Enabled = false
Conf.Save()
filesys.ReleaseAllFileLocks()
util.PushEndlessProgress(Conf.Language(63))
util.LogInfof("starting recovery...")
start := time.Now()
decryptedDataDir, err := decryptDataDir(passwd)
if nil != err {
return
}
newDataDir := filepath.Join(util.WorkspaceDir, "data.new")
os.RemoveAll(newDataDir)
if err = os.MkdirAll(newDataDir, 0755); nil != err {
util.ClearPushProgress(100)
return
}
if err = stableCopy(decryptedDataDir, newDataDir); nil != err {
util.ClearPushProgress(100)
return
}
oldDataDir := filepath.Join(util.WorkspaceDir, "data.old")
if err = os.RemoveAll(oldDataDir); nil != err {
util.ClearPushProgress(100)
return
}
// 备份恢复时生成历史 https://github.com/siyuan-note/siyuan/issues/4752
if gulu.File.IsExist(util.DataDir) {
var historyDir string
historyDir, err = util.GetHistoryDir("backup")
if nil != err {
util.LogErrorf("get history dir failed: %s", err)
util.ClearPushProgress(100)
return
}
var dirs []os.DirEntry
dirs, err = os.ReadDir(util.DataDir)
if nil != err {
util.LogErrorf("read dir [%s] failed: %s", util.DataDir, err)
util.ClearPushProgress(100)
return
}
for _, dir := range dirs {
from := filepath.Join(util.DataDir, dir.Name())
to := filepath.Join(historyDir, dir.Name())
if err = os.Rename(from, to); nil != err {
util.LogErrorf("rename [%s] to [%s] failed: %s", from, to, err)
util.ClearPushProgress(100)
return
}
}
}
if gulu.File.IsExist(util.DataDir) {
if err = os.RemoveAll(util.DataDir); nil != err {
util.LogErrorf("remove [%s] failed: %s", util.DataDir, err)
util.ClearPushProgress(100)
return
}
}
if err = os.Rename(newDataDir, util.DataDir); nil != err {
util.ClearPushProgress(100)
util.LogErrorf("rename data dir from [%s] to [%s] failed: %s", newDataDir, util.DataDir, err)
return
}
elapsed := time.Now().Sub(start).Seconds()
size, _ := util.SizeOfDirectory(util.DataDir, false)
sizeStr := humanize.Bytes(uint64(size))
util.LogInfof("recovered backup [size=%s] in [%.2fs]", sizeStr, elapsed)
util.PushEndlessProgress(Conf.Language(62))
time.Sleep(2 * time.Second)
RefreshFileTree()
if syncEnabled {
func() {
time.Sleep(5 * time.Second)
util.PushMsg(Conf.Language(134), 7000)
}()
}
return
}
func CreateLocalBackup() (err error) {
if "" == Conf.E2EEPasswd {
return errors.New(Conf.Language(11))
}
defer util.ClearPushProgress(100)
util.PushEndlessProgress(Conf.Language(22))
WaitForWritingFiles()
filesys.ReleaseAllFileLocks()
util.LogInfof("creating backup...")
start := time.Now()
data := util.AESDecrypt(Conf.E2EEPasswd)
data, _ = hex.DecodeString(string(data))
passwd := string(data)
encryptedDataDir, err := encryptDataDir(passwd)
if nil != err {
util.LogErrorf("encrypt data dir failed: %s", err)
err = errors.New(fmt.Sprintf(Conf.Language(23), formatErrorMsg(err)))
return
}
newBackupDir := Conf.Backup.GetSaveDir() + ".new"
os.RemoveAll(newBackupDir)
if err = os.MkdirAll(newBackupDir, 0755); nil != err {
err = errors.New(fmt.Sprintf(Conf.Language(23), formatErrorMsg(err)))
return
}
if err = stableCopy(encryptedDataDir, newBackupDir); nil != err {
util.LogErrorf("copy encrypted data dir from [%s] to [%s] failed: %s", encryptedDataDir, newBackupDir, err)
err = errors.New(fmt.Sprintf(Conf.Language(23), formatErrorMsg(err)))
return
}
err = genCloudIndex(newBackupDir, map[string]bool{})
if nil != err {
return
}
conf := map[string]interface{}{"updated": time.Now().UnixMilli()}
data, err = gulu.JSON.MarshalJSON(conf)
if nil != err {
util.LogErrorf("marshal backup conf.json failed: %s", err)
} else {
confPath := filepath.Join(newBackupDir, "conf.json")
if err = os.WriteFile(confPath, data, 0644); nil != err {
util.LogErrorf("write backup conf.json [%s] failed: %s", confPath, err)
}
}
oldBackupDir := Conf.Backup.GetSaveDir() + ".old"
os.RemoveAll(oldBackupDir)
backupDir := Conf.Backup.GetSaveDir()
if gulu.File.IsExist(backupDir) {
if err = os.Rename(backupDir, oldBackupDir); nil != err {
util.LogErrorf("rename backup dir from [%s] to [%s] failed: %s", backupDir, oldBackupDir, err)
err = errors.New(fmt.Sprintf(Conf.Language(23), formatErrorMsg(err)))
return
}
}
if err = os.Rename(newBackupDir, backupDir); nil != err {
util.LogErrorf("rename backup dir from [%s] to [%s] failed: %s", newBackupDir, backupDir, err)
err = errors.New(fmt.Sprintf(Conf.Language(23), formatErrorMsg(err)))
return
}
os.RemoveAll(oldBackupDir)
elapsed := time.Now().Sub(start).Seconds()
size, _ := util.SizeOfDirectory(backupDir, false)
sizeStr := humanize.Bytes(uint64(size))
util.LogInfof("created backup [size=%s] in [%.2fs]", sizeStr, elapsed)
util.PushEndlessProgress(Conf.Language(21))
time.Sleep(2 * time.Second)
return
}
func DownloadBackup() (err error) {
// 使用索引文件进行解密验证 https://github.com/siyuan-note/siyuan/issues/3789
var tmpFetchedFiles int
var tmpTransferSize uint64
err = ossDownload0(util.TempDir+"/backup", "backup", "/"+pathJSON, &tmpFetchedFiles, &tmpTransferSize, false)
if nil != err {
return
}
data, err := os.ReadFile(filepath.Join(util.TempDir, "/backup/"+pathJSON))
if nil != err {
return
}
passwdData, _ := hex.DecodeString(string(util.AESDecrypt(Conf.E2EEPasswd)))
passwd := string(passwdData)
data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err {
err = errors.New(Conf.Language(28))
return
}
localDirPath := Conf.Backup.GetSaveDir()
util.PushEndlessProgress(Conf.Language(68))
start := time.Now()
fetchedFilesCount, transferSize, _, err := ossDownload(localDirPath, "backup", false)
if nil == err {
elapsed := time.Now().Sub(start).Seconds()
util.LogInfof("downloaded backup [fetchedFiles=%d, transferSize=%s] in [%.2fs]", fetchedFilesCount, humanize.Bytes(transferSize), elapsed)
util.PushEndlessProgress(Conf.Language(69))
}
return
}
func UploadBackup() (err error) {
defer util.ClearPushProgress(100)
if err = checkUploadBackup(); nil != err {
return
}
localDirPath := Conf.Backup.GetSaveDir()
util.PushEndlessProgress(Conf.Language(61))
util.LogInfof("uploading backup...")
start := time.Now()
wroteFiles, transferSize, err := ossUpload(localDirPath, "backup", "", false)
if nil == err {
elapsed := time.Now().Sub(start).Seconds()
util.LogInfof("uploaded backup [wroteFiles=%d, transferSize=%s] in [%.2fs]", wroteFiles, humanize.Bytes(transferSize), elapsed)
util.PushEndlessProgress(Conf.Language(41))
time.Sleep(2 * time.Second)
return
}
err = errors.New(formatErrorMsg(err))
return
}
var pathJSON = fmt.Sprintf("%x", md5.Sum([]byte("paths.json"))) // 6952277a5a37c17aa6a7c6d86cd507b1
func encryptDataDir(passwd string) (encryptedDataDir string, err error) {
encryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "backup-encrypt")
if err = os.RemoveAll(encryptedDataDir); nil != err {
return
}
if err = os.MkdirAll(encryptedDataDir, 0755); nil != err {
return
}
ctime := map[string]time.Time{}
metaJSON := map[string]string{}
filepath.Walk(util.DataDir, func(path string, info fs.FileInfo, _ error) error {
if util.DataDir == path {
return nil
}
if isCloudSkipFile(path, info) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
plainP := strings.TrimPrefix(path, util.DataDir+string(os.PathSeparator))
p := plainP
parts := strings.Split(p, string(os.PathSeparator))
buf := bytes.Buffer{}
for i, part := range parts {
buf.WriteString(fmt.Sprintf("%x", sha256.Sum256([]byte(part)))[:7])
if i < len(parts)-1 {
buf.WriteString(string(os.PathSeparator))
}
}
p = buf.String()
metaJSON[filepath.ToSlash(p)] = filepath.ToSlash(plainP)
p = encryptedDataDir + string(os.PathSeparator) + p
if info.IsDir() {
if err = os.MkdirAll(p, 0755); nil != err {
return io.EOF
}
if fi, err0 := os.Stat(path); nil == err0 {
ctime[p] = fi.ModTime()
}
} else {
if err = os.MkdirAll(filepath.Dir(p), 0755); nil != err {
return io.EOF
}
f, err0 := os.Create(p)
if nil != err0 {
util.LogErrorf("create file [%s] failed: %s", p, err0)
err = err0
return io.EOF
}
data, err0 := os.ReadFile(path)
if nil != err0 {
util.LogErrorf("read file [%s] failed: %s", path, err0)
err = err0
return io.EOF
}
data, err0 = encryption.AESGCMEncryptBinBytes(data, passwd)
if nil != err0 {
util.LogErrorf("encrypt file [%s] failed: %s", path, err0)
err = errors.New("encrypt file failed")
return io.EOF
}
if _, err0 = f.Write(data); nil != err0 {
util.LogErrorf("write file [%s] failed: %s", p, err0)
err = err0
return io.EOF
}
if err0 = f.Close(); nil != err0 {
util.LogErrorf("close file [%s] failed: %s", p, err0)
err = err0
return io.EOF
}
fi, err0 := os.Stat(path)
if nil != err0 {
util.LogErrorf("stat file [%s] failed: %s", path, err0)
err = err0
return io.EOF
}
ctime[p] = fi.ModTime()
}
return nil
})
if nil != err {
return
}
for p, t := range ctime {
if err = os.Chtimes(p, t, t); nil != err {
return
}
}
// 检查文件是否全部已经编入索引
err = filepath.Walk(encryptedDataDir, func(path string, info fs.FileInfo, _ error) error {
if encryptedDataDir == path {
return nil
}
path = strings.TrimPrefix(path, encryptedDataDir+string(os.PathSeparator))
path = filepath.ToSlash(path)
if _, ok := metaJSON[path]; !ok {
util.LogErrorf("not found backup path in meta [%s]", path)
return errors.New(Conf.Language(27))
}
return nil
})
if nil != err {
return
}
data, err := gulu.JSON.MarshalJSON(metaJSON)
if nil != err {
return
}
data, err = encryption.AESGCMEncryptBinBytes(data, passwd)
if nil != err {
return "", errors.New("encrypt file failed")
}
meta := filepath.Join(encryptedDataDir, pathJSON)
if err = gulu.File.WriteFileSafer(meta, data, 0644); nil != err {
return
}
return
}
func decryptDataDir(passwd string) (decryptedDataDir string, err error) {
decryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "backup-decrypt")
if err = os.RemoveAll(decryptedDataDir); nil != err {
return
}
backupDir := Conf.Backup.GetSaveDir()
meta := filepath.Join(backupDir, pathJSON)
data, err := os.ReadFile(meta)
if nil != err {
return
}
data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err {
return "", errors.New(Conf.Language(40))
}
metaJSON := map[string]string{}
if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
return
}
modTimes := map[string]time.Time{}
err = filepath.Walk(backupDir, func(path string, info fs.FileInfo, _ error) error {
if backupDir == path || pathJSON == info.Name() || strings.HasSuffix(info.Name(), ".json") {
return nil
}
encryptedP := strings.TrimPrefix(path, backupDir+string(os.PathSeparator))
encryptedP = filepath.ToSlash(encryptedP)
plainP := filepath.Join(decryptedDataDir, metaJSON[encryptedP])
plainP = filepath.FromSlash(plainP)
if info.IsDir() {
if err = os.MkdirAll(plainP, 0755); nil != err {
return io.EOF
}
} else {
if err = os.MkdirAll(filepath.Dir(plainP), 0755); nil != err {
return io.EOF
}
var err0 error
data, err0 = os.ReadFile(path)
if nil != err0 {
util.LogErrorf("read file [%s] failed: %s", path, err0)
err = err0
return io.EOF
}
data, err0 = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err0 {
util.LogErrorf("decrypt file [%s] failed: %s", path, err0)
err = errors.New(Conf.Language(40))
return io.EOF
}
if err0 = os.WriteFile(plainP, data, 0644); nil != err0 {
util.LogErrorf("write file [%s] failed: %s", plainP, err0)
err = err0
return io.EOF
}
}
fi, err0 := os.Stat(path)
if nil != err0 {
util.LogErrorf("stat file [%s] failed: %s", path, err0)
err = err0
return io.EOF
}
modTimes[plainP] = fi.ModTime()
return nil
})
for plainP, modTime := range modTimes {
if err = os.Chtimes(plainP, modTime, modTime); nil != err {
return
}
}
return
}