mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-24 08:00:57 +00:00
751 lines
21 KiB
Go
751 lines
21 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
|
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/global"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/files"
|
|
"gorm.io/gorm"
|
|
"strconv"
|
|
)
|
|
|
|
const (
|
|
fileHistorySettingEnable = "FileHistoryStatus"
|
|
fileHistorySettingMaxPerPath = "FileHistoryMaxPerPath"
|
|
fileHistorySettingDiskQuotaMB = "FileHistoryDiskQuotaMB"
|
|
fileHistoryOpSave = "save"
|
|
fileHistoryOpRestore = "restore"
|
|
fileHistoryOpRename = "rename"
|
|
fileHistoryOpMove = "move"
|
|
fileHistoryRootDirName = "file-history"
|
|
defaultFileHistoryMaxPerPath = 20
|
|
defaultFileHistoryDiskQuotaMB = 1024
|
|
)
|
|
|
|
var historyService = NewIFileHistoryService()
|
|
var fileHistoryPathLocks sync.Map
|
|
|
|
type FileHistoryService struct {
|
|
repo repo.IFileHistoryRepo
|
|
}
|
|
|
|
type IFileHistoryService interface {
|
|
GetSettingInfo() (*response.FileHistorySettingInfo, error)
|
|
UpdateSetting(req request.FileHistorySettingUpdate) error
|
|
RecordSave(path string, content []byte, fileMode os.FileMode) error
|
|
HasRelatedHistory(path string) (bool, error)
|
|
RecordOperation(operation string, filePath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string) error
|
|
Restore(req request.FileHistoryRestoreReq) (response.FileInfo, error)
|
|
Search(req request.FileHistorySearchReq) (int64, []response.FileHistoryInfo, error)
|
|
GetContent(req request.FileHistoryContentReq) (response.FileHistoryInfo, error)
|
|
Delete(req request.FileHistoryDeleteReq) error
|
|
DeleteRelatedHistory(path string) error
|
|
}
|
|
|
|
func NewIFileHistoryService() IFileHistoryService {
|
|
return &FileHistoryService{repo: repo.NewIFileHistoryRepo()}
|
|
}
|
|
|
|
func (s *FileHistoryService) GetSettingInfo() (*response.FileHistorySettingInfo, error) {
|
|
info := &response.FileHistorySettingInfo{
|
|
Enable: constant.StatusEnable,
|
|
MaxPerPath: defaultFileHistoryMaxPerPath,
|
|
DiskQuotaMB: defaultFileHistoryDiskQuotaMB,
|
|
}
|
|
if value, err := settingRepo.GetValueByKey(fileHistorySettingEnable); err == nil && value != "" {
|
|
info.Enable = value
|
|
}
|
|
if value, err := settingRepo.GetValueByKey(fileHistorySettingMaxPerPath); err == nil && value != "" {
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && parsed > 0 {
|
|
info.MaxPerPath = parsed
|
|
}
|
|
}
|
|
if value, err := settingRepo.GetValueByKey(fileHistorySettingDiskQuotaMB); err == nil && value != "" {
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && parsed > 0 {
|
|
info.DiskQuotaMB = parsed
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) UpdateSetting(req request.FileHistorySettingUpdate) error {
|
|
if req.MaxPerPath <= 0 {
|
|
req.MaxPerPath = defaultFileHistoryMaxPerPath
|
|
}
|
|
if req.DiskQuotaMB <= 0 {
|
|
req.DiskQuotaMB = defaultFileHistoryDiskQuotaMB
|
|
}
|
|
if err := settingRepo.UpdateOrCreate(fileHistorySettingEnable, req.Enable); err != nil {
|
|
return err
|
|
}
|
|
if err := settingRepo.UpdateOrCreate(fileHistorySettingMaxPerPath, fmt.Sprintf("%d", req.MaxPerPath)); err != nil {
|
|
return err
|
|
}
|
|
if err := settingRepo.UpdateOrCreate(fileHistorySettingDiskQuotaMB, fmt.Sprintf("%d", req.DiskQuotaMB)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileHistoryService) RecordSave(filePath string, content []byte, fileMode os.FileMode) error {
|
|
return s.RecordOperation(fileHistoryOpSave, filePath, content, fileMode, "", "")
|
|
}
|
|
|
|
func (s *FileHistoryService) HasRelatedHistory(filePath string) (bool, error) {
|
|
absPath, err := filepath.Abs(filePath)
|
|
if err != nil {
|
|
absPath = filePath
|
|
}
|
|
if _, err = s.getLatestActiveRelatedByPath(absPath); err == nil {
|
|
return true, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, err
|
|
}
|
|
if _, err = s.getLatestRelatedByPath(absPath); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) RecordOperation(operation string, filePath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string) error {
|
|
info, err := s.GetSettingInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Enable != constant.StatusEnable {
|
|
return nil
|
|
}
|
|
if fileMode == 0 {
|
|
fileMode = 0640
|
|
}
|
|
if content == nil {
|
|
content = []byte{}
|
|
}
|
|
|
|
absPath, err := filepath.Abs(filePath)
|
|
if err != nil {
|
|
absPath = filePath
|
|
}
|
|
absSourcePath := normalizeAbsPath(sourcePath)
|
|
absTargetPath := normalizeAbsPath(targetPath)
|
|
resolvePaths := []string{absPath}
|
|
recordPath := absPath
|
|
switch operation {
|
|
case fileHistoryOpRename, fileHistoryOpMove:
|
|
resolvePaths = []string{absSourcePath, absTargetPath, absPath}
|
|
if absTargetPath != "" {
|
|
recordPath = absTargetPath
|
|
}
|
|
default:
|
|
if absSourcePath != "" {
|
|
resolvePaths = append(resolvePaths, absSourcePath)
|
|
}
|
|
if absTargetPath != "" {
|
|
resolvePaths = append(resolvePaths, absTargetPath)
|
|
}
|
|
}
|
|
|
|
fileID, latestChainRecord, chainErr := s.resolveFileChain(resolvePaths...)
|
|
if chainErr != nil && !errors.Is(chainErr, gorm.ErrRecordNotFound) {
|
|
return chainErr
|
|
}
|
|
if fileID == "" {
|
|
fileID = common.GetUuid()
|
|
}
|
|
if operation == fileHistoryOpRename || operation == fileHistoryOpMove {
|
|
if !s.isEditableHistorySnapshot(fileMode, content) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
previousID := uint(0)
|
|
if latestVersion, err := s.getLatestVersionByFileID(fileID); err == nil {
|
|
switch operation {
|
|
case fileHistoryOpSave:
|
|
contentHash := sha256HexBytes(content)
|
|
if latestVersion.ContentSHA == contentHash {
|
|
return nil
|
|
}
|
|
previousID = latestVersion.ID
|
|
case fileHistoryOpRestore:
|
|
previousID = latestVersion.ID
|
|
default:
|
|
if latestChainRecord.ID != 0 {
|
|
previousID = latestChainRecord.ID
|
|
}
|
|
}
|
|
} else if latestChainRecord.ID != 0 {
|
|
previousID = latestChainRecord.ID
|
|
}
|
|
|
|
recordContent := content
|
|
recordDeleted := false
|
|
|
|
recordID, storagePath, err := s.recordSnapshot(fileID, recordDeleted, operation, recordPath, recordContent, fileMode, absSourcePath, absTargetPath, previousID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.enforceRetention(fileID, recordID); err != nil {
|
|
_ = s.repo.DeleteByIDs([]uint{recordID})
|
|
_ = os.Remove(s.absStoragePath(storagePath))
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileHistoryService) recordSnapshot(fileID string, deleted bool, operation string, absPath string, content []byte, fileMode os.FileMode, sourcePath string, targetPath string, previousID uint) (uint, string, error) {
|
|
if fileMode == 0 {
|
|
fileMode = 0640
|
|
}
|
|
now := time.Now()
|
|
pathHash := sha256Hex(absPath)
|
|
contentHash := sha256HexBytes(content)
|
|
fileName := filepath.Base(absPath)
|
|
extension := filepath.Ext(absPath)
|
|
storagePath := s.buildStoragePath(absPath, now)
|
|
|
|
if err := os.MkdirAll(path.Dir(s.absStoragePath(storagePath)), os.ModePerm); err != nil {
|
|
return 0, "", err
|
|
}
|
|
if err := os.WriteFile(s.absStoragePath(storagePath), content, 0640); err != nil {
|
|
return 0, "", err
|
|
}
|
|
|
|
record := &model.FileHistory{
|
|
FileID: fileID,
|
|
Path: absPath,
|
|
PathHash: pathHash,
|
|
SourcePath: sourcePath,
|
|
TargetPath: targetPath,
|
|
FileName: fileName,
|
|
Extension: extension,
|
|
FileMode: fmt.Sprintf("%04o", fileMode.Perm()),
|
|
Operation: operation,
|
|
Deleted: deleted,
|
|
ContentSize: int64(len(content)),
|
|
ContentSHA: contentHash,
|
|
StoragePath: storagePath,
|
|
PreviousID: previousID,
|
|
}
|
|
|
|
if err := s.repo.Create(record); err != nil {
|
|
_ = os.Remove(s.absStoragePath(storagePath))
|
|
return 0, "", err
|
|
}
|
|
return record.ID, storagePath, nil
|
|
}
|
|
|
|
func normalizeAbsPath(filePath string) string {
|
|
filePath = strings.TrimSpace(filePath)
|
|
if filePath == "" {
|
|
return ""
|
|
}
|
|
absPath, err := filepath.Abs(filePath)
|
|
if err != nil {
|
|
return filePath
|
|
}
|
|
return absPath
|
|
}
|
|
|
|
func (s *FileHistoryService) Search(req request.FileHistorySearchReq) (int64, []response.FileHistoryInfo, error) {
|
|
page := req.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
size := req.PageSize
|
|
if size <= 0 {
|
|
size = 20
|
|
}
|
|
offset := (page - 1) * size
|
|
opts := []repo.DBOption{}
|
|
opts = append(opts, s.repo.WithNotOperation("init"))
|
|
switch strings.ToLower(strings.TrimSpace(req.Scope)) {
|
|
case "current":
|
|
if strings.TrimSpace(req.Path) == "" {
|
|
return 0, nil, buserr.New("ErrInvalidParams")
|
|
}
|
|
absPath, err := filepath.Abs(req.Path)
|
|
if err != nil {
|
|
absPath = req.Path
|
|
}
|
|
chain, err := s.getLatestActiveRelatedByPath(absPath)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
chain, err = s.getLatestRelatedByPath(absPath)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return 0, []response.FileHistoryInfo{}, nil
|
|
}
|
|
return 0, nil, err
|
|
}
|
|
} else {
|
|
return 0, nil, err
|
|
}
|
|
}
|
|
opts = append(opts, s.repo.WithByFileID(chain.FileID))
|
|
case "all":
|
|
if strings.TrimSpace(req.Path) != "" {
|
|
opts = append(opts, s.repo.WithByRelatedPath(normalizeAbsPath(req.Path)))
|
|
}
|
|
default:
|
|
return 0, nil, buserr.New("ErrInvalidParams")
|
|
}
|
|
if strings.TrimSpace(req.Operation) != "" {
|
|
opts = append(opts, s.repo.WithByOperation(strings.TrimSpace(req.Operation)))
|
|
}
|
|
|
|
total, items, err := s.repo.Page(size, offset, opts...)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
res := make([]response.FileHistoryInfo, 0, len(items))
|
|
for _, item := range items {
|
|
currentPath := item.Path
|
|
if resolved, err := s.getCurrentPathByFileID(item.FileID); err == nil && strings.TrimSpace(resolved) != "" {
|
|
currentPath = resolved
|
|
}
|
|
res = append(res, response.FileHistoryInfo{
|
|
ID: item.ID,
|
|
FileID: item.FileID,
|
|
Path: item.Path,
|
|
CurrentPath: currentPath,
|
|
PreviousID: item.PreviousID,
|
|
SourcePath: item.SourcePath,
|
|
TargetPath: item.TargetPath,
|
|
FileName: item.FileName,
|
|
Extension: item.Extension,
|
|
FileMode: item.FileMode,
|
|
Operation: item.Operation,
|
|
Deleted: item.Deleted,
|
|
ContentSize: item.ContentSize,
|
|
ContentSHA: item.ContentSHA,
|
|
StoragePath: item.StoragePath,
|
|
CreatedAt: item.CreatedAt,
|
|
UpdatedAt: item.UpdatedAt,
|
|
})
|
|
}
|
|
return total, res, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) GetContent(req request.FileHistoryContentReq) (response.FileHistoryInfo, error) {
|
|
record, err := s.repo.Get(s.repo.WithByID(req.ID))
|
|
if err != nil {
|
|
return response.FileHistoryInfo{}, err
|
|
}
|
|
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
|
|
if err != nil {
|
|
return response.FileHistoryInfo{}, err
|
|
}
|
|
currentContent := s.getCurrentContent(record)
|
|
return response.FileHistoryInfo{
|
|
ID: record.ID,
|
|
FileID: record.FileID,
|
|
Path: record.Path,
|
|
CurrentPath: func() string {
|
|
if currentPath, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(currentPath) != "" {
|
|
return currentPath
|
|
}
|
|
return record.Path
|
|
}(),
|
|
PreviousID: record.PreviousID,
|
|
SourcePath: record.SourcePath,
|
|
TargetPath: record.TargetPath,
|
|
FileName: record.FileName,
|
|
Extension: record.Extension,
|
|
FileMode: record.FileMode,
|
|
Operation: record.Operation,
|
|
Deleted: record.Deleted,
|
|
ContentSize: record.ContentSize,
|
|
ContentSHA: record.ContentSHA,
|
|
StoragePath: record.StoragePath,
|
|
Content: string(content),
|
|
CurrentContent: currentContent,
|
|
CreatedAt: record.CreatedAt,
|
|
UpdatedAt: record.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) Restore(req request.FileHistoryRestoreReq) (response.FileInfo, error) {
|
|
record, err := s.repo.Get(s.repo.WithByID(req.ID))
|
|
if err != nil {
|
|
return response.FileInfo{}, err
|
|
}
|
|
if !s.isVersionOperation(record.Operation) {
|
|
return response.FileInfo{}, buserr.New("ErrInvalidParams")
|
|
}
|
|
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
|
|
if err != nil {
|
|
return response.FileInfo{}, err
|
|
}
|
|
|
|
currentPath := record.Path
|
|
if chainPath, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(chainPath) != "" {
|
|
currentPath = chainPath
|
|
}
|
|
|
|
lock := s.getPathLock(currentPath)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
var rollbackContent []byte
|
|
var rollbackMode os.FileMode
|
|
var existedBefore bool
|
|
currentInfo, currentErr := files.NewFileInfo(files.FileOption{
|
|
Path: currentPath,
|
|
Expand: false,
|
|
})
|
|
if currentErr == nil {
|
|
existedBefore = true
|
|
rollbackContent, err = os.ReadFile(currentPath)
|
|
if err != nil {
|
|
return response.FileInfo{}, err
|
|
}
|
|
rollbackMode = currentInfo.FileMode
|
|
} else {
|
|
rollbackMode = parseFileMode(record.FileMode)
|
|
if rollbackMode == 0 {
|
|
rollbackMode = 0640
|
|
}
|
|
}
|
|
|
|
targetMode := rollbackMode
|
|
if targetMode == 0 {
|
|
targetMode = parseFileMode(record.FileMode)
|
|
}
|
|
if targetMode == 0 {
|
|
targetMode = 0640
|
|
}
|
|
|
|
fo := files.NewFileOp()
|
|
if err := fo.WriteFile(currentPath, strings.NewReader(string(content)), targetMode); err != nil {
|
|
_ = s.rollbackRestore(currentPath, rollbackContent, rollbackMode, existedBefore)
|
|
return response.FileInfo{}, err
|
|
}
|
|
if err := historyService.RecordOperation(fileHistoryOpRestore, currentPath, rollbackContent, rollbackMode, "", ""); err != nil {
|
|
global.LOG.Warnf("record file restore history failed for %s: %v", currentPath, err)
|
|
}
|
|
|
|
info, err := files.NewFileInfo(files.FileOption{
|
|
Path: currentPath,
|
|
Expand: true,
|
|
})
|
|
if err != nil {
|
|
return response.FileInfo{}, err
|
|
}
|
|
return response.FileInfo{FileInfo: *info}, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) Delete(req request.FileHistoryDeleteReq) error {
|
|
if len(req.IDs) == 0 {
|
|
return nil
|
|
}
|
|
for _, id := range req.IDs {
|
|
record, err := s.repo.Get(s.repo.WithByID(id))
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
if err := s.repo.DeleteByIDs([]uint{id}); err != nil {
|
|
return err
|
|
}
|
|
_ = os.Remove(s.absStoragePath(record.StoragePath))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *FileHistoryService) DeleteRelatedHistory(filePath string) error {
|
|
absPath := normalizeAbsPath(filePath)
|
|
if strings.TrimSpace(absPath) == "" {
|
|
return nil
|
|
}
|
|
|
|
fileID, latestRecord, err := s.resolveFileChain(absPath)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if strings.TrimSpace(fileID) == "" {
|
|
fileID = latestRecord.FileID
|
|
}
|
|
if strings.TrimSpace(fileID) == "" {
|
|
return nil
|
|
}
|
|
|
|
var records []model.FileHistory
|
|
if err := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID).Find(&records).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := make([]uint, 0, len(records))
|
|
for _, record := range records {
|
|
ids = append(ids, record.ID)
|
|
_ = os.Remove(s.absStoragePath(record.StoragePath))
|
|
}
|
|
return s.repo.DeleteByIDs(ids)
|
|
}
|
|
|
|
func (s *FileHistoryService) rollbackRestore(filePath string, rollbackContent []byte, rollbackMode os.FileMode, existedBefore bool) error {
|
|
fo := files.NewFileOp()
|
|
if !existedBefore {
|
|
return os.Remove(filePath)
|
|
}
|
|
if rollbackMode == 0 {
|
|
rollbackMode = 0640
|
|
}
|
|
return fo.WriteFile(filePath, strings.NewReader(string(rollbackContent)), rollbackMode)
|
|
}
|
|
|
|
func (s *FileHistoryService) enforceRetention(fileID string, keepID uint) error {
|
|
info, err := s.GetSettingInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Enable != constant.StatusEnable {
|
|
return nil
|
|
}
|
|
|
|
maxPerPath := info.MaxPerPath
|
|
if maxPerPath <= 0 {
|
|
maxPerPath = 20
|
|
}
|
|
quotaBytes := int64(info.DiskQuotaMB) * 1024 * 1024
|
|
|
|
if maxPerPath > 0 {
|
|
total, records, err := s.listFileOldest(fileID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if int(total) > maxPerPath {
|
|
removeCount := int(total) - maxPerPath
|
|
for i := 0; i < removeCount && i < len(records); i++ {
|
|
if err := s.repo.DeleteByIDs([]uint{records[i].ID}); err != nil {
|
|
return err
|
|
}
|
|
_ = os.Remove(s.absStoragePath(records[i].StoragePath))
|
|
}
|
|
}
|
|
}
|
|
|
|
if quotaBytes <= 0 {
|
|
return nil
|
|
}
|
|
|
|
totalSize, err := s.totalSize()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if totalSize <= quotaBytes {
|
|
return nil
|
|
}
|
|
return buserr.New("ErrHistoryQuotaExceeded")
|
|
}
|
|
|
|
func (s *FileHistoryService) getPathLock(absPath string) *sync.Mutex {
|
|
actual, _ := fileHistoryPathLocks.LoadOrStore(absPath, &sync.Mutex{})
|
|
return actual.(*sync.Mutex)
|
|
}
|
|
|
|
func (s *FileHistoryService) listFileOldest(fileID string) (int64, []model.FileHistory, error) {
|
|
var total int64
|
|
var items []model.FileHistory
|
|
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID)
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return 0, nil, err
|
|
}
|
|
if err := db.Order("created_at asc").Find(&items).Error; err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return total, items, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) getLatestRelatedByPath(absPath string) (model.FileHistory, error) {
|
|
var item model.FileHistory
|
|
db := global.DB.Model(&model.FileHistory{}).Where("path = ? OR source_path = ? OR target_path = ?", absPath, absPath, absPath).Order("created_at desc")
|
|
err := db.First(&item).Error
|
|
return item, err
|
|
}
|
|
|
|
func (s *FileHistoryService) getLatestActiveRelatedByPath(absPath string) (model.FileHistory, error) {
|
|
var item model.FileHistory
|
|
db := global.DB.Model(&model.FileHistory{}).Where("deleted = ? AND (path = ? OR source_path = ? OR target_path = ?)", false, absPath, absPath, absPath).Order("created_at desc")
|
|
err := db.First(&item).Error
|
|
return item, err
|
|
}
|
|
|
|
func (s *FileHistoryService) getLatestVersionByFileID(fileID string) (model.FileHistory, error) {
|
|
var item model.FileHistory
|
|
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ? AND operation IN ?", fileID, []string{fileHistoryOpSave, fileHistoryOpRestore}).Order("created_at desc")
|
|
err := db.First(&item).Error
|
|
return item, err
|
|
}
|
|
|
|
func (s *FileHistoryService) getCurrentPathByFileID(fileID string) (string, error) {
|
|
record, err := s.getLatestRelatedByFileID(fileID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return record.Path, nil
|
|
}
|
|
|
|
func (s *FileHistoryService) getCurrentContent(record model.FileHistory) string {
|
|
currentPath := ""
|
|
if resolved, err := s.getCurrentPathByFileID(record.FileID); err == nil && strings.TrimSpace(resolved) != "" {
|
|
currentPath = resolved
|
|
}
|
|
candidates := []string{}
|
|
if strings.TrimSpace(currentPath) != "" {
|
|
candidates = append(candidates, currentPath)
|
|
}
|
|
if strings.TrimSpace(record.Path) != "" && record.Path != currentPath {
|
|
candidates = append(candidates, record.Path)
|
|
}
|
|
for _, candidate := range candidates {
|
|
content, err := os.ReadFile(candidate)
|
|
if err == nil {
|
|
return string(content)
|
|
}
|
|
}
|
|
content, err := os.ReadFile(s.absStoragePath(record.StoragePath))
|
|
if err == nil {
|
|
return string(content)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *FileHistoryService) isEditableHistorySnapshot(fileMode os.FileMode, content []byte) bool {
|
|
if fileMode.IsDir() || files.IsBlockDevice(fileMode) {
|
|
return false
|
|
}
|
|
if len(content) == 0 {
|
|
return true
|
|
}
|
|
return !files.DetectBinary(content)
|
|
}
|
|
|
|
func (s *FileHistoryService) getLatestRelatedByFileID(fileID string) (model.FileHistory, error) {
|
|
var item model.FileHistory
|
|
db := global.DB.Model(&model.FileHistory{}).Where("file_id = ?", fileID).Order("created_at desc")
|
|
err := db.First(&item).Error
|
|
return item, err
|
|
}
|
|
|
|
func (s *FileHistoryService) resolveFileChain(paths ...string) (string, model.FileHistory, error) {
|
|
for _, itemPath := range paths {
|
|
itemPath = strings.TrimSpace(itemPath)
|
|
if itemPath == "" {
|
|
continue
|
|
}
|
|
record, err := s.getLatestActiveRelatedByPath(itemPath)
|
|
if err == nil {
|
|
return record.FileID, record, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return "", model.FileHistory{}, err
|
|
}
|
|
}
|
|
return "", model.FileHistory{}, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
func (s *FileHistoryService) isVersionOperation(operation string) bool {
|
|
return operation == fileHistoryOpSave || operation == fileHistoryOpRestore
|
|
}
|
|
|
|
func (s *FileHistoryService) totalSize() (int64, error) {
|
|
var total int64
|
|
err := global.DB.Model(&model.FileHistory{}).Select("coalesce(sum(content_size),0)").Scan(&total).Error
|
|
return total, err
|
|
}
|
|
|
|
func (s *FileHistoryService) listOldest() ([]model.FileHistory, error) {
|
|
var items []model.FileHistory
|
|
err := global.DB.Model(&model.FileHistory{}).Order("created_at asc").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func (s *FileHistoryService) buildStoragePath(absPath string, now time.Time) string {
|
|
fileName := filepath.Base(absPath)
|
|
extension := filepath.Ext(fileName)
|
|
baseName := strings.TrimSuffix(fileName, extension)
|
|
if baseName == "" {
|
|
baseName = fileName
|
|
extension = ""
|
|
}
|
|
baseName = sanitizeHistoryFileName(baseName)
|
|
if baseName == "" {
|
|
baseName = "file"
|
|
}
|
|
dayDir := now.Format("2006-01-02")
|
|
shortCode := strings.ReplaceAll(common.GetUuid(), "-", "")
|
|
if len(shortCode) > 8 {
|
|
shortCode = shortCode[:8]
|
|
}
|
|
return path.Join(fileHistoryRootDirName, dayDir, fmt.Sprintf("%s__%s%s", baseName, shortCode, extension))
|
|
}
|
|
|
|
func (s *FileHistoryService) absStoragePath(rel string) string {
|
|
return path.Join(global.Dir.LocalBackupDir, rel)
|
|
}
|
|
|
|
func sanitizeHistoryFileName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
replacer := strings.NewReplacer(
|
|
"/", "_",
|
|
"\\", "_",
|
|
":", "_",
|
|
"*", "_",
|
|
"?", "_",
|
|
"\"", "_",
|
|
"<", "_",
|
|
">", "_",
|
|
"|", "_",
|
|
)
|
|
return replacer.Replace(name)
|
|
}
|
|
|
|
func parseFileMode(mode string) os.FileMode {
|
|
if strings.TrimSpace(mode) == "" {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.ParseUint(strings.TrimSpace(mode), 8, 32)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return os.FileMode(parsed)
|
|
}
|
|
|
|
func sha256Hex(value string) string {
|
|
sum := sha256.Sum256([]byte(value))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func sha256HexBytes(value []byte) string {
|
|
sum := sha256.Sum256(value)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|