1192 lines
30 KiB
Go
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
|
|
}
|