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 }