teg/backend/milestone_backup.go
2026-07-03 16:56:20 +02:00

1192 lines
30 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"io"
"log"
"net"
"net/http"
"os"
pathpkg "path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/hirochachacha/go-smb2"
"github.com/jackc/pgx/v5"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
const (
backupLogListTailBytes int64 = 512 * 1024
backupLogDetailTailBytes int64 = 1024 * 1024
backupLogMaxFiles = 200
backupLogTailLineCount = 240
backupSMBTimeout = 15 * time.Second
)
var errInvalidMilestoneBackupLogName = errors.New("ungueltiger Backup-Protokolldateiname")
type milestoneBackupLogFile struct {
Name string `json:"name"`
Size int64 `json:"size"`
ModifiedAt time.Time `json:"modifiedAt"`
Summary milestoneBackupLogSummary `json:"summary"`
}
type milestoneBackupLogDetail struct {
milestoneBackupLogFile
TailLines []string `json:"tailLines"`
}
type milestoneBackupLogSummary struct {
Status string `json:"status"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt"`
Duration string `json:"duration"`
TotalFiles string `json:"totalFiles"`
CopiedFiles string `json:"copiedFiles"`
FailedCount int `json:"failedCount"`
Rows []milestoneBackupLogSummaryRow `json:"rows"`
}
type milestoneBackupLogSummaryRow struct {
Key string `json:"key"`
Label string `json:"label"`
Total string `json:"total"`
Copied string `json:"copied"`
Skipped string `json:"skipped"`
Mismatch string `json:"mismatch"`
Failed string `json:"failed"`
Extras string `json:"extras"`
}
type milestoneBackupSettings struct {
NasHost string
NasShare string
NasUsername string
NasPassword string
LogRootPath string
}
type milestoneBackupFolderRoot struct {
Label string `json:"label"`
Path string `json:"path"`
}
type milestoneBackupFolderItem struct {
Name string `json:"name"`
DisplayName string `json:"displayName,omitempty"`
Path string `json:"path"`
HasChildren bool `json:"hasChildren"`
}
type milestoneBackupFolderBrowseRequest struct {
NasHost string `json:"nasHost"`
NasShare string `json:"nasShare"`
NasUsername string `json:"nasUsername"`
NasPassword string `json:"nasPassword"`
Path string `json:"path"`
}
func (s *Server) handleListMilestoneBackupLogs(w http.ResponseWriter, r *http.Request) {
settings, err := s.getMilestoneBackupSettings(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusBadRequest, "Backup-Protokollpfad ist nicht konfiguriert")
return
}
if err != nil {
log.Printf("Milestone backup log path load failed: %v", err)
writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden")
return
}
rootPath := settings.effectiveLogRootPath()
if strings.TrimSpace(rootPath) == "" {
writeError(w, http.StatusBadRequest, "Backup-Protokollpfad ist nicht konfiguriert")
return
}
fileName := strings.TrimSpace(r.URL.Query().Get("file"))
if fileName != "" {
detail, err := readMilestoneBackupLogDetail(r.Context(), settings, rootPath, fileName)
if err != nil {
log.Printf("Milestone backup log detail failed: file=%q error=%v", fileName, err)
writeMilestoneBackupLogError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"rootPath": rootPath,
"log": detail,
})
return
}
files, err := listMilestoneBackupLogFiles(r.Context(), settings, rootPath)
if err != nil {
log.Printf("Milestone backup log list failed: root=%q error=%v", rootPath, err)
writeMilestoneBackupLogError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"rootPath": rootPath,
"logs": files,
})
}
func (s *Server) handleListMilestoneBackupFolders(w http.ResponseWriter, r *http.Request) {
settings, err := s.getMilestoneBackupSettings(r.Context())
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusBadRequest, "Backup-NAS oder Protokollordner ist nicht konfiguriert")
return
}
if err != nil {
log.Printf("Milestone backup settings load failed: %v", err)
writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden")
return
}
rootPath := settings.effectiveBrowseRootPath()
if rootPath == "" {
writeError(w, http.StatusBadRequest, "Backup-NAS oder Protokollordner ist nicht konfiguriert")
return
}
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
currentPath := requestedPath
if currentPath == "" {
currentPath = rootPath
}
folders, err := listMilestoneBackupFolders(r.Context(), settings, rootPath, currentPath)
if err != nil {
log.Printf("Milestone backup folder list failed: path=%q error=%v", currentPath, err)
writeMilestoneBackupLogError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"path": currentPath,
"roots": []milestoneBackupFolderRoot{
{Label: "Backup", Path: rootPath},
},
"folders": folders,
})
}
func (s *Server) handleBrowseMilestoneBackupFolders(w http.ResponseWriter, r *http.Request) {
var input milestoneBackupFolderBrowseRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
settings, err := s.getMilestoneBackupBrowseSettings(r.Context(), input)
if err != nil {
log.Printf("Milestone backup browse settings load failed: %v", err)
writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden")
return
}
if settings.NasHost == "" {
writeError(w, http.StatusBadRequest, "NAS-IP oder Hostname ist erforderlich")
return
}
browseCtx, cancelBrowse := context.WithTimeout(r.Context(), backupSMBTimeout)
defer cancelBrowse()
requestedPath := settings.canonicalDisplayPath(input.Path)
if settings.NasShare == "" {
roots, err := listMilestoneBackupShareRoots(browseCtx, settings)
if err != nil {
log.Printf("Milestone backup share list failed: host=%q error=%v", settings.NasHost, err)
writeMilestoneBackupLogError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"path": settings.uncHostPath(),
"roots": roots,
"folders": []milestoneBackupFolderItem{},
})
return
}
rootPath := settings.uncRootPath()
currentPath := requestedPath
if currentPath == "" {
currentPath = rootPath
}
folders, err := listMilestoneBackupFoldersSMB(browseCtx, settings, rootPath, currentPath)
if err != nil {
if errors.Is(err, os.ErrPermission) && normalizeBackupDisplayPath(currentPath) == normalizeBackupDisplayPath(rootPath) {
writeJSON(w, http.StatusOK, map[string]any{
"path": currentPath,
"roots": []milestoneBackupFolderRoot{
{Label: settings.NasShare, Path: rootPath},
},
"folders": []milestoneBackupFolderItem{},
"warning": "Die NAS-Freigabe wurde gefunden, aber der Ordner kann nicht aufgelistet werden. Prüfe die Leserechte oder trage den Unterordner manuell ein.",
})
return
}
log.Printf("Milestone backup folder browse failed: path=%q error=%v", currentPath, err)
writeMilestoneBackupLogError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"path": currentPath,
"roots": []milestoneBackupFolderRoot{
{Label: settings.NasShare, Path: rootPath},
},
"folders": folders,
})
}
func (s *Server) getMilestoneBackupBrowseSettings(
ctx context.Context,
input milestoneBackupFolderBrowseRequest,
) (milestoneBackupSettings, error) {
savedSettings, err := s.getMilestoneBackupSettings(ctx)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return milestoneBackupSettings{}, err
}
hasSavedSettings := err == nil
settings := milestoneBackupSettings{
NasHost: normalizeBackupNasHost(input.NasHost),
NasShare: normalizeBackupNasShare(input.NasShare),
NasUsername: strings.TrimSpace(input.NasUsername),
NasPassword: strings.TrimSpace(input.NasPassword),
LogRootPath: strings.TrimSpace(input.Path),
}
pathHost, pathShare := backupUNCPathHostAndShare(input.Path)
if settings.NasHost == "" {
settings.NasHost = pathHost
}
if settings.NasShare == "" {
settings.NasShare = pathShare
}
if settings.NasHost == "" && hasSavedSettings {
settings.NasHost = savedSettings.NasHost
}
if settings.NasUsername == "" && hasSavedSettings && settings.NasHost == savedSettings.NasHost {
settings.NasUsername = savedSettings.NasUsername
}
if settings.NasPassword == "" &&
hasSavedSettings &&
settings.NasHost == savedSettings.NasHost &&
settings.NasUsername == savedSettings.NasUsername {
settings.NasPassword = savedSettings.NasPassword
}
return settings, nil
}
func (s *Server) getMilestoneBackupSettings(ctx context.Context) (milestoneBackupSettings, error) {
var settings milestoneBackupSettings
err := s.db.QueryRow(
ctx,
`
SELECT
COALESCE(backup_nas_host, ''),
COALESCE(backup_nas_share, ''),
COALESCE(backup_nas_username, ''),
COALESCE(pgp_sym_decrypt(backup_nas_password_encrypted, $1), ''),
COALESCE(backup_log_root_path, '')
FROM milestone_settings
WHERE id = 1
LIMIT 1
`,
s.milestoneEncryptionKey(),
).Scan(
&settings.NasHost,
&settings.NasShare,
&settings.NasUsername,
&settings.NasPassword,
&settings.LogRootPath,
)
settings.NasHost = normalizeBackupNasHost(settings.NasHost)
settings.NasShare = normalizeBackupNasShare(settings.NasShare)
settings.NasUsername = strings.TrimSpace(settings.NasUsername)
settings.NasPassword = strings.TrimSpace(settings.NasPassword)
settings.LogRootPath = strings.TrimSpace(settings.LogRootPath)
return settings, err
}
func writeMilestoneBackupLogError(w http.ResponseWriter, err error) {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Backup-Protokoll wurde nicht gefunden")
return
}
if errors.Is(err, os.ErrPermission) {
writeError(w, http.StatusForbidden, "Backup-Protokollpfad kann nicht gelesen werden. Prüfe die NAS-Freigabe, den Benutzer und die Leserechte.")
return
}
if errors.Is(err, context.DeadlineExceeded) {
writeError(w, http.StatusGatewayTimeout, "NAS hat nicht rechtzeitig geantwortet. Prüfe Hostname, Benutzername, Domäne und Netzwerkverbindung.")
return
}
if errors.Is(err, context.Canceled) {
writeError(w, http.StatusGatewayTimeout, "NAS-Anfrage wurde abgebrochen.")
return
}
if errors.Is(err, errInvalidMilestoneBackupLogName) {
writeError(w, http.StatusBadRequest, "Backup-Protokolldateiname ist ungültig")
return
}
writeError(w, http.StatusInternalServerError, "Backup-Protokolle konnten nicht geladen werden")
}
func (settings milestoneBackupSettings) hasNAS() bool {
return settings.NasHost != "" && settings.NasShare != ""
}
func (settings milestoneBackupSettings) uncRootPath() string {
if !settings.hasNAS() {
return ""
}
return `\\` + settings.NasHost + `\` + settings.NasShare
}
func (settings milestoneBackupSettings) uncHostPath() string {
if settings.NasHost == "" {
return ""
}
return `\\` + settings.NasHost
}
func (settings milestoneBackupSettings) effectiveBrowseRootPath() string {
if settings.hasNAS() {
return settings.uncRootPath()
}
return strings.TrimSpace(settings.LogRootPath)
}
func (settings milestoneBackupSettings) effectiveLogRootPath() string {
if strings.TrimSpace(settings.LogRootPath) != "" {
return strings.TrimSpace(settings.LogRootPath)
}
return settings.effectiveBrowseRootPath()
}
func listMilestoneBackupLogFiles(ctx context.Context, settings milestoneBackupSettings, rootPath string) ([]milestoneBackupLogFile, error) {
if settings.hasNAS() {
return listMilestoneBackupLogFilesSMB(ctx, settings, rootPath)
}
entries, err := os.ReadDir(rootPath)
if err != nil {
return nil, err
}
files := make([]milestoneBackupLogFile, 0)
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
path := filepath.Join(rootPath, entry.Name())
text, err := readTextFileTail(path, backupLogListTailBytes)
if err != nil {
return nil, err
}
files = append(files, milestoneBackupLogFile{
Name: entry.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
Summary: parseMilestoneBackupLogSummary(text),
})
}
sort.Slice(files, func(left, right int) bool {
return files[left].ModifiedAt.After(files[right].ModifiedAt)
})
if len(files) > backupLogMaxFiles {
files = files[:backupLogMaxFiles]
}
return files, nil
}
func listMilestoneBackupLogFilesSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string) ([]milestoneBackupLogFile, error) {
share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings)
if err != nil {
return nil, err
}
defer cleanup()
relativeRoot, err := settings.relativeSMBPath(rootPath)
if err != nil {
return nil, err
}
entries, err := share.ReadDir(relativeRoot)
if err != nil {
return nil, err
}
files := make([]milestoneBackupLogFile, 0)
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") {
continue
}
relativeFilePath := joinSMBRelativePath(relativeRoot, entry.Name())
text, err := readSMBTextFileTail(share, relativeFilePath, backupLogListTailBytes)
if err != nil {
return nil, err
}
files = append(files, milestoneBackupLogFile{
Name: entry.Name(),
Size: entry.Size(),
ModifiedAt: entry.ModTime(),
Summary: parseMilestoneBackupLogSummary(text),
})
}
sort.Slice(files, func(left, right int) bool {
return files[left].ModifiedAt.After(files[right].ModifiedAt)
})
if len(files) > backupLogMaxFiles {
files = files[:backupLogMaxFiles]
}
return files, nil
}
func readMilestoneBackupLogDetail(ctx context.Context, settings milestoneBackupSettings, rootPath string, fileName string) (milestoneBackupLogDetail, error) {
if settings.hasNAS() {
return readMilestoneBackupLogDetailSMB(ctx, settings, rootPath, fileName)
}
path, err := resolveMilestoneBackupLogPath(rootPath, fileName)
if err != nil {
return milestoneBackupLogDetail{}, err
}
info, err := os.Stat(path)
if err != nil {
return milestoneBackupLogDetail{}, err
}
if info.IsDir() {
return milestoneBackupLogDetail{}, os.ErrNotExist
}
text, err := readTextFileTail(path, backupLogDetailTailBytes)
if err != nil {
return milestoneBackupLogDetail{}, err
}
return milestoneBackupLogDetail{
milestoneBackupLogFile: milestoneBackupLogFile{
Name: filepath.Base(path),
Size: info.Size(),
ModifiedAt: info.ModTime(),
Summary: parseMilestoneBackupLogSummary(text),
},
TailLines: tailNonEmptyLines(text, backupLogTailLineCount),
}, nil
}
func readMilestoneBackupLogDetailSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string, fileName string) (milestoneBackupLogDetail, error) {
if err := validateMilestoneBackupLogFileName(fileName); err != nil {
return milestoneBackupLogDetail{}, err
}
share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings)
if err != nil {
return milestoneBackupLogDetail{}, err
}
defer cleanup()
relativeRoot, err := settings.relativeSMBPath(rootPath)
if err != nil {
return milestoneBackupLogDetail{}, err
}
relativeFilePath := joinSMBRelativePath(relativeRoot, fileName)
info, err := share.Stat(relativeFilePath)
if err != nil {
return milestoneBackupLogDetail{}, err
}
if info.IsDir() {
return milestoneBackupLogDetail{}, os.ErrNotExist
}
text, err := readSMBTextFileTail(share, relativeFilePath, backupLogDetailTailBytes)
if err != nil {
return milestoneBackupLogDetail{}, err
}
return milestoneBackupLogDetail{
milestoneBackupLogFile: milestoneBackupLogFile{
Name: filepath.Base(fileName),
Size: info.Size(),
ModifiedAt: info.ModTime(),
Summary: parseMilestoneBackupLogSummary(text),
},
TailLines: tailNonEmptyLines(text, backupLogTailLineCount),
}, nil
}
func resolveMilestoneBackupLogPath(rootPath string, fileName string) (string, error) {
if err := validateMilestoneBackupLogFileName(fileName); err != nil {
return "", err
}
cleanName := strings.TrimSpace(fileName)
return filepath.Join(rootPath, cleanName), nil
}
func validateMilestoneBackupLogFileName(fileName string) error {
cleanName := strings.TrimSpace(fileName)
if cleanName == "" ||
cleanName != filepath.Base(cleanName) ||
strings.Contains(cleanName, "/") ||
strings.Contains(cleanName, `\`) ||
!strings.EqualFold(filepath.Ext(cleanName), ".log") {
return errInvalidMilestoneBackupLogName
}
return nil
}
func readTextFileTail(path string, maxBytes int64) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", err
}
return readTextReadSeekerTail(file, info.Size(), maxBytes)
}
func readSMBTextFileTail(share *smb2.Share, path string, maxBytes int64) (string, error) {
file, err := share.Open(path)
if err != nil {
return "", err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", err
}
return readTextReadSeekerTail(file, info.Size(), maxBytes)
}
func readTextReadSeekerTail(file interface {
io.Reader
io.ReaderAt
io.Seeker
}, size int64, maxBytes int64) (string, error) {
start := int64(0)
if size > maxBytes {
start = size - maxBytes
}
reader := io.NewSectionReader(file, start, size-start)
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
if start > 0 {
if index := bytes.IndexByte(data, '\n'); index >= 0 && index+1 < len(data) {
data = data[index+1:]
}
}
return decodeMilestoneBackupLogText(data), nil
}
func listMilestoneBackupFolders(ctx context.Context, settings milestoneBackupSettings, rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) {
if settings.hasNAS() {
return listMilestoneBackupFoldersSMB(ctx, settings, rootPath, currentPath)
}
return listMilestoneBackupFoldersLocal(rootPath, currentPath)
}
func listMilestoneBackupShareRoots(ctx context.Context, settings milestoneBackupSettings) ([]milestoneBackupFolderRoot, error) {
session, cleanup, err := openMilestoneBackupSMBSession(ctx, settings)
if err != nil {
return nil, err
}
defer cleanup()
shareNames, err := session.ListSharenames()
if err != nil {
return nil, err
}
roots := make([]milestoneBackupFolderRoot, 0, len(shareNames))
for _, shareName := range shareNames {
shareName = normalizeBackupNasShare(shareName)
if shareName == "" || strings.EqualFold(shareName, "IPC$") {
continue
}
roots = append(roots, milestoneBackupFolderRoot{
Label: shareName,
Path: `\\` + settings.NasHost + `\` + shareName,
})
}
sort.Slice(roots, func(left, right int) bool {
return strings.ToLower(roots[left].Label) < strings.ToLower(roots[right].Label)
})
return roots, nil
}
func listMilestoneBackupFoldersLocal(rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) {
if !pathIsInsideBackupRoot(rootPath, currentPath) {
return nil, errInvalidMilestoneBackupLogName
}
entries, err := os.ReadDir(currentPath)
if err != nil {
return nil, err
}
folders := make([]milestoneBackupFolderItem, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(currentPath, entry.Name())
folders = append(folders, milestoneBackupFolderItem{
Name: entry.Name(),
DisplayName: entry.Name(),
Path: path,
HasChildren: directoryHasChildFolders(path),
})
}
sort.Slice(folders, func(left, right int) bool {
return strings.ToLower(folders[left].Name) < strings.ToLower(folders[right].Name)
})
return folders, nil
}
func listMilestoneBackupFoldersSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) {
if !pathIsInsideBackupRoot(rootPath, currentPath) {
return nil, errInvalidMilestoneBackupLogName
}
share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings)
if err != nil {
return nil, err
}
defer cleanup()
relativeCurrentPath, err := settings.relativeSMBPath(currentPath)
if err != nil {
return nil, err
}
entries, err := share.ReadDir(relativeCurrentPath)
if err != nil {
return nil, err
}
folders := make([]milestoneBackupFolderItem, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
relativePath := joinSMBRelativePath(relativeCurrentPath, entry.Name())
folders = append(folders, milestoneBackupFolderItem{
Name: entry.Name(),
DisplayName: entry.Name(),
Path: settings.uncPathFromRelative(relativePath),
HasChildren: smbDirectoryHasChildFolders(share, relativePath),
})
}
sort.Slice(folders, func(left, right int) bool {
return strings.ToLower(folders[left].Name) < strings.ToLower(folders[right].Name)
})
return folders, nil
}
func pathIsInsideBackupRoot(rootPath string, currentPath string) bool {
root := normalizeBackupDisplayPath(rootPath)
current := normalizeBackupDisplayPath(currentPath)
return root != "" && (current == root || strings.HasPrefix(current, root+`\`))
}
func normalizeBackupDisplayPath(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "/", `\`)
value = strings.TrimRight(value, `\`)
if strings.HasPrefix(value, `\\`) {
value = `\\` + strings.TrimPrefix(value, `\\`)
}
return strings.ToLower(value)
}
func (settings milestoneBackupSettings) canonicalDisplayPath(value string) string {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "/", `\`)
value = strings.TrimRight(value, `\`)
if value == "" {
return ""
}
if strings.HasPrefix(value, `\\`) {
return `\\` + strings.TrimPrefix(value, `\\`)
}
host := strings.TrimSpace(settings.NasHost)
if host != "" && strings.HasPrefix(strings.ToLower(value), strings.ToLower(host)+`\`) {
return `\\` + value
}
return value
}
func directoryHasChildFolders(path string) bool {
entries, err := os.ReadDir(path)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
return true
}
}
return false
}
func smbDirectoryHasChildFolders(share *smb2.Share, path string) bool {
entries, err := share.ReadDir(path)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
return true
}
}
return false
}
func openMilestoneBackupSMBSession(ctx context.Context, settings milestoneBackupSettings) (*smb2.Session, func(), error) {
dialer := net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(settings.NasHost, "445"))
if err != nil {
return nil, func() {}, err
}
domain, username := splitBackupNasUsername(settings.NasUsername)
smbDialer := smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: username,
Password: settings.NasPassword,
Domain: domain,
},
}
session, err := smbDialer.DialContext(ctx, conn)
if err != nil {
conn.Close()
return nil, func() {}, err
}
cleanup := func() {
session.Logoff()
conn.Close()
}
return session, cleanup, nil
}
func openMilestoneBackupSMBShare(ctx context.Context, settings milestoneBackupSettings) (*smb2.Share, func(), error) {
session, cleanupSession, err := openMilestoneBackupSMBSession(ctx, settings)
if err != nil {
return nil, func() {}, err
}
share, err := session.Mount(settings.NasShare)
if err != nil {
cleanupSession()
return nil, func() {}, err
}
share = share.WithContext(ctx)
cleanup := func() {
share.Umount()
cleanupSession()
}
return share, cleanup, nil
}
func splitBackupNasUsername(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return "", ""
}
if index := strings.Index(value, `\`); index > 0 {
return value[:index], value[index+1:]
}
if index := strings.Index(value, `/`); index > 0 {
return value[:index], value[index+1:]
}
return "", value
}
func backupUNCPathHostAndShare(value string) (string, string) {
value = strings.TrimSpace(value)
value = strings.ReplaceAll(value, "/", `\`)
value = strings.TrimPrefix(value, `\\`)
value = strings.Trim(value, `\`)
parts := strings.Split(value, `\`)
if len(parts) < 2 {
return normalizeBackupNasHost(value), ""
}
return normalizeBackupNasHost(parts[0]), normalizeBackupNasShare(parts[1])
}
func (settings milestoneBackupSettings) relativeSMBPath(displayPath string) (string, error) {
base := normalizeBackupDisplayPath(settings.uncRootPath())
current := normalizeBackupDisplayPath(displayPath)
if current == "" || current == base {
return ".", nil
}
if !strings.HasPrefix(current, base+`\`) {
return "", errInvalidMilestoneBackupLogName
}
relativePath := strings.TrimPrefix(current, base+`\`)
relativePath = strings.ReplaceAll(relativePath, `\`, "/")
relativePath = pathpkg.Clean(relativePath)
if relativePath == "." || relativePath == "/" {
return ".", nil
}
if relativePath == ".." || strings.HasPrefix(relativePath, "../") {
return "", errInvalidMilestoneBackupLogName
}
return relativePath, nil
}
func (settings milestoneBackupSettings) uncPathFromRelative(relativePath string) string {
cleanRelativePath := strings.TrimSpace(relativePath)
cleanRelativePath = strings.Trim(cleanRelativePath, `/\`)
cleanRelativePath = strings.ReplaceAll(cleanRelativePath, "/", `\`)
if cleanRelativePath == "" || cleanRelativePath == "." {
return settings.uncRootPath()
}
return strings.TrimRight(settings.uncRootPath(), `\`) + `\` + cleanRelativePath
}
func joinSMBRelativePath(basePath string, childName string) string {
basePath = strings.TrimSpace(basePath)
childName = strings.Trim(childName, `/\`)
if basePath == "" || basePath == "." {
return childName
}
return strings.TrimRight(basePath, `/\`) + "/" + childName
}
func decodeMilestoneBackupLogText(data []byte) string {
if len(data) >= 2 {
if data[0] == 0xff && data[1] == 0xfe {
return decodeUTF16(data, unicode.LittleEndian)
}
if data[0] == 0xfe && data[1] == 0xff {
return decodeUTF16(data, unicode.BigEndian)
}
}
if looksLikeUTF16LE(data) {
return decodeUTF16(data, unicode.LittleEndian)
}
if looksLikeUTF16BE(data) {
return decodeUTF16(data, unicode.BigEndian)
}
if utf8.Valid(data) {
return string(data)
}
return string(bytes.ToValidUTF8(data, []byte("?")))
}
func decodeUTF16(data []byte, endianness unicode.Endianness) string {
decoder := unicode.UTF16(endianness, unicode.UseBOM).NewDecoder()
decoded, _, err := transform.Bytes(decoder, data)
if err != nil {
return string(bytes.ToValidUTF8(data, []byte("?")))
}
return string(decoded)
}
func looksLikeUTF16LE(data []byte) bool {
return countZeroBytesAtParity(data, 1) > len(data)/8
}
func looksLikeUTF16BE(data []byte) bool {
return countZeroBytesAtParity(data, 0) > len(data)/8
}
func countZeroBytesAtParity(data []byte, parity int) int {
count := 0
limit := len(data)
if limit > 4096 {
limit = 4096
}
for index := parity; index < limit; index += 2 {
if data[index] == 0 {
count++
}
}
return count
}
var backupLogSummaryLinePattern = regexp.MustCompile(`^\s*([^:]{1,48})\s*:\s*(.+?)\s*$`)
var multiSpacePattern = regexp.MustCompile(`\s{2,}`)
var leadingIntegerPattern = regexp.MustCompile(`^\D*([0-9][0-9.,]*)`)
func parseMilestoneBackupLogSummary(text string) milestoneBackupLogSummary {
summary := milestoneBackupLogSummary{
Status: "unknown",
Rows: []milestoneBackupLogSummaryRow{},
}
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
startIndex := len(lines) - 600
if startIndex < 0 {
startIndex = 0
}
for _, line := range lines[startIndex:] {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
continue
}
lowerLine := strings.ToLower(trimmedLine)
if strings.Contains(lowerLine, "started") || strings.Contains(lowerLine, "gestartet") {
if value := valueAfterColon(trimmedLine); value != "" {
summary.StartedAt = value
}
}
if strings.Contains(lowerLine, "ended") || strings.Contains(lowerLine, "beendet") {
if value := valueAfterColon(trimmedLine); value != "" {
summary.EndedAt = value
}
}
matches := backupLogSummaryLinePattern.FindStringSubmatch(line)
if len(matches) != 3 {
continue
}
key, label := normalizeBackupLogSummaryLabel(matches[1])
if key == "" {
continue
}
cells := parseBackupLogSummaryCells(matches[2])
if len(cells) < 6 {
continue
}
row := milestoneBackupLogSummaryRow{
Key: key,
Label: label,
Total: cells[0],
Copied: cells[1],
Skipped: cells[2],
Mismatch: cells[3],
Failed: cells[4],
Extras: cells[5],
}
summary.Rows = append(summary.Rows, row)
if key == "files" {
summary.TotalFiles = row.Total
summary.CopiedFiles = row.Copied
summary.FailedCount += parseBackupLogInteger(row.Failed)
}
if key == "dirs" {
summary.FailedCount += parseBackupLogInteger(row.Failed)
}
if key == "times" {
summary.Duration = row.Total
}
}
if summary.FailedCount > 0 {
summary.Status = "failed"
} else if len(summary.Rows) > 0 {
summary.Status = "success"
}
return summary
}
func valueAfterColon(value string) string {
index := strings.Index(value, ":")
if index < 0 || index+1 >= len(value) {
return ""
}
return strings.TrimSpace(value[index+1:])
}
func normalizeBackupLogSummaryLabel(value string) (string, string) {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = strings.Trim(normalized, ".")
switch normalized {
case "dirs", "directories", "verzeichnisse", "ordner":
return "dirs", "Verzeichnisse"
case "files", "dateien":
return "files", "Dateien"
case "bytes", "byte":
return "bytes", "Bytes"
case "times", "zeiten", "time":
return "times", "Dauer"
}
return "", ""
}
func parseBackupLogSummaryCells(value string) []string {
cells := multiSpacePattern.Split(strings.TrimSpace(value), -1)
if len(cells) >= 6 {
return cells[:6]
}
fields := strings.Fields(value)
if len(fields) >= 6 {
return fields[:6]
}
return cells
}
func parseBackupLogInteger(value string) int {
match := leadingIntegerPattern.FindStringSubmatch(strings.TrimSpace(value))
if len(match) != 2 {
return 0
}
cleanValue := strings.NewReplacer(".", "", ",", "", " ", "").Replace(match[1])
number, err := strconv.Atoi(cleanValue)
if err != nil {
return 0
}
return number
}
func tailNonEmptyLines(text string, maxLines int) []string {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
cleanLines := make([]string, 0, len(lines))
for _, line := range lines {
cleanLines = append(cleanLines, strings.TrimRight(line, "\r"))
}
for len(cleanLines) > 0 && strings.TrimSpace(cleanLines[len(cleanLines)-1]) == "" {
cleanLines = cleanLines[:len(cleanLines)-1]
}
if len(cleanLines) > maxLines {
cleanLines = cleanLines[len(cleanLines)-maxLines:]
}
return cleanLines
}