siyuan/kernel/model/sync.go

1359 lines
35 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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/sha256"
"errors"
"fmt"
"io"
"io/fs"
"math"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/88250/gulu"
"github.com/dustin/go-humanize"
"github.com/emirpasic/gods/sets/hashset"
"github.com/mattn/go-zglob"
"github.com/siyuan-note/encryption"
"github.com/siyuan-note/siyuan/kernel/cache"
"github.com/siyuan-note/siyuan/kernel/filesys"
"github.com/siyuan-note/siyuan/kernel/sql"
"github.com/siyuan-note/siyuan/kernel/treenode"
"github.com/siyuan-note/siyuan/kernel/util"
)
var (
syncSameCount = 0
syncDownloadErrCount = 0
fixSyncInterval = 5 * time.Minute
syncInterval = fixSyncInterval
syncPlanTime = time.Now().Add(syncInterval)
BootSyncSucc = -1 // -1未执行0执行成功1执行失败
ExitSyncSucc = -1
)
func AutoSync() {
for {
time.Sleep(5 * time.Second)
if time.Now().After(syncPlanTime) {
SyncData(false, false, false)
syncPlanTime = time.Now().Add(syncInterval)
}
}
}
func SyncData(boot, exit, byHand bool) {
defer util.Recover()
if util.IsMutexLocked(&syncLock) {
util.LogWarnf("sync has been locked")
syncInterval = 30 * time.Second
return
}
if boot {
util.IncBootProgress(3, "Syncing data from the cloud...")
BootSyncSucc = 0
}
if exit {
ExitSyncSucc = 0
}
if !IsSubscriber() || !Conf.Sync.Enabled || "" == Conf.Sync.CloudName || "" == Conf.E2EEPasswd {
if byHand {
if "" == Conf.Sync.CloudName {
util.PushMsg(Conf.Language(123), 5000)
} else if "" == Conf.E2EEPasswd {
util.PushMsg(Conf.Language(11), 5000)
} else if !Conf.Sync.Enabled {
util.PushMsg(Conf.Language(124), 5000)
}
}
return
}
if !IsValidCloudDirName(Conf.Sync.CloudName) {
return
}
if boot {
util.LogInfof("sync before boot")
}
if exit {
util.LogInfof("sync before exit")
util.PushMsg(Conf.Language(81), 1000*60*15)
}
if 7 < syncDownloadErrCount && !byHand {
util.LogErrorf("sync download error too many times, cancel auto sync, try to sync by hand")
util.PushErrMsg(Conf.Language(125), 1000*60*60)
syncInterval = 64 * time.Minute
return
}
now := util.CurrentTimeMillis()
Conf.Sync.Synced = now
util.BroadcastByType("main", "syncing", 0, Conf.Language(81), nil)
defer func() {
synced := util.Millisecond2Time(Conf.Sync.Synced).Format("2006-01-02-15:04:05") + "\n\n" + Conf.Sync.Stat
msg := fmt.Sprintf(Conf.Language(82), synced)
Conf.Sync.Stat = msg
Conf.Save()
util.BroadcastByType("main", "syncing", 1, msg, nil)
}()
syncLock.Lock()
defer syncLock.Unlock()
WaitForWritingFiles()
writingDataLock.Lock()
var err error
// 将 data 变更同步到 sync
if err = workspaceData2SyncDir(); nil != err {
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
writingDataLock.Unlock()
return
}
syncConf, err := getWorkspaceDataConf()
if nil != err {
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
writingDataLock.Unlock()
return
}
writingDataLock.Unlock()
cloudSyncVer, err := getCloudSyncVer(Conf.Sync.CloudName)
if nil != err {
msg := fmt.Sprintf(Conf.Language(24), err.Error())
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
return
}
//util.LogInfof("sync [cloud=%d, local=%d]", cloudSyncVer, syncConf.SyncVer)
if cloudSyncVer == syncConf.SyncVer {
BootSyncSucc = 0
ExitSyncSucc = 0
syncSameCount++
if 10 < syncSameCount {
syncSameCount = 5
}
if !byHand {
syncInterval = time.Minute * time.Duration(int(math.Pow(2, float64(syncSameCount))))
if fixSyncInterval.Minutes() > syncInterval.Minutes() {
syncInterval = time.Minute * 8
}
util.LogInfof("set sync interval to [%dm]", int(syncInterval.Minutes()))
}
Conf.Sync.Stat = Conf.Language(133)
return
}
cloudUsedAssetSize, cloudUsedBackupSize, device, err := getCloudSync(Conf.Sync.CloudName)
if nil != err {
msg := fmt.Sprintf(Conf.Language(24), err.Error())
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
return
}
localSyncDirPath := Conf.Sync.GetSaveDir()
syncSameCount = 0
if cloudSyncVer < syncConf.SyncVer {
// 上传
if -1 == cloudSyncVer {
// 初次上传
IncWorkspaceDataVer()
incLocalSyncVer()
}
start := time.Now()
//util.LogInfof("sync [cloud=%d, local=%d] uploading...", cloudSyncVer, syncConf.SyncVer)
syncSize, err := util.SizeOfDirectory(localSyncDirPath, false)
if nil != err {
util.PushErrMsg(fmt.Sprintf(Conf.Language(80), formatErrorMsg(err)), 7000)
return
}
leftSyncSize := int64(Conf.User.UserSiYuanRepoSize) - cloudUsedAssetSize - cloudUsedBackupSize
if leftSyncSize < syncSize {
util.PushErrMsg(fmt.Sprintf(Conf.Language(43), byteCountSI(int64(Conf.User.UserSiYuanRepoSize))), 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
return
}
wroteFiles, transferSize, err := ossUpload(localSyncDirPath, "sync/"+Conf.Sync.CloudName, device, boot)
if nil != err {
util.PushClearMsg()
IncWorkspaceDataVer() // 上传失败的话提升本地版本,以备下次上传
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
return
}
util.PushClearMsg()
elapsed := time.Now().Sub(start).Seconds()
stat := fmt.Sprintf(Conf.Language(130), wroteFiles, humanize.Bytes(transferSize)) + fmt.Sprintf(Conf.Language(132), elapsed)
util.LogInfof("sync [cloud=%d, local=%d, wroteFiles=%d, transferSize=%s] uploaded in [%.2fs]", cloudSyncVer, syncConf.SyncVer, wroteFiles, humanize.Bytes(transferSize), elapsed)
Conf.Sync.Uploaded = now
Conf.Sync.Stat = stat
BootSyncSucc = 0
ExitSyncSucc = 0
if !byHand {
syncInterval = fixSyncInterval
}
return
}
// 下载
if !boot && !exit {
CloseWatchAssets()
defer WatchAssets()
}
start := time.Now()
//util.LogInfof("sync [cloud=%d, local=%d] downloading...", cloudSyncVer, syncConf.SyncVer)
// 使用索引文件进行解密验证 https://github.com/siyuan-note/siyuan/issues/3789
var tmpFetchedFiles int
var tmpTransferSize uint64
err = ossDownload0(util.TempDir+"/sync", "sync/"+Conf.Sync.CloudName, "/"+pathJSON, &tmpFetchedFiles, &tmpTransferSize, boot || exit)
if nil != err {
util.PushClearMsg()
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
syncDownloadErrCount++
return
}
tmpPathJSON := filepath.Join(util.TempDir, "/sync/"+pathJSON)
data, err := os.ReadFile(tmpPathJSON)
if nil != err {
return
}
data, err = encryption.AESGCMDecryptBinBytes(data, Conf.E2EEPasswd)
if nil != err {
util.PushClearMsg()
msg := Conf.Language(28)
Conf.Sync.Stat = msg
util.PushErrMsg(fmt.Sprintf(Conf.Language(80), msg), 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
Conf.Sync.Stat = msg
syncDownloadErrCount++
return
}
// 解密验证成功后将其移动到 sync/ 文件夹下
if err = os.Rename(tmpPathJSON, filepath.Join(localSyncDirPath, pathJSON)); nil != err {
util.PushClearMsg()
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
syncDownloadErrCount++
return
}
fetchedFilesCount, transferSize, downloadedFiles, err := ossDownload(localSyncDirPath, "sync/"+Conf.Sync.CloudName, boot || exit)
if nil != err {
util.PushClearMsg()
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
err = syncDirUpsertWorkspaceData(downloadedFiles)
if nil != err {
util.LogErrorf("upsert partially downloaded files to workspace data failed: %s", err)
}
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
syncDownloadErrCount++
return
}
util.PushClearMsg()
// 恢复
var upsertFiles, removeFiles []string
if upsertFiles, removeFiles, err = syncDir2WorkspaceData(boot); nil != err {
msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
Conf.Sync.Stat = msg
util.PushErrMsg(msg, 7000)
if boot {
BootSyncSucc = 1
}
if exit {
ExitSyncSucc = 1
}
syncDownloadErrCount++
return
}
syncDownloadErrCount = 0
// 清理空文件夹
clearEmptyDirs(util.DataDir)
elapsed := time.Now().Sub(start).Seconds()
stat := fmt.Sprintf(Conf.Language(129), fetchedFilesCount, humanize.Bytes(transferSize)) + fmt.Sprintf(Conf.Language(131), elapsed)
util.LogInfof("sync [cloud=%d, local=%d, fetchedFiles=%d, transferSize=%s] downloaded in [%.2fs]", cloudSyncVer, syncConf.SyncVer, fetchedFilesCount, humanize.Bytes(transferSize), elapsed)
Conf.Sync.Downloaded = now
Conf.Sync.Stat = stat
BootSyncSucc = 0
ExitSyncSucc = 0
if !byHand {
syncInterval = fixSyncInterval
}
if boot && gulu.File.IsExist(util.BlockTreePath) {
// 在 blocktree 存在的情况下不会重建索引,所以这里需要刷新 blocktree 和 database
if err = treenode.ReadBlockTree(); nil != err {
os.RemoveAll(util.BlockTreePath)
util.LogWarnf("removed block tree [%s] due to %s", util.BlockTreePath, err)
return
}
for _, upsertFile := range upsertFiles {
if !strings.HasSuffix(upsertFile, ".sy") {
continue
}
upsertFile = filepath.ToSlash(upsertFile)
box := upsertFile[:strings.Index(upsertFile, "/")]
p := strings.TrimPrefix(upsertFile, box)
tree, err0 := LoadTree(box, p)
if nil != err0 {
continue
}
treenode.ReindexBlockTree(tree)
sql.UpsertTreeQueue(tree)
}
for _, removeFile := range removeFiles {
if !strings.HasSuffix(removeFile, ".sy") {
continue
}
id := strings.TrimSuffix(filepath.Base(removeFile), ".sy")
block := treenode.GetBlockTree(id)
if nil != block {
treenode.RemoveBlockTreesByRootID(block.RootID)
sql.RemoveTreeQueue(block.BoxID, block.RootID)
}
}
}
if !boot && !exit {
// 增量索引
for _, upsertFile := range upsertFiles {
if !strings.HasSuffix(upsertFile, ".sy") {
continue
}
upsertFile = filepath.ToSlash(upsertFile)
box := upsertFile[:strings.Index(upsertFile, "/")]
p := strings.TrimPrefix(upsertFile, box)
tree, err0 := LoadTree(box, p)
if nil != err0 {
continue
}
treenode.ReindexBlockTree(tree)
sql.UpsertTreeQueue(tree)
//util.LogInfof("sync index tree [%s]", tree.ID)
}
for _, removeFile := range removeFiles {
if !strings.HasSuffix(removeFile, ".sy") {
continue
}
id := strings.TrimSuffix(filepath.Base(removeFile), ".sy")
block := treenode.GetBlockTree(id)
if nil != block {
treenode.RemoveBlockTreesByRootID(block.RootID)
sql.RemoveTreeQueue(block.BoxID, block.RootID)
//util.LogInfof("sync remove tree [%s]", block.RootID)
}
}
cache.ClearDocsIAL() // 同步后文档树文档图标没有更新 https://github.com/siyuan-note/siyuan/issues/4939
util.ReloadUI()
}
return
}
// 清理 dir 下符合 ID 规则的空文件夹。
// 因为是深度遍历,所以可能会清理不完全空文件夹,每次遍历仅能清理叶子节点。但是多次调用后,可以清理完全。
func clearEmptyDirs(dir string) {
var emptyDirs []string
filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
if nil != err || !info.IsDir() || dir == path {
return err
}
if util.IsIDPattern(info.Name()) {
if files, readDirErr := os.ReadDir(path); nil == readDirErr && 0 == len(files) {
emptyDirs = append(emptyDirs, path)
}
}
return nil
})
for _, emptyDir := range emptyDirs {
if err := os.RemoveAll(emptyDir); nil != err {
util.LogErrorf("clear empty dir [%s] failed [%s]", emptyDir, err.Error())
}
}
}
func SetCloudSyncDir(name string) {
if Conf.Sync.CloudName == name {
return
}
syncLock.Lock()
defer syncLock.Unlock()
Conf.Sync.CloudName = name
Conf.Save()
}
func SetSyncEnable(b bool) (err error) {
syncLock.Lock()
defer syncLock.Unlock()
Conf.Sync.Enabled = b
Conf.Save()
return
}
var syncLock = sync.Mutex{}
func syncDirUpsertWorkspaceData(downloadedFiles []string) (err error) {
start := time.Now()
modified := map[string]bool{}
syncDir := Conf.Sync.GetSaveDir()
for _, file := range downloadedFiles {
file = filepath.Join(syncDir, file)
modified[file] = true
}
decryptedDataDir, _, err := recoverSyncData(modified)
if nil != err {
util.LogErrorf("decrypt data dir failed: %s", err)
return
}
dataDir := util.DataDir
if err = stableCopy(decryptedDataDir, dataDir); nil != err {
util.LogErrorf("copy decrypted data dir from [%s] to data dir [%s] failed: %s", decryptedDataDir, dataDir, err)
return
}
if elapsed := time.Since(start).Milliseconds(); 5000 < elapsed {
util.LogInfof("sync data to workspace data elapsed [%dms]", elapsed)
}
return
}
// syncDir2WorkspaceData 将 sync 的数据更新到 data 中。
// 1. 删除 data 中冗余的文件
// 2. 将 sync 中新增/修改的文件解密后拷贝到 data 中
func syncDir2WorkspaceData(boot bool) (upsertFiles, removeFiles []string, err error) {
start := time.Now()
unchanged, removeFiles, err := unchangedSyncList()
if nil != err {
return
}
modified := modifiedSyncList(unchanged)
decryptedDataDir, upsertFiles, err := recoverSyncData(modified)
if nil != err {
util.LogErrorf("decrypt data dir failed: %s", err)
return
}
if boot {
util.IncBootProgress(0, "Copying decrypted data...")
}
dataDir := util.DataDir
if err = stableCopy(decryptedDataDir, dataDir); nil != err {
util.LogErrorf("copy decrypted data dir from [%s] to data dir [%s] failed: %s", decryptedDataDir, dataDir, err)
return
}
if elapsed := time.Since(start).Milliseconds(); 5000 < elapsed {
util.LogInfof("sync data to workspace data elapsed [%dms]", elapsed)
}
return
}
// workspaceData2SyncDir 将 data 的数据更新到 sync 中。
// 1. 删除 sync 中多余的文件
// 2. 将 data 中新增/修改的文件加密后拷贝到 sync 中
func workspaceData2SyncDir() (err error) {
start := time.Now()
filesys.ReleaseAllFileLocks()
passwd := Conf.E2EEPasswd
unchanged, err := unchangedDataList(passwd)
if nil != err {
return
}
encryptedDataDir, err := prepareSyncData(passwd, unchanged)
if nil != err {
util.LogErrorf("encrypt data dir failed: %s", err)
return
}
syncDir := Conf.Sync.GetSaveDir()
if err = stableCopy(encryptedDataDir, syncDir); nil != err {
util.LogErrorf("copy encrypted data dir from [%s] to sync dir [%s] failed: %s", encryptedDataDir, syncDir, err)
return
}
if elapsed := time.Since(start).Milliseconds(); 5000 < elapsed {
util.LogInfof("workspace data to sync data elapsed [%dms]", elapsed)
}
return
}
type CloudIndex struct {
Hash string `json:"hash"`
Size int64 `json:"size"`
}
func genCloudIndex(localDirPath string, excludes map[string]bool) (err error) {
cloudIndex := map[string]*CloudIndex{}
err = filepath.Walk(localDirPath, func(path string, info fs.FileInfo, err error) error {
if nil != err {
return err
}
if localDirPath == path || info.IsDir() || excludes[path] {
return nil
}
if util.CloudSingleFileMaxSizeLimit < info.Size() {
return nil
}
hash, hashErr := util.GetEtag(path)
if nil != hashErr {
util.LogErrorf("get file [%s] hash failed: %s", path, hashErr)
return hashErr
}
p := strings.TrimPrefix(path, localDirPath)
p = filepath.ToSlash(p)
cloudIndex[p] = &CloudIndex{Hash: hash, Size: info.Size()}
return nil
})
if nil != err {
util.LogErrorf("walk sync dir [%s] failed: %s", localDirPath, err)
return
}
data, err := gulu.JSON.MarshalJSON(cloudIndex)
if nil != err {
util.LogErrorf("marshal sync cloud index failed: %s", err)
return
}
if err = os.WriteFile(filepath.Join(localDirPath, "index.json"), data, 0644); nil != err {
util.LogErrorf("write sync cloud index failed: %s", err)
return
}
return
}
func recoverSyncData(modified map[string]bool) (decryptedDataDir string, upsertFiles []string, err error) {
passwd := Conf.E2EEPasswd
decryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "sync-decrypt")
if err = os.RemoveAll(decryptedDataDir); nil != err {
return
}
if err = os.MkdirAll(decryptedDataDir, 0755); nil != err {
return
}
syncDir := Conf.Sync.GetSaveDir()
meta := filepath.Join(syncDir, pathJSON)
data, err := os.ReadFile(meta)
if nil != err {
return
}
data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err {
err = errors.New(Conf.Language(40))
return
}
metaJSON := map[string]string{}
if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
return
}
modTimes := map[string]time.Time{}
now := time.Now().Format("2006-01-02-150405")
filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
if syncDir == path || pathJSON == info.Name() {
return nil
}
// 如果不是新增或者修改则跳过
if !modified[path] {
return nil
}
encryptedP := strings.TrimPrefix(path, syncDir+string(os.PathSeparator))
encryptedP = filepath.ToSlash(encryptedP)
if "" == metaJSON[encryptedP] {
return nil
}
plainP := filepath.Join(decryptedDataDir, metaJSON[encryptedP])
plainP = filepath.FromSlash(plainP)
p := strings.TrimPrefix(plainP, decryptedDataDir+string(os.PathSeparator))
upsertFiles = append(upsertFiles, p)
dataPath := filepath.Join(util.DataDir, p)
if gulu.File.IsExist(dataPath) && !gulu.File.IsDir(dataPath) { // 不是目录的话说明必定是已经存在的文件,这些文件被覆盖需要生成历史
genSyncHistory(now, dataPath)
}
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
}
if !strings.HasPrefix(encryptedP, ".siyuan") {
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
})
return
}
func prepareSyncData(passwd string, unchangedList map[string]bool) (encryptedDataDir string, err error) {
encryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "sync-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 || nil == info {
return nil
}
isDir := info.IsDir()
if isCloudSkipFile(path, info) {
if isDir {
return filepath.SkipDir
}
return nil
}
plainP := strings.TrimPrefix(path, util.DataDir+string(os.PathSeparator))
p := plainP
if !strings.HasPrefix(plainP, ".siyuan") { // 配置目录下都用明文,其他文件需要映射文件名
p = pathSha246(p, string(os.PathSeparator))
}
metaJSON[filepath.ToSlash(p)] = filepath.ToSlash(plainP)
// 如果不是新增或者修改则跳过
if unchangedList[path] {
return nil
}
p = encryptedDataDir + string(os.PathSeparator) + p
//util.LogInfof("update sync [%s] for data [%s]", p, path)
if 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
}
data, err0 := filesys.NoLockFileRead(path)
if nil != err0 {
util.LogErrorf("read file [%s] failed: %s", path, err0)
err = err0
return io.EOF
}
if !strings.HasPrefix(plainP, ".siyuan") {
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
}
}
err0 = os.WriteFile(p, data, 0644)
if nil != err0 {
util.LogErrorf("write 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 sync 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 = os.WriteFile(meta, data, 0644); nil != err {
return
}
return
}
// modifiedSyncList 获取 sync 中新增和修改的文件列表。
func modifiedSyncList(unchangedList map[string]bool) (ret map[string]bool) {
ret = map[string]bool{}
syncDir := Conf.Sync.GetSaveDir()
filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
if syncDir == path || pathJSON == info.Name() {
return nil
}
if !unchangedList[path] {
ret[path] = true
}
return nil
})
return
}
// unchangedSyncList 获取 data 和 sync 一致(没有修改过)的文件列表,并删除 data 中不存在于 sync 中的多余文件。
func unchangedSyncList() (ret map[string]bool, removes []string, err error) {
syncDir := Conf.Sync.GetSaveDir()
meta := filepath.Join(syncDir, pathJSON)
if !gulu.File.IsExist(meta) {
return
}
data, err := os.ReadFile(meta)
if nil != err {
return
}
passwd := Conf.E2EEPasswd
data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err {
err = errors.New(Conf.Language(40))
return
}
metaJSON := map[string]string{}
if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
return
}
syncIgnoreList := getSyncIgnoreList()
excludes := map[string]bool{}
ignores := syncIgnoreList.Values()
for _, p := range ignores {
relPath := p.(string)
relPath = pathSha246(relPath, "/")
relPath = filepath.Join(syncDir, relPath)
excludes[relPath] = true
}
ret = map[string]bool{}
sep := string(os.PathSeparator)
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+sep)
dataP := plainP
dataP = pathSha246(dataP, sep)
syncP := filepath.Join(syncDir, dataP)
if excludes[syncP] {
return nil
}
if !gulu.File.IsExist(syncP) { // sync 已经删除的文件
removes = append(removes, path)
if gulu.File.IsDir(path) {
return filepath.SkipDir
}
return nil
}
stat, _ := os.Stat(syncP)
syncModTime := stat.ModTime()
if info.ModTime() == syncModTime {
ret[syncP] = true
return nil
}
return nil
})
// 在 data 中删除 sync 已经删除的文件
now := time.Now().Format("2006-01-02-150405")
for _, remove := range removes {
genSyncHistory(now, remove)
if ".siyuan" != filepath.Base(remove) {
if err = os.RemoveAll(remove); nil != err {
util.LogErrorf("remove [%s] failed: %s", remove, err)
}
}
}
return
}
// unchangedDataList 获取 sync 和 data 一致(没有修改过)的文件列表,并删除 sync 中不存在于 data 中的多余文件。
func unchangedDataList(passwd string) (ret map[string]bool, err error) {
syncDir := Conf.Sync.GetSaveDir()
meta := filepath.Join(syncDir, pathJSON)
if !gulu.File.IsExist(meta) {
return
}
data, err := os.ReadFile(meta)
if nil != err {
return
}
data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
if nil != err {
return ret, errors.New(Conf.Language(40))
}
metaJSON := map[string]string{}
if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
return
}
ret = map[string]bool{}
var removeList []string
filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
if syncDir == path || pathJSON == info.Name() {
return nil
}
encryptedP := strings.TrimPrefix(path, syncDir+string(os.PathSeparator))
encryptedP = filepath.ToSlash(encryptedP)
decryptedP := metaJSON[encryptedP]
if "" == decryptedP {
removeList = append(removeList, path)
if gulu.File.IsDir(path) {
return filepath.SkipDir
}
return nil
}
dataP := filepath.Join(util.DataDir, decryptedP)
dataP = filepath.FromSlash(dataP)
if !gulu.File.IsExist(dataP) { // data 已经删除的文件
removeList = append(removeList, path)
if gulu.File.IsDir(path) {
return filepath.SkipDir
}
return nil
}
stat, _ := os.Stat(dataP)
dataModTime := stat.ModTime()
if info.ModTime() == dataModTime {
ret[dataP] = true
return nil
}
return nil
})
// 在 sync 中删除 data 中已经删除的文件
for _, remove := range removeList {
if strings.HasSuffix(remove, "index.json") {
continue
}
if err = os.RemoveAll(remove); nil != err {
util.LogErrorf("remove [%s] failed: %s", remove, err)
}
}
return
}
func getWorkspaceDataConf() (conf *filesys.DataConf, err error) {
conf = &filesys.DataConf{Updated: util.CurrentTimeMillis(), Device: Conf.System.ID}
confPath := filepath.Join(Conf.Sync.GetSaveDir(), ".siyuan", "conf.json")
if !gulu.File.IsExist(confPath) {
os.MkdirAll(filepath.Dir(confPath), 0755)
data, _ := gulu.JSON.MarshalIndentJSON(conf, "", " ")
if err = os.WriteFile(confPath, data, 0644); nil != err {
util.LogErrorf("save sync conf [%s] failed: %s", confPath, err)
}
return
}
data, err := os.ReadFile(confPath)
if nil != err {
util.LogErrorf("read sync conf [%s] failed: %s", confPath, err)
return
}
if err = gulu.JSON.UnmarshalJSON(data, conf); nil != err {
filesys.IncWorkspaceDataVer(false, Conf.System.ID) // 尝试恢复 data/.siyuan/conf.json
util.LogErrorf("unmarshal sync conf [%s] failed: %s", confPath, err)
err = errors.New(Conf.Language(84))
return
}
return
}
func incLocalSyncVer() {
conf, err := getWorkspaceDataConf()
if nil != err {
return
}
conf.SyncVer++
data, _ := gulu.JSON.MarshalIndentJSON(conf, "", " ")
confPath := filepath.Join(Conf.Sync.GetSaveDir(), ".siyuan", "conf.json")
if err = os.WriteFile(confPath, data, 0644); nil != err {
util.LogErrorf("save sync conf [%s] failed: %s", confPath, err)
}
return
}
func isCloudSkipFile(path string, info os.FileInfo) bool {
baseName := info.Name()
if strings.HasPrefix(baseName, ".") {
if ".siyuan" == baseName {
return false
}
return true
}
if "history" == baseName {
if strings.HasSuffix(path, ".siyuan"+string(os.PathSeparator)+"history") {
return true
}
}
if (os.ModeSymlink == info.Mode()&os.ModeType) || (!strings.Contains(path, ".siyuan") && gulu.File.IsHidden(path)) {
return true
}
return false
}
func CreateCloudSyncDir(name string) (err error) {
syncLock.Lock()
defer syncLock.Unlock()
if !IsValidCloudDirName(name) {
return errors.New(Conf.Language(37))
}
err = createCloudSyncDirOSS(name)
if nil != err {
return
}
return
}
func RemoveCloudSyncDir(name string) (err error) {
syncLock.Lock()
defer syncLock.Unlock()
if "" == name {
return
}
err = removeCloudDirPath("sync/" + name)
if nil != err {
return
}
if Conf.Sync.CloudName == name {
Conf.Sync.CloudName = ""
Conf.Save()
}
return
}
func ListCloudSyncDir() (syncDirs []*Sync, hSize string, err error) {
syncDirs = []*Sync{}
dirs, size, err := listCloudSyncDirOSS()
for _, d := range dirs {
dirSize := int64(d["size"].(float64))
syncDirs = append(syncDirs, &Sync{
Size: dirSize,
HSize: humanize.Bytes(uint64(dirSize)),
Updated: d["updated"].(string),
CloudName: d["name"].(string),
})
}
hSize = humanize.Bytes(uint64(size))
return
}
func genSyncHistory(now, p string) {
dir := strings.TrimPrefix(p, util.DataDir+string(os.PathSeparator))
if strings.Contains(dir, string(os.PathSeparator)) {
dir = dir[:strings.Index(dir, string(os.PathSeparator))]
}
if ".siyuan" == dir || ".siyuan" == filepath.Base(p) {
return
}
historyDir, err := util.GetHistoryDirNow(now, "sync")
if nil != err {
util.LogErrorf("get history dir failed: %s", err)
return
}
relativePath := strings.TrimPrefix(p, util.DataDir)
historyPath := filepath.Join(historyDir, relativePath)
filesys.ReleaseFileLocks(p)
if err = gulu.File.Copy(p, historyPath); nil != err {
util.LogErrorf("gen sync history failed: %s", err)
return
}
}
func formatErrorMsg(err error) string {
msg := err.Error()
if strings.Contains(msg, "Permission denied") || strings.Contains(msg, "Access is denied") {
msg = Conf.Language(33)
} else if strings.Contains(msg, "Device or resource busy") {
msg = Conf.Language(85)
}
msg = msg + " v" + util.Ver
return msg
}
func IsValidCloudDirName(cloudDirName string) bool {
if 64 < len(cloudDirName) {
return false
}
chars := []byte{'~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', '=',
'[', ']', '{', '}', '\\', '|', ';', ':', '\'', '"', '<', ',', '>', '.', '?', '/', ' '}
var charsStr string
for _, char := range chars {
charsStr += string(char)
}
if strings.ContainsAny(cloudDirName, charsStr) {
return false
}
tmp := util.RemoveInvisible(cloudDirName)
return tmp == cloudDirName
}
func getSyncIgnoreList() (ret *hashset.Set) {
ret = hashset.New()
ignore := filepath.Join(util.DataDir, ".siyuan", "syncignore")
os.MkdirAll(filepath.Dir(ignore), 0755)
if !gulu.File.IsExist(ignore) {
if err := os.WriteFile(ignore, nil, 0644); nil != err {
util.LogErrorf("create syncignore [%s] failed: %s", ignore, err)
return
}
}
data, err := os.ReadFile(ignore)
if nil != err {
util.LogErrorf("read syncignore [%s] failed: %s", ignore, err)
return
}
dataStr := string(data)
dataStr = strings.ReplaceAll(dataStr, "\r\n", "\n")
lines := strings.Split(dataStr, "\n")
// 默认忽略帮助文档
lines = append(lines, "20210808180117-6v0mkxr/**/*")
lines = append(lines, "20210808180117-czj9bvb/**/*")
lines = append(lines, "20211226090932-5lcq56f/**/*")
var parents []string
for _, line := range lines {
if idx := strings.Index(line, "/*"); -1 < idx {
parent := line[:idx]
parents = append(parents, parent)
}
}
lines = append(lines, parents...)
for _, line := range lines {
line = strings.TrimSpace(line)
if "" == line {
continue
}
pattern := filepath.Join(util.DataDir, line)
pattern = filepath.FromSlash(pattern)
matches, globErr := zglob.Glob(pattern)
if nil != globErr && globErr != os.ErrNotExist {
util.LogErrorf("glob [%s] failed: %s", line, globErr)
continue
}
for _, m := range matches {
m = filepath.ToSlash(m)
if strings.Contains(m, ".siyuan/history") {
continue
}
m = strings.TrimPrefix(m, filepath.ToSlash(util.DataDir+string(os.PathSeparator)))
ret.Add(m)
}
}
return
}
func pathSha246(p, sep string) string {
buf := bytes.Buffer{}
parts := strings.Split(p, sep)
for i, part := range parts {
buf.WriteString(fmt.Sprintf("%x", sha256.Sum256([]byte(part)))[:7])
if i < len(parts)-1 {
buf.WriteString(sep)
}
}
return buf.String()
}
func GetSyncDirection(cloudDirName string) (code int, msg string) { // 0失败10上传20下载30一致
if !IsSubscriber() {
return
}
if "" == cloudDirName {
return
}
if !IsValidCloudDirName(cloudDirName) {
return
}
syncConf, err := getWorkspaceDataConf()
if nil != err {
msg = fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
return
}
cloudSyncVer, err := getCloudSyncVer(cloudDirName)
if nil != err {
msg = fmt.Sprintf(Conf.Language(24), err.Error())
return
}
if cloudSyncVer < syncConf.SyncVer {
return 10, fmt.Sprintf(Conf.Language(89), cloudDirName) // 上传
}
if cloudSyncVer > syncConf.SyncVer {
return 20, fmt.Sprintf(Conf.Language(90), cloudDirName) // 下载
}
return 30, fmt.Sprintf(Conf.Language(91), cloudDirName) // 一致
}
func IncWorkspaceDataVer() {
filesys.IncWorkspaceDataVer(true, Conf.System.ID)
syncSameCount = 0
syncInterval = fixSyncInterval
syncPlanTime = time.Now().Add(30 * time.Second)
}
func stableCopy(src, dest string) (err error) {
if gulu.OS.IsWindows() {
robocopy := "robocopy"
cmd := exec.Command(robocopy, src, dest, "/DCOPY:T", "/E", "/IS", "/R:0", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/NS", "/NC")
util.CmdAttr(cmd)
var output []byte
output, err = cmd.CombinedOutput()
if strings.Contains(err.Error(), "exit status 16") {
// 某些版本的 Windows 无法同步 https://github.com/siyuan-note/siyuan/issues/4197
return gulu.File.Copy(src, dest)
}
if nil != err && strings.Contains(err.Error(), exec.ErrNotFound.Error()) {
robocopy = os.Getenv("SystemRoot") + "\\System32\\" + "robocopy"
cmd = exec.Command(robocopy, src, dest, "/DCOPY:T", "/E", "/IS", "/R:0", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/NS", "/NC")
util.CmdAttr(cmd)
output, err = cmd.CombinedOutput()
}
if nil == err ||
strings.Contains(err.Error(), "exit status 3") ||
strings.Contains(err.Error(), "exit status 1") ||
strings.Contains(err.Error(), "exit status 2") ||
strings.Contains(err.Error(), "exit status 5") ||
strings.Contains(err.Error(), "exit status 6") ||
strings.Contains(err.Error(), "exit status 7") {
return nil
}
util.LogErrorf("robocopy data from [%s] to [%s] failed: %s %s", src, dest, string(output), err)
}
return gulu.File.Copy(src, dest)
}