From 61b1dacde2b7f6ab4ac1579d537e2838c932f34d Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 18:41:36 +0200 Subject: [PATCH 01/54] feat: add git backup package --- internal/backup/config.go | 14 ++ internal/backup/gitignore.go | 26 +++ internal/backup/gitignore_test.go | 49 +++++ internal/backup/repo.go | 322 ++++++++++++++++++++++++++++ internal/backup/repo_test.go | 334 ++++++++++++++++++++++++++++++ internal/backup/scheduler.go | 62 ++++++ internal/backup/scheduler_test.go | 130 ++++++++++++ internal/backup/status.go | 39 ++++ 8 files changed, 976 insertions(+) create mode 100644 internal/backup/config.go create mode 100644 internal/backup/gitignore.go create mode 100644 internal/backup/gitignore_test.go create mode 100644 internal/backup/repo.go create mode 100644 internal/backup/repo_test.go create mode 100644 internal/backup/scheduler.go create mode 100644 internal/backup/scheduler_test.go create mode 100644 internal/backup/status.go diff --git a/internal/backup/config.go b/internal/backup/config.go new file mode 100644 index 000000000..c42904f17 --- /dev/null +++ b/internal/backup/config.go @@ -0,0 +1,14 @@ +package backup + +type Config struct { + Enabled bool + RootDir string // path to LeafWiki root/ content directory + AssetsDir string // path to LeafWiki assets/ directory + AuthorName string + AuthorEmail string + RemoteURL string // SSH remote, e.g. git@github.com:user/repo.git + Branch string // remote branch to push to, default "main" + SSHKeyPath string // path to private key file (optional if SSHKey set) + SSHKey string // raw PEM private key (env var preferred) + IntervalMinutes int // how often to run the scheduled backup, default 60 +} \ No newline at end of file diff --git a/internal/backup/gitignore.go b/internal/backup/gitignore.go new file mode 100644 index 000000000..db5222af9 --- /dev/null +++ b/internal/backup/gitignore.go @@ -0,0 +1,26 @@ +package backup + +import ( + "os" + "path/filepath" +) + +const gitignoreContent = `# LeafWiki runtime files – do not commit +*.db +*.db-shm +*.db-wal +*.tmp +.tmp-* +` + +// EnsureGitignore writes a .gitignore to repoDir if it does not already exist. +func EnsureGitignore(repoDir string) error { + gitignorePath := filepath.Join(repoDir, ".gitignore") + if _, err := os.Stat(gitignorePath); err == nil { + // File exists, do not overwrite + return nil + } else if !os.IsNotExist(err) { + return err + } + return os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644) +} \ No newline at end of file diff --git a/internal/backup/gitignore_test.go b/internal/backup/gitignore_test.go new file mode 100644 index 000000000..2b752df87 --- /dev/null +++ b/internal/backup/gitignore_test.go @@ -0,0 +1,49 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnsureGitignore_CreatesFile(t *testing.T) { + tmpDir := t.TempDir() + err := EnsureGitignore(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignore failed: %v", err) + } + + expectedPath := filepath.Join(tmpDir, ".gitignore") + content, err := os.ReadFile(expectedPath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + if string(content) != gitignoreContent { + t.Errorf("expected content %q, got %q", gitignoreContent, string(content)) + } +} + +func TestEnsureGitignore_DoesNotOverwrite(t *testing.T) { + tmpDir := t.TempDir() + gitignorePath := filepath.Join(tmpDir, ".gitignore") + existingContent := "existing content\n" + err := os.WriteFile(gitignorePath, []byte(existingContent), 0644) + if err != nil { + t.Fatalf("failed to write existing .gitignore: %v", err) + } + + err = EnsureGitignore(tmpDir) + if err != nil { + t.Fatalf("EnsureGitignore failed: %v", err) + } + + content, err := os.ReadFile(gitignorePath) + if err != nil { + t.Fatalf("failed to read .gitignore: %v", err) + } + + if string(content) != existingContent { + t.Errorf("expected content %q, got %q", existingContent, string(content)) + } +} \ No newline at end of file diff --git a/internal/backup/repo.go b/internal/backup/repo.go new file mode 100644 index 000000000..7a08ade38 --- /dev/null +++ b/internal/backup/repo.go @@ -0,0 +1,322 @@ +package backup + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/ssh" + sshcrypto "golang.org/x/crypto/ssh" +) + +// Repository wraps a git repository with backup-specific state. +type Repository struct { + cfg Config + repo *gogit.Repository + status *Status +} + +// Init opens an existing repo at repoDir or initialises a new one. +// On first init, stages root/ and assets/ and makes an initial commit. +func Init(cfg Config) (*Repository, error) { + repoDir := filepath.Dir(cfg.RootDir) + if cfg.RootDir == "" { + return nil, fmt.Errorf("RootDir is required") + } + if cfg.AssetsDir == "" { + return nil, fmt.Errorf("AssetsDir is required") + } + + // Ensure parent directory exists + if err := os.MkdirAll(repoDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create repo directory: %w", err) + } + + r := &Repository{ + cfg: cfg, + status: &Status{}, + } + + // Try to open existing repo + repo, err := gogit.PlainOpen(repoDir) + if err == nil { + r.repo = repo + return r, nil + } + + // Initialize new repo + repo, err = gogit.PlainInit(repoDir, false) + if err != nil { + return nil, fmt.Errorf("failed to init repo: %w", err) + } + r.repo = repo + + // Create initial commit with root/ and assets/ if they exist + if err := r.makeInitialCommit(); err != nil { + return nil, fmt.Errorf("failed to make initial commit: %w", err) + } + + return r, nil +} + +// makeInitialCommit creates the first commit with root/ and assets/ directories. +func (r *Repository) makeInitialCommit() error { + wt, err := r.repo.Worktree() + if err != nil { + return err + } + + // Compute relative paths from repo root + repoDir := filepath.Dir(r.cfg.RootDir) + rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) + if err != nil { + return fmt.Errorf("failed to compute relative path for root: %w", err) + } + assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) + if err != nil { + return fmt.Errorf("failed to compute relative path for assets: %w", err) + } + + // Stage root/ and assets/ directories using relative paths + // Track if we actually staged any content (files within directories) + stagedFiles := false + if _, err := os.Stat(r.cfg.RootDir); err == nil { + if _, err := wt.Add(rootRel); err != nil { + return fmt.Errorf("failed to stage root dir: %w", err) + } + // Check if root has any files + if hasFiles(r.cfg.RootDir) { + stagedFiles = true + } + } + if _, err := os.Stat(r.cfg.AssetsDir); err == nil { + if _, err := wt.Add(assetsRel); err != nil { + return fmt.Errorf("failed to stage assets dir: %w", err) + } + // Check if assets has any files + if hasFiles(r.cfg.AssetsDir) { + stagedFiles = true + } + } + + // If no files were found in root/assets, skip initial commit + // The first RunBackup will create the commit when there's actual content + if !stagedFiles { + return nil + } + + // Check if there's anything to commit + status, err := wt.Status() + if err != nil { + return err + } + if status.IsClean() { + return nil // Nothing to commit + } + + commit, err := wt.Commit("Initial commit", &gogit.CommitOptions{ + Author: &object.Signature{ + Name: r.cfg.AuthorName, + Email: r.cfg.AuthorEmail, + When: time.Now(), + }, + }) + if err != nil { + return fmt.Errorf("failed to commit: %w", err) + } + + // Push to remote if configured + if r.cfg.RemoteURL != "" { + if err := r.push(commit.String()); err != nil { + r.status.SetError(err.Error()) + // Don't return error - initial commit succeeded + } + } + + return nil +} + +// hasFiles returns true if the directory contains any files (non-recursive). +func hasFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() { + return true + } + } + return false +} + +// RunBackup stages all changes in root/ and assets/, commits if anything +// changed, then pushes to the configured remote. +// message format: "backup: " +// Returns nil and skips commit+push if the working tree is clean. +func (r *Repository) RunBackup() error { + wt, err := r.repo.Worktree() + if err != nil { + errMsg := fmt.Errorf("failed to get worktree: %w", err).Error() + r.status.SetError(errMsg) + return nil // Never propagate + } + + // Compute relative paths from repo root + repoDir := filepath.Dir(r.cfg.RootDir) + rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) + if err != nil { + r.status.SetError(fmt.Errorf("failed to compute relative path for root: %w", err).Error()) + return nil + } + assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) + if err != nil { + r.status.SetError(fmt.Errorf("failed to compute relative path for assets: %w", err).Error()) + return nil + } + + // Stage root/ and assets/ directories using relative paths + if _, err := os.Stat(r.cfg.RootDir); err == nil { + if _, err := wt.Add(rootRel); err != nil { + r.status.SetError(fmt.Errorf("failed to stage root dir: %w", err).Error()) + return nil + } + } + if _, err := os.Stat(r.cfg.AssetsDir); err == nil { + if _, err := wt.Add(assetsRel); err != nil { + r.status.SetError(fmt.Errorf("failed to stage assets dir: %w", err).Error()) + return nil + } + } + + // Check working tree status + status, err := wt.Status() + if err != nil { + errMsg := fmt.Errorf("failed to get status: %w", err).Error() + r.status.SetError(errMsg) + return nil // Never propagate + } + + if status.IsClean() { + // Even when there's nothing to commit, record successful backup check + r.status.SetSuccess(time.Now()) + return nil // Nothing to commit + } + + // Commit changes + commitMsg := fmt.Sprintf("backup: %s", time.Now().Format(time.RFC3339)) + commit, err := wt.Commit(commitMsg, &gogit.CommitOptions{ + Author: &object.Signature{ + Name: r.cfg.AuthorName, + Email: r.cfg.AuthorEmail, + When: time.Now(), + }, + }) + if err != nil { + errMsg := fmt.Errorf("failed to commit: %w", err).Error() + r.status.SetError(errMsg) + return nil // Never propagate + } + + // Push to remote + if r.cfg.RemoteURL != "" { + if err := r.push(commit.String()); err != nil { + r.status.SetError(err.Error()) + return nil // Never propagate + } + } + + r.status.SetSuccess(time.Now()) + return nil +} + +// push pushes the given commit hash to the configured remote. +func (r *Repository) push(commitHash string) error { + // Build SSH auth + auth, err := r.buildSSHAuth() + if err != nil { + return fmt.Errorf("failed to build SSH auth: %w", err) + } + + // Get remote - use r.repo directly since we're using the repo instance + remote, err := r.repo.Remote("origin") + if err != nil { + // Remote doesn't exist, create it + _, err = r.repo.CreateRemote(&config.RemoteConfig{ + Name: "origin", + URLs: []string{r.cfg.RemoteURL}, + }) + if err != nil { + return fmt.Errorf("failed to create remote: %w", err) + } + remote, err = r.repo.Remote("origin") + if err != nil { + return fmt.Errorf("failed to get remote: %w", err) + } + } + + // Push - go-git handles refspec automatically + err = remote.Push(&gogit.PushOptions{ + Auth: auth, + }) + if err != nil { + return fmt.Errorf("failed to push: %w", err) + } + + return nil +} + +// buildSSHAuth builds SSH authentication from config. +func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { + var privateKey []byte + var err error + + // Try SSHKey string first + if r.cfg.SSHKey != "" { + privateKey = []byte(r.cfg.SSHKey) + } else if r.cfg.SSHKeyPath != "" { + privateKey, err = os.ReadFile(r.cfg.SSHKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read SSH key: %w", err) + } + } else { + return nil, fmt.Errorf("no SSH key provided") + } + + // Parse the private key using x/crypto/ssh + signer, err := sshcrypto.ParsePrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse SSH key: %w", err) + } + + // Use go-git ssh package's PublicKeys with the signer + return &ssh.PublicKeys{ + User: "git", + Signer: signer, + }, nil +} + +// Status returns a snapshot of the last backup time and any error. +func (r *Repository) Status() StatusSnapshot { + return r.status.Snapshot() +} + +// getRepoDir returns the parent directory of RootDir (i.e., the git repo root). +func (r *Repository) getRepoDir() string { + return filepath.Dir(r.cfg.RootDir) +} + +// stripPrefix strips the repoDir prefix from a full path to get a relative path. +func stripPrefix(fullPath, repoDir string) string { + rel, _ := filepath.Rel(repoDir, fullPath) + // If rel starts with "..", something is wrong - use just the base name + if strings.HasPrefix(rel, "..") { + return filepath.Base(fullPath) + } + return rel +} \ No newline at end of file diff --git a/internal/backup/repo_test.go b/internal/backup/repo_test.go new file mode 100644 index 000000000..8d2018cd1 --- /dev/null +++ b/internal/backup/repo_test.go @@ -0,0 +1,334 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" +) + +func TestInit_InitializesNewRepo(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + // Create root and assets directories + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + // Create a test file in root + testFile := filepath.Join(rootDir, "test.md") + err = os.WriteFile(testFile, []byte("# Test\n"), 0644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + if repo == nil { + t.Fatal("Init returned nil repo") + } + + // Verify the repo exists + r, err := git.PlainOpen(tmpDir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + + // Verify there's at least one commit + head, err := r.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + if head.Type() != plumbing.HashReference { + t.Errorf("expected HEAD to be a hash reference (branch), got %v", head.Type()) + } + // go-git may create "master" or "main" depending on version/config + // Just verify we have a branch head + branchName := head.Name().Short() + if branchName != "main" && branchName != "master" { + t.Errorf("expected HEAD to be main or master branch, got %s", branchName) + } +} + +func TestInit_OpensExistingRepo(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + // Create directories + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + // Initialize a git repo manually + r, err := git.PlainInit(tmpDir, false) + if err != nil { + t.Fatalf("failed to init repo: %v", err) + } + _ = r + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + if repo == nil { + t.Fatal("Init returned nil repo") + } + + // Verify repo is still valid + _, err = git.PlainOpen(tmpDir) + if err != nil { + t.Fatalf("failed to open existing repo: %v", err) + } +} + +func TestRunBackup_NothingToCommit(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + // Create directories + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Run backup on clean working tree + err = repo.RunBackup() + if err != nil { + t.Fatalf("RunBackup failed: %v", err) + } + + // Verify status is clean + status := repo.Status() + if status.LastError != "" { + t.Errorf("expected no error, got %s", status.LastError) + } +} + +func TestRunBackup_StagesAndCommits(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + // Create directories + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Write a file to root/ + testFile := filepath.Join(rootDir, "new.md") + err = os.WriteFile(testFile, []byte("# New File\n"), 0644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + // Run backup + err = repo.RunBackup() + if err != nil { + t.Fatalf("RunBackup failed: %v", err) + } + + // Verify the file was committed + r, err := git.PlainOpen(tmpDir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + + head, err := r.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + commit, err := r.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("failed to get commit: %v", err) + } + + if commit.Author.Name != "Test Author" { + t.Errorf("expected author name 'Test Author', got %s", commit.Author.Name) + } +} + +func TestRunBackup_OnlyStagedDirs(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + otherDir := filepath.Join(tmpDir, "other") + + // Create directories + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + err = os.MkdirAll(otherDir, 0755) + if err != nil { + t.Fatalf("failed to create other dir: %v", err) + } + + // Write a file outside root/ and assets/ + otherFile := filepath.Join(otherDir, "outside.txt") + err = os.WriteFile(otherFile, []byte("should not be committed\n"), 0644) + if err != nil { + t.Fatalf("failed to write other file: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Write a file to root/ + testFile := filepath.Join(rootDir, "new.md") + err = os.WriteFile(testFile, []byte("# New File\n"), 0644) + if err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + // Run backup + err = repo.RunBackup() + if err != nil { + t.Fatalf("RunBackup failed: %v", err) + } + + // Verify the commit only contains files from root/ and assets/ + r, err := git.PlainOpen(tmpDir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + + head, err := r.Head() + if err != nil { + t.Fatalf("failed to get HEAD: %v", err) + } + + commit, err := r.CommitObject(head.Hash()) + if err != nil { + t.Fatalf("failed to get commit: %v", err) + } + + // Check that "outside.txt" is not in the commit + tree, err := commit.Tree() + if err != nil { + t.Fatalf("failed to get tree: %v", err) + } + + _, err = tree.File("other/outside.txt") + if err == nil { + t.Error("expected 'other/outside.txt' to not be in commit tree") + } + + // Verify the file from root/ is present + _, err = tree.File("root/new.md") + if err != nil { + t.Errorf("expected 'root/new.md' to be in commit tree: %v", err) + } +} + +func TestInit_RequiresRootDir(t *testing.T) { + cfg := Config{ + RootDir: "", + AssetsDir: "/some/path", + } + _, err := Init(cfg) + if err == nil { + t.Error("expected error for empty RootDir") + } +} + +func TestInit_RequiresAssetsDir(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: "", + } + _, err = Init(cfg) + if err == nil { + t.Error("expected error for empty AssetsDir") + } +} \ No newline at end of file diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go new file mode 100644 index 000000000..59e50b923 --- /dev/null +++ b/internal/backup/scheduler.go @@ -0,0 +1,62 @@ +package backup + +import ( + "time" +) + +// Scheduler runs periodic git backups. +type Scheduler struct { + repo *Repository + ticker *time.Ticker + manual chan struct{} + done chan struct{} +} + +// NewScheduler creates and starts the background goroutine. +func NewScheduler(repo *Repository, interval time.Duration) *Scheduler { + s := &Scheduler{ + repo: repo, + ticker: time.NewTicker(interval), + manual: make(chan struct{}, 1), + done: make(chan struct{}), + } + + go s.run() + return s +} + +func (s *Scheduler) run() { + // Run immediately on start + s.repo.RunBackup() + + for { + select { + case <-s.ticker.C: + s.repo.RunBackup() + case <-s.manual: + s.repo.RunBackup() + case <-s.done: + return + } + } +} + +// TriggerNow signals the scheduler to run a backup immediately, +// regardless of the interval. Non-blocking. +func (s *Scheduler) TriggerNow() { + select { + case s.manual <- struct{}{}: + default: + } +} + +// Stop shuts down the goroutine cleanly. +func (s *Scheduler) Stop() { + s.ticker.Stop() + select { + case <-s.done: + // Already closed + default: + close(s.done) + } +} \ No newline at end of file diff --git a/internal/backup/scheduler_test.go b/internal/backup/scheduler_test.go new file mode 100644 index 000000000..e892c35e4 --- /dev/null +++ b/internal/backup/scheduler_test.go @@ -0,0 +1,130 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestScheduler_TriggerNow(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Create scheduler with a long interval so it won't fire naturally + scheduler := NewScheduler(repo, 10*time.Minute) + defer scheduler.Stop() + + // Give it a moment to process the initial run + time.Sleep(100 * time.Millisecond) + + // TriggerNow should not block + scheduler.TriggerNow() + + // Give it a moment to process + time.Sleep(100 * time.Millisecond) + + // Verify that status was updated (backup ran) + if repo.status.LastBackupAt.IsZero() { + t.Error("expected LastBackupAt to be set after TriggerNow") + } +} + +func TestScheduler_Stop(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + scheduler := NewScheduler(repo, 10*time.Minute) + + // Stop should not block + scheduler.Stop() + + // Verify we can call Stop multiple times safely + scheduler.Stop() +} + +func TestScheduler_RunsOnStart(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // Create scheduler with very long interval + scheduler := NewScheduler(repo, 10*time.Hour) + defer scheduler.Stop() + + // Wait a moment for the initial run + time.Sleep(100 * time.Millisecond) + + // Verify that LastBackupAt was set (scheduler ran immediately on start) + if repo.status.LastBackupAt.IsZero() { + t.Error("expected scheduler to run immediately on start") + } +} \ No newline at end of file diff --git a/internal/backup/status.go b/internal/backup/status.go new file mode 100644 index 000000000..3fc582b3e --- /dev/null +++ b/internal/backup/status.go @@ -0,0 +1,39 @@ +package backup + +import ( + "sync" + "time" +) + +type Status struct { + mu sync.RWMutex + LastBackupAt time.Time + LastError string +} + +func (s *Status) SetSuccess(t time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.LastBackupAt = t + s.LastError = "" +} + +func (s *Status) SetError(err string) { + s.mu.Lock() + defer s.mu.Unlock() + s.LastError = err +} + +func (s *Status) Snapshot() StatusSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + return StatusSnapshot{ + LastBackupAt: s.LastBackupAt, + LastError: s.LastError, + } +} + +type StatusSnapshot struct { + LastBackupAt time.Time `json:"lastBackupAt"` + LastError string `json:"lastError"` +} \ No newline at end of file From d5344818e2c4e094e043c6ca184f6f514691542e Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 18:42:03 +0200 Subject: [PATCH 02/54] feat: add git backup CLI flags and initialization in cmd/leafwiki --- cmd/leafwiki/main.go | 75 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 17 ++++++++++ go.sum | 77 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index 90d358576..aabd614b4 100644 --- a/cmd/leafwiki/main.go +++ b/cmd/leafwiki/main.go @@ -7,10 +7,13 @@ import ( "log/slog" "math" "os" + "path/filepath" "strings" "time" "github.com/dustin/go-humanize" + "github.com/perber/wiki/internal/backup" + wikibackup "github.com/perber/wiki/internal/wiki/backup" "github.com/perber/wiki/internal/core/tools" httpinternal "github.com/perber/wiki/internal/http" authmw "github.com/perber/wiki/internal/http/middleware/auth" @@ -51,6 +54,14 @@ func writeUsage(w io.Writer) { --http-remote-user-header-name HTTP header carrying the username from a trusted proxy (default: Remote-User) --trusted-proxy-ips Comma-separated trusted proxy IPs/CIDRs (e.g. 127.0.0.1,172.18.0.0/16) --http-remote-user-logout-url URL the frontend redirects to after logout in proxy-auth mode (default: "") + --git-backup Enable git backup to a remote repository (default: false) + --git-backup-author-name Git commit author name for backups (default: LeafWiki Backup) + --git-backup-author-email Git commit author email for backups (default: backup@leafwiki.local) + --git-backup-remote Git remote URL (SSH) for backups (required when git-backup is enabled) + --git-backup-branch Git branch to push to (default: main) + --git-backup-ssh-key-path Path to SSH private key for git backup + --git-backup-ssh-key Raw SSH private key for git backup (env var preferred) + --git-backup-interval Git backup interval in minutes (default: 60) Environment variables: LEAFWIKI_HOST @@ -76,6 +87,14 @@ func writeUsage(w io.Writer) { LEAFWIKI_HTTP_REMOTE_USER_HEADER_NAME LEAFWIKI_TRUSTED_PROXY_IPS LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL + LEAFWIKI_GIT_BACKUP + LEAFWIKI_GIT_BACKUP_AUTHOR_NAME + LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL + LEAFWIKI_GIT_BACKUP_REMOTE + LEAFWIKI_GIT_BACKUP_BRANCH + LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH + LEAFWIKI_GIT_BACKUP_SSH_KEY + LEAFWIKI_GIT_BACKUP_INTERVAL `); err != nil { panic(err) } @@ -131,6 +150,14 @@ type cliFlags struct { httpRemoteUserHeader *string trustedProxyIPs *string httpRemoteUserLogoutURL *string + gitBackup *bool + gitBackupAuthorName *string + gitBackupAuthorEmail *string + gitBackupRemote *string + gitBackupBranch *string + gitBackupSSHKeyPath *string + gitBackupSSHKey *string + gitBackupInterval *int } func registerFlags(fs *flag.FlagSet) *cliFlags { @@ -157,6 +184,14 @@ func registerFlags(fs *flag.FlagSet) *cliFlags { httpRemoteUserHeader: fs.String("http-remote-user-header-name", "Remote-User", "HTTP header name carrying the username from a trusted proxy (default: Remote-User)"), trustedProxyIPs: fs.String("trusted-proxy-ips", "", "comma-separated list of trusted proxy IPs/CIDRs (e.g. 127.0.0.1,172.18.0.0/16)"), httpRemoteUserLogoutURL: fs.String("http-remote-user-logout-url", "", "URL the frontend redirects to after logout when reverse-proxy auth is active (e.g. https://auth.example.com/logout)"), + gitBackup: fs.Bool("git-backup", false, "enable git backup to a remote repository (default: false)"), + gitBackupAuthorName: fs.String("git-backup-author-name", "", "git commit author name for backups (default: LeafWiki Backup)"), + gitBackupAuthorEmail: fs.String("git-backup-author-email", "", "git commit author email for backups (default: backup@leafwiki.local)"), + gitBackupRemote: fs.String("git-backup-remote", "", "git remote URL (SSH) for backups (required when git-backup is enabled)"), + gitBackupBranch: fs.String("git-backup-branch", "", "git branch to push to (default: main)"), + gitBackupSSHKeyPath: fs.String("git-backup-ssh-key-path", "", "path to SSH private key for git backup"), + gitBackupSSHKey: fs.String("git-backup-ssh-key", "", "raw SSH private key for git backup (env var preferred)"), + gitBackupInterval: fs.Int("git-backup-interval", 0, "git backup interval in minutes (default: 60)"), } } @@ -199,11 +234,24 @@ func main() { httpRemoteUserHeader := resolveString("http-remote-user-header-name", *flags.httpRemoteUserHeader, visited, "LEAFWIKI_HTTP_REMOTE_USER_HEADER_NAME", "Remote-User") trustedProxyIPsRaw := resolveString("trusted-proxy-ips", *flags.trustedProxyIPs, visited, "LEAFWIKI_TRUSTED_PROXY_IPS", "") httpRemoteUserLogoutURL := resolveString("http-remote-user-logout-url", *flags.httpRemoteUserLogoutURL, visited, "LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL", "") + gitBackupEnabled := resolveBool("git-backup", *flags.gitBackup, visited, "LEAFWIKI_GIT_BACKUP") + gitBackupAuthorName := resolveString("git-backup-author-name", *flags.gitBackupAuthorName, visited, "LEAFWIKI_GIT_BACKUP_AUTHOR_NAME", "LeafWiki Backup") + gitBackupAuthorEmail := resolveString("git-backup-author-email", *flags.gitBackupAuthorEmail, visited, "LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL", "backup@leafwiki.local") + gitBackupRemote := resolveString("git-backup-remote", *flags.gitBackupRemote, visited, "LEAFWIKI_GIT_BACKUP_REMOTE", "") + gitBackupBranch := resolveString("git-backup-branch", *flags.gitBackupBranch, visited, "LEAFWIKI_GIT_BACKUP_BRANCH", "main") + gitBackupSSHKeyPath := resolveString("git-backup-ssh-key-path", *flags.gitBackupSSHKeyPath, visited, "LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH", "") + gitBackupSSHKey := resolveString("git-backup-ssh-key", *flags.gitBackupSSHKey, visited, "LEAFWIKI_GIT_BACKUP_SSH_KEY", "") + gitBackupInterval := resolveInt("git-backup-interval", *flags.gitBackupInterval, visited, "LEAFWIKI_GIT_BACKUP_INTERVAL", 60) trustedProxies, err := authmw.ParseTrustedProxies(trustedProxyIPsRaw) if err != nil { fail("invalid --trusted-proxy-ips value", "error", err) } + // Validate git backup configuration + if gitBackupEnabled && gitBackupRemote == "" { + fail("git-backup-remote is required when git-backup is enabled. Set it using --git-backup-remote or LEAFWIKI_GIT_BACKUP_REMOTE.") + } + args := flag.Args() if len(args) > 0 { switch args[0] { @@ -271,6 +319,33 @@ func main() { } }() + // Initialize git backup if enabled + var backupScheduler *backup.Scheduler + if gitBackupEnabled { + repoDir := dataDir + if err := backup.EnsureGitignore(repoDir); err != nil { + fail("git backup init failed: %v", err) + } + backupRepo, err := backup.Init(backup.Config{ + Enabled: true, + RootDir: filepath.Join(dataDir, "root"), + AssetsDir: filepath.Join(dataDir, "assets"), + AuthorName: gitBackupAuthorName, + AuthorEmail: gitBackupAuthorEmail, + RemoteURL: gitBackupRemote, + Branch: gitBackupBranch, + SSHKeyPath: gitBackupSSHKeyPath, + SSHKey: gitBackupSSHKey, + IntervalMinutes: gitBackupInterval, + }) + if err != nil { + fail("git backup init failed: %v", err) + } + backupScheduler = backup.NewScheduler(backupRepo, time.Duration(gitBackupInterval)*time.Minute) + defer backupScheduler.Stop() + w.SetBackupRoutes(wikibackup.NewRoutes(backupRepo, backupScheduler, w.AuthService())) + } + router := httpinternal.NewRouter(w.Registrars(), w.FrontendConfig(), httpinternal.RouterOptions{ PublicAccess: publicAccess, InjectCodeInHeader: injectCodeInHeader, diff --git a/go.mod b/go.mod index 0f5c714b1..458319b1c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.10.1 github.com/gin-gonic/gin v1.12.0 + github.com/go-git/go-git/v5 v5.19.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gosimple/slug v1.15.0 github.com/microcosm-cc/bluemonday v1.0.27 @@ -18,22 +19,33 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -41,17 +53,22 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 7d5beb16a..fc9b213b5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,14 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -6,13 +17,21 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -21,6 +40,16 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -35,6 +64,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -50,12 +81,19 @@ github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6 github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -71,8 +109,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= @@ -81,15 +125,22 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -102,6 +153,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= @@ -110,26 +163,46 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 10362c0978b1137b819af26214ff35251bc0b152 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 18:42:27 +0200 Subject: [PATCH 03/54] feat: add backup admin API routes --- internal/wiki/backup/routes.go | 69 ++++++++++++++++++++++++++++++++++ internal/wiki/wiki.go | 18 ++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 internal/wiki/backup/routes.go diff --git a/internal/wiki/backup/routes.go b/internal/wiki/backup/routes.go new file mode 100644 index 000000000..391cbda69 --- /dev/null +++ b/internal/wiki/backup/routes.go @@ -0,0 +1,69 @@ +package wikibackup + +import ( + "net/http" + + "github.com/gin-gonic/gin" + coreauth "github.com/perber/wiki/internal/core/auth" + backupSvc "github.com/perber/wiki/internal/backup" + httpinternal "github.com/perber/wiki/internal/http" + authmw "github.com/perber/wiki/internal/http/middleware/auth" + "github.com/perber/wiki/internal/http/middleware/security" +) + +// Routes is the RouteRegistrar for the backup admin endpoints. +type Routes struct { + repo *backupSvc.Repository + scheduler *backupSvc.Scheduler + authService *coreauth.AuthService +} + +// NewRoutes constructs the backup RouteRegistrar. +func NewRoutes(repo *backupSvc.Repository, scheduler *backupSvc.Scheduler, authService *coreauth.AuthService) *Routes { + return &Routes{ + repo: repo, + scheduler: scheduler, + authService: authService, + } +} + +// RegisterRoutes implements RouteRegistrar. +func (r *Routes) RegisterRoutes(ctx httpinternal.RouterContext) { + opts := ctx.Opts + + authGroup := ctx.Base.Group("/api") + authGroup.Use( + authmw.InjectPublicEditor(opts.AuthDisabled), + authmw.RequireAuth(r.authService, ctx.AuthCookies, opts.AuthDisabled), + security.CSRFMiddleware(ctx.CSRFCookie), + ) + + // Admin-only backup endpoints + adminGroup := authGroup.Group("/admin") + adminGroup.Use(authmw.RequireAdmin(opts.AuthDisabled)) + + adminGroup.GET("/backup/status", r.handleGetBackupStatus) + adminGroup.POST("/backup/push", r.handleTriggerBackup) +} + +// handleGetBackupStatus returns the current backup status. +func (r *Routes) handleGetBackupStatus(c *gin.Context) { + if r.scheduler == nil || r.repo == nil { + c.JSON(http.StatusOK, gin.H{"enabled": false}) + return + } + c.JSON(http.StatusOK, gin.H{ + "enabled": true, + "status": r.repo.Status(), + }) +} + +// handleTriggerBackup triggers an immediate backup and returns 202 Accepted. +func (r *Routes) handleTriggerBackup(c *gin.Context) { + if r.scheduler == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "backup not enabled"}) + return + } + r.scheduler.TriggerNow() + c.JSON(http.StatusAccepted, gin.H{"triggered": true}) +} \ No newline at end of file diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go index 783f79144..e14d1e926 100644 --- a/internal/wiki/wiki.go +++ b/internal/wiki/wiki.go @@ -31,6 +31,7 @@ import ( wikirevisions "github.com/perber/wiki/internal/wiki/revisions" wikisearch "github.com/perber/wiki/internal/wiki/search" wikitags "github.com/perber/wiki/internal/wiki/tags" + wikibackup "github.com/perber/wiki/internal/wiki/backup" ) type Wiki struct { @@ -62,6 +63,7 @@ type Wiki struct { tags *tags.TagsService props *properties.PropertiesService log *slog.Logger + backupRoutes *wikibackup.Routes } const SYSTEM_USER_ID = "system" @@ -418,7 +420,7 @@ func (w *Wiki) buildImporterRoutes(options *WikiOptions) *wikiimporter.Routes { // Registrars returns all domain route registrars in registration order. func (w *Wiki) Registrars() []httpinternal.RouteRegistrar { - return []httpinternal.RouteRegistrar{ + registrars := []httpinternal.RouteRegistrar{ w.authRoutes, w.pagesRoutes, w.assetsRoutes, @@ -430,6 +432,20 @@ func (w *Wiki) Registrars() []httpinternal.RouteRegistrar { w.brandingRoutes, w.importerRoutes, } + if w.backupRoutes != nil { + registrars = append(registrars, w.backupRoutes) + } + return registrars +} + +// SetBackupRoutes sets the backup routes and must be called before router creation. +func (w *Wiki) SetBackupRoutes(r *wikibackup.Routes) { + w.backupRoutes = r +} + +// AuthService returns the authentication service. +func (w *Wiki) AuthService() *auth.AuthService { + return w.auth } // FrontendConfig returns the minimal runtime data required by the router to serve the SPA. From b6109895c2699eb5b9bd9caf019f239dd729455e Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 18:42:45 +0200 Subject: [PATCH 04/54] feat: add backup settings UI component --- .../src/features/backup/BackupSettings.tsx | 141 ++++++++++++++++++ ui/leafwiki-ui/src/features/router/router.tsx | 11 ++ ui/leafwiki-ui/src/lib/api/backup.ts | 27 ++++ ui/leafwiki-ui/src/stores/backup.ts | 50 +++++++ 4 files changed, 229 insertions(+) create mode 100644 ui/leafwiki-ui/src/features/backup/BackupSettings.tsx create mode 100644 ui/leafwiki-ui/src/lib/api/backup.ts create mode 100644 ui/leafwiki-ui/src/stores/backup.ts diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx new file mode 100644 index 000000000..fc8e91cfa --- /dev/null +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -0,0 +1,141 @@ +import { Button } from '@/components/ui/button' +import { Loader2 } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' +import { useBackupStore } from '@/stores/backup' +import { useSetTitle } from '../viewer/setTitle' + +const POLL_INTERVAL_MS = 5000 +const DEFAULT_BACKUP_INTERVAL_MINUTES = 60 + +function formatDate(value: string | null): string { + if (!value) return 'Never' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'Never' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) +} + +function getNextBackup(lastBackupAt: string | null): string { + if (!lastBackupAt) return '—' + const date = new Date(lastBackupAt) + if (Number.isNaN(date.getTime())) return '—' + const nextDate = new Date(date.getTime() + DEFAULT_BACKUP_INTERVAL_MINUTES * 60 * 1000) + return formatDate(nextDate.toISOString()) +} + +export default function BackupSettings() { + const { + enabled, + lastBackupAt, + lastError, + isLoading, + isPolling, + loadStatus, + triggerPush, + stopPolling, + } = useBackupStore() + + const pollingRef = useRef | null>(null) + const lastBackupAtRef = useRef(null) + + useSetTitle({ title: 'Backup Settings' }) + + useEffect(() => { + loadStatus() + }, [loadStatus]) + + // Set up polling after push + useEffect(() => { + if (isPolling) { + lastBackupAtRef.current = lastBackupAt + pollingRef.current = setInterval(async () => { + await loadStatus() + }, POLL_INTERVAL_MS) + } + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + } + }, [isPolling, loadStatus]) + + // Stop polling when lastBackupAt advances + useEffect(() => { + if (isPolling && lastBackupAtRef.current !== null && lastBackupAt !== null) { + if (lastBackupAtRef.current !== lastBackupAt) { + stopPolling() + toast.success('Backup completed successfully') + } + } + }, [lastBackupAt, isPolling, stopPolling]) + + const handlePush = async () => { + try { + await triggerPush() + toast.success('Backup triggered') + } catch (err) { + toast.error('Failed to trigger backup') + } + } + + return ( +
+

Backup Settings

+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && ( +
+

Git Backup

+ +
+ Status: + {enabled ? 'Enabled' : 'Disabled'} +
+ + {enabled && ( + <> +
+ Last backup: + {formatDate(lastBackupAt)} +
+ +
+ Next backup: + {getNextBackup(lastBackupAt)} +
+ +
+ Last error: + + {lastError || '—'} + +
+ +
+ +
+ + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/ui/leafwiki-ui/src/features/router/router.tsx b/ui/leafwiki-ui/src/features/router/router.tsx index 876f94f72..07cf5d0da 100644 --- a/ui/leafwiki-ui/src/features/router/router.tsx +++ b/ui/leafwiki-ui/src/features/router/router.tsx @@ -1,4 +1,5 @@ import { createBrowserRouter, Navigate, RouteObject } from 'react-router-dom' +import BackupSettings from '../backup/BackupSettings' import LoginForm from '../auth/LoginForm' import BrandingSettings from '../branding/BrandingSettings' import PageEditor from '../editor/PageEditor' @@ -56,6 +57,16 @@ export const createLeafWikiRouter = ( ), }, + { + path: '/settings/backup', + element: isReadOnlyViewer ? ( + + ) : ( + + + + ), + }, { path: '/settings/importer', element: isReadOnlyViewer ? ( diff --git a/ui/leafwiki-ui/src/lib/api/backup.ts b/ui/leafwiki-ui/src/lib/api/backup.ts new file mode 100644 index 000000000..66ae5d529 --- /dev/null +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -0,0 +1,27 @@ +import { API_BASE_URL } from '../config' +import { fetchWithAuth } from './auth' + +const BACKUP_STATUS_URL = '/api/admin/backup/status' +const BACKUP_PUSH_URL = '/api/admin/backup/push' + +export interface BackupStatusResponse { + enabled: boolean + status?: { + lastBackupAt: string | null + lastError: string + } +} + +export async function fetchBackupStatus(): Promise { + const res = await fetch(`${API_BASE_URL}${BACKUP_STATUS_URL}`, { + credentials: 'include', + }) + if (!res.ok) throw new Error('Failed to fetch backup status') + return res.json() +} + +export async function triggerBackupPush(): Promise { + await fetchWithAuth(BACKUP_PUSH_URL, { + method: 'POST', + }) +} \ No newline at end of file diff --git a/ui/leafwiki-ui/src/stores/backup.ts b/ui/leafwiki-ui/src/stores/backup.ts new file mode 100644 index 000000000..dc771b001 --- /dev/null +++ b/ui/leafwiki-ui/src/stores/backup.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand' +import { fetchBackupStatus, triggerBackupPush, BackupStatusResponse } from '@/lib/api/backup' + +interface BackupState { + enabled: boolean + lastBackupAt: string | null + lastError: string + isLoading: boolean + isPolling: boolean + loadStatus: () => Promise + triggerPush: () => Promise + startPolling: () => void + stopPolling: () => void +} + +export const useBackupStore = create((set, get) => ({ + enabled: false, + lastBackupAt: null, + lastError: '', + isLoading: false, + isPolling: false, + + loadStatus: async () => { + set({ isLoading: true }) + try { + const data: BackupStatusResponse = await fetchBackupStatus() + set({ + enabled: data.enabled, + lastBackupAt: data.status?.lastBackupAt ?? null, + lastError: data.status?.lastError ?? '', + isLoading: false, + }) + } catch { + set({ isLoading: false }) + } + }, + + triggerPush: async () => { + await triggerBackupPush() + get().startPolling() + }, + + startPolling: () => { + set({ isPolling: true }) + }, + + stopPolling: () => { + set({ isPolling: false }) + }, +})) \ No newline at end of file From e642ab50333149e55e213e5bc6c69b8f65da9a72 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 18:43:03 +0200 Subject: [PATCH 05/54] feat: add docker backup documentation --- docs/docker-backup.md | 141 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/docker-backup.md diff --git a/docs/docker-backup.md b/docs/docker-backup.md new file mode 100644 index 000000000..847de305b --- /dev/null +++ b/docs/docker-backup.md @@ -0,0 +1,141 @@ +# Docker Git Backup + +LeafWiki supports automated Git backup of your wiki content to a remote Git repository. + +## Overview + +The backup feature pushes your `root/` (pages) and `assets/` (attachments) directories to a remote Git repository on a configurable schedule. + +## Configuration + +Enable git backup via environment variables: + +| Variable | Description | Required | +|---|---|---| +| `LEAFWIKI_GIT_BACKUP` | Set to `"true"` to enable backup | Yes | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL (e.g. `git@github.com:user/wiki-backup.git`) | Yes | +| `LEAFWIKI_GIT_BACKUP_BRANCH` | Remote branch to push to (default: `main`) | No | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name (default: `LeafWiki Backup`) | No | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email (default: `backup@leafwiki.local`) | No | +| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes (default: `60`) | No | + +## SSH Key Authentication + +The backup uses SSH to authenticate with your Git remote. Provide the private key via: + +### Option 1: Docker Secret (Recommended for production) + +```yaml +services: + leafwiki: + image: ghcr.io/perber/leafwiki:latest + environment: + LEAFWIKI_GIT_BACKUP: "true" + LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" + LEAFWIKI_GIT_BACKUP_BRANCH: "main" + LEAFWIKI_GIT_BACKUP_INTERVAL: "60" + secrets: + - backup_ssh_key + +secrets: + backup_ssh_key: + file: ./backup_key +``` + +The SSH key will be mounted at `/run/secrets/backup_ssh_key` inside the container. + +### Option 2: File Mount + +Mount your SSH key into the container: + +```yaml +services: + leafwiki: + image: ghcr.io/perber/leafwiki:latest + environment: + LEAFWIKI_GIT_BACKUP: "true" + LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" + LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH: "/secrets/backup_key" + volumes: + - ./backup_key:/secrets/backup_key:ro +``` + +## Docker Compose Example + +```yaml +services: + leafwiki: + image: ghcr.io/perber/leafwiki:latest + environment: + LEAFWIKI_JWT_SECRET: "${JWT_SECRET}" + LEAFWIKI_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" + LEAFWIKI_GIT_BACKUP: "true" + LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" + LEAFWIKI_GIT_BACKUP_BRANCH: "main" + LEAFWIKI_GIT_BACKUP_INTERVAL: "60" + secrets: + - backup_ssh_key + +secrets: + backup_ssh_key: + file: ./backup_key + +volumes: + - ./data:/app/data +``` + +## Repository Setup + +Before enabling backup, ensure your remote repository is initialized: + +```bash +# Create a bare repository on GitHub/GitLab +# The first time LeafWiki starts with git-backup enabled, +# it will create an initial commit with your existing content +``` + +## What Gets Backed Up + +Only the following directories are staged and committed: +- `data/root/` — all wiki pages (Markdown files) +- `data/assets/` — uploaded attachments + +The following are explicitly excluded (not backed up): +- `*.db` — SQLite database (search index, metadata) +- `*.db-shm`, `*.db-wal` — SQLite WAL files +- `*.tmp`, `.tmp-*` — temporary files + +## Admin UI + +Once enabled, a "Backup" section appears in the admin settings panel at `/settings/backup`. You can: +- View the current backup status +- See the last backup time and any errors +- Trigger an immediate backup with "Push now" + +## Troubleshooting + +### SSH Key Permissions + +Ensure your SSH private key has correct permissions: +```bash +chmod 600 backup_key +``` + +### First Backup Fails + +If the first backup fails, check: +1. SSH key is correctly mounted/readable +2. The remote repository exists and you have push access +3. SSH known_hosts is configured for the Git host + +### Verify Backup is Working + +Check the logs: +```bash +docker compose logs -f leafwiki | grep -i backup +``` + +Or use the admin API: +```bash +curl -u admin:PASSWORD http://localhost:8080/api/admin/backup/status +``` \ No newline at end of file From 121cbaacb32eee3f94d4f8dbbbac6f7d46044ae8 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 19:23:29 +0200 Subject: [PATCH 06/54] fix: add logging to backup module --- internal/backup/repo.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 7a08ade38..63ed0349e 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -2,6 +2,7 @@ package backup import ( "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -232,6 +233,7 @@ func (r *Repository) RunBackup() error { } r.status.SetSuccess(time.Now()) + slog.Default().Info("backup completed", "lastBackupAt", time.Now()) return nil } @@ -265,9 +267,10 @@ func (r *Repository) push(commitHash string) error { Auth: auth, }) if err != nil { + slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL) return fmt.Errorf("failed to push: %w", err) } - + slog.Default().Info("git push successful", "remote", r.cfg.RemoteURL) return nil } @@ -291,6 +294,7 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { // Parse the private key using x/crypto/ssh signer, err := sshcrypto.ParsePrivateKey(privateKey) if err != nil { + slog.Default().Error("failed to parse SSH key", "error", err, "path", r.cfg.SSHKeyPath) return nil, fmt.Errorf("failed to parse SSH key: %w", err) } From 143eab28077a96dd1fc310ef748c3c37f13dc385 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 19:25:03 +0200 Subject: [PATCH 07/54] fix: use insecure host key callback for SSH git backup --- internal/backup/repo.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 63ed0349e..1ad6251b9 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -298,11 +298,13 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { return nil, fmt.Errorf("failed to parse SSH key: %w", err) } - // Use go-git ssh package's PublicKeys with the signer - return &ssh.PublicKeys{ + // Use InsecureIgnoreHostKey since we don't have a known_hosts file in the container + auth := &ssh.PublicKeys{ User: "git", Signer: signer, - }, nil + } + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + return auth, nil } // Status returns a snapshot of the last backup time and any error. From 53009da2a0352f9ff261e5a71b9e360d4aad65e7 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 19:55:52 +0200 Subject: [PATCH 08/54] fix: prevent time.NewTicker panic on zero interval in backup scheduler --- internal/backup/scheduler.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go index 59e50b923..9b81f523c 100644 --- a/internal/backup/scheduler.go +++ b/internal/backup/scheduler.go @@ -2,8 +2,13 @@ package backup import ( "time" + + "golang.org/x/exp/slog" ) +// Minimum interval to prevent time.NewTicker(0) panic +const minInterval = 1 * time.Minute + // Scheduler runs periodic git backups. type Scheduler struct { repo *Repository @@ -14,6 +19,10 @@ type Scheduler struct { // NewScheduler creates and starts the background goroutine. func NewScheduler(repo *Repository, interval time.Duration) *Scheduler { + if interval < minInterval { + slog.Default().Warn("backup scheduler interval too small, using minimum", "requested", interval, "using", minInterval) + interval = minInterval + } s := &Scheduler{ repo: repo, ticker: time.NewTicker(interval), From 8944c7aae60fc6aaa2d208627e1ee1e880604006 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:21:08 +0200 Subject: [PATCH 09/54] fix: update LastBackupAt only when content was actually backed up --- go.mod | 1 + internal/backup/repo.go | 8 +++----- internal/backup/scheduler_test.go | 10 ++++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 458319b1c..e0eb155fe 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.51.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 ) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 1ad6251b9..e91bcdeaf 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -204,9 +204,7 @@ func (r *Repository) RunBackup() error { } if status.IsClean() { - // Even when there's nothing to commit, record successful backup check - r.status.SetSuccess(time.Now()) - return nil // Nothing to commit + return nil // Nothing to commit - don't update LastBackupAt } // Commit changes @@ -233,7 +231,7 @@ func (r *Repository) RunBackup() error { } r.status.SetSuccess(time.Now()) - slog.Default().Info("backup completed", "lastBackupAt", time.Now()) + slog.Default().Info("backup pushed to remote") return nil } @@ -270,7 +268,7 @@ func (r *Repository) push(commitHash string) error { slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL) return fmt.Errorf("failed to push: %w", err) } - slog.Default().Info("git push successful", "remote", r.cfg.RemoteURL) + slog.Default().Info("git push succeeded") return nil } diff --git a/internal/backup/scheduler_test.go b/internal/backup/scheduler_test.go index e892c35e4..9d3b2e782 100644 --- a/internal/backup/scheduler_test.go +++ b/internal/backup/scheduler_test.go @@ -38,6 +38,11 @@ func TestScheduler_TriggerNow(t *testing.T) { scheduler := NewScheduler(repo, 10*time.Minute) defer scheduler.Stop() + // Add a file to back up so there's something to commit + if err := os.WriteFile(filepath.Join(rootDir, "test.txt"), []byte("content"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + // Give it a moment to process the initial run time.Sleep(100 * time.Millisecond) @@ -120,6 +125,11 @@ func TestScheduler_RunsOnStart(t *testing.T) { scheduler := NewScheduler(repo, 10*time.Hour) defer scheduler.Stop() + // Add a file to back up so there's something to commit + if err := os.WriteFile(filepath.Join(rootDir, "test.txt"), []byte("content"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + // Wait a moment for the initial run time.Sleep(100 * time.Millisecond) From 07756646fdb67a5708d97cb1216ca7818053f8e5 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:21:33 +0200 Subject: [PATCH 10/54] feat: add git backup variables --- .env.example | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.env.example b/.env.example index bf2fb3071..6d1925650 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,30 @@ LEAFWIKI_MAX_REVISION_HISTORY= # Enable link refactoring dialog and rewrite flow (true/false) LEAFWIKI_ENABLE_LINK_REFACTOR= +# ─── Git Backup ──────────────────────────────────────────────── +# Enable automated git backup to a remote repository (true/false) +LEAFWIKI_GIT_BACKUP=false + +# SSH remote URL for the backup repository (e.g. git@github.com:user/wiki-backup.git) +# Required when LEAFWIKI_GIT_BACKUP=true +LEAFWIKI_GIT_BACKUP_REMOTE= + +# Branch to push to (default: main) +LEAFWIKI_GIT_BACKUP_BRANCH=main + +# Git commit author name +LEAFWIKI_GIT_BACKUP_AUTHOR_NAME=LeafWiki Backup + +# Git commit author email +LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL=backup@leafwiki.local + +# Backup interval in minutes (default: 60) +LEAFWIKI_GIT_BACKUP_INTERVAL=60 + +# Path to SSH private key file (alternative to LEAFWIKI_GIT_BACKUP_SSH_KEY) +LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH= + +# Raw SSH private key (PEM). Mount your key file and set this, OR use LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH +# Example: LEAFWIKI_GIT_BACKUP_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\n..." +LEAFWIKI_GIT_BACKUP_SSH_KEY= From 49898be48434d09930b509fe597a19f7b0730641 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:21:56 +0200 Subject: [PATCH 11/54] feat: add backup settings to user dropdown --- ui/leafwiki-ui/src/components/UserToolbar.tsx | 6 +++ .../src/features/backup/BackupSettings.tsx | 38 ++++++++++--------- .../src/features/backup/useToolbarActions.ts | 13 +++++++ 3 files changed, 39 insertions(+), 18 deletions(-) create mode 100644 ui/leafwiki-ui/src/features/backup/useToolbarActions.ts diff --git a/ui/leafwiki-ui/src/components/UserToolbar.tsx b/ui/leafwiki-ui/src/components/UserToolbar.tsx index d5674a5a8..37e577d93 100644 --- a/ui/leafwiki-ui/src/components/UserToolbar.tsx +++ b/ui/leafwiki-ui/src/components/UserToolbar.tsx @@ -91,6 +91,12 @@ export default function UserToolbar() { > Import + navigate('/settings/backup')} + > + Backup Settings + diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index fc8e91cfa..dc3e7f286 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -1,12 +1,13 @@ import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' import { Loader2 } from 'lucide-react' import { useEffect, useRef } from 'react' import { toast } from 'sonner' import { useBackupStore } from '@/stores/backup' import { useSetTitle } from '../viewer/setTitle' +import { useToolbarActions } from './useToolbarActions' const POLL_INTERVAL_MS = 5000 -const DEFAULT_BACKUP_INTERVAL_MINUTES = 60 function formatDate(value: string | null): string { if (!value) return 'Never' @@ -18,14 +19,6 @@ function formatDate(value: string | null): string { }).format(date) } -function getNextBackup(lastBackupAt: string | null): string { - if (!lastBackupAt) return '—' - const date = new Date(lastBackupAt) - if (Number.isNaN(date.getTime())) return '—' - const nextDate = new Date(date.getTime() + DEFAULT_BACKUP_INTERVAL_MINUTES * 60 * 1000) - return formatDate(nextDate.toISOString()) -} - export default function BackupSettings() { const { enabled, @@ -41,6 +34,8 @@ export default function BackupSettings() { const pollingRef = useRef | null>(null) const lastBackupAtRef = useRef(null) + // reset toolbar actions on mount + useToolbarActions() useSetTitle({ title: 'Backup Settings' }) useEffect(() => { @@ -96,26 +91,33 @@ export default function BackupSettings() { {!isLoading && (

Git Backup

+

+ Automatically pushes changes to the configured remote repository. +

- Status: + {enabled ? 'Enabled' : 'Disabled'}
+ {!enabled && ( +

+ To enable Git backup, set the environment variable{' '} + LEAFWIKI_GIT_BACKUP=true{' '} + and configure{' '} + LEAFWIKI_GIT_BACKUP_REMOTE. +

+ )} + {enabled && ( <>
- Last backup: + {formatDate(lastBackupAt)}
- Next backup: - {getNextBackup(lastBackupAt)} -
- -
- Last error: + {lastError || '—'} @@ -138,4 +140,4 @@ export default function BackupSettings() { )}
) -} \ No newline at end of file +} diff --git a/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts b/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts new file mode 100644 index 000000000..3752f4e2d --- /dev/null +++ b/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts @@ -0,0 +1,13 @@ +// Hook to provide toolbar actions for the page viewer + +import { useEffect } from 'react' +import { useToolbarStore } from '../toolbar/toolbarStore' + +// Hook to set up toolbar actions based on app mode and read-only status +export function useToolbarActions() { + const setButtons = useToolbarStore((state) => state.setButtons) + + useEffect(() => { + setButtons([]) + }, [setButtons]) +} \ No newline at end of file From a0a3e79dd5f399270849661c145a64f413eeb227 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:25:40 +0200 Subject: [PATCH 12/54] fix: align BackupSettings layout with other settings pages --- .../src/features/backup/BackupSettings.tsx | 92 ++++++++++--------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index dc3e7f286..6f4f8f1f8 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -79,50 +79,56 @@ export default function BackupSettings() { } return ( -
-

Backup Settings

- - {isLoading && ( -
- -
- )} - - {!isLoading && ( -
-

Git Backup

-

- Automatically pushes changes to the configured remote repository. -

- -
- - {enabled ? 'Enabled' : 'Disabled'} + <> +
+

Backup Settings

+ + {isLoading && ( +
+
+ )} - {!enabled && ( -

- To enable Git backup, set the environment variable{' '} - LEAFWIKI_GIT_BACKUP=true{' '} - and configure{' '} - LEAFWIKI_GIT_BACKUP_REMOTE. -

- )} - - {enabled && ( - <> -
- - {formatDate(lastBackupAt)} -
+ {!isLoading && ( + <> +
+

Git Backup

+

+ Automatically pushes changes to the configured remote repository. +

- - - {lastError || '—'} - + + {enabled ? 'Enabled' : 'Disabled'}
+ {!enabled && ( +

+ To enable Git backup, set the environment variable{' '} + LEAFWIKI_GIT_BACKUP=true{' '} + and configure{' '} + LEAFWIKI_GIT_BACKUP_REMOTE. +

+ )} + + {enabled && ( + <> +
+ + {formatDate(lastBackupAt)} +
+ +
+ + + {lastError || '—'} + +
+ + )} +
+ + {enabled && (
- - )} -
- )} -
+ )} + + )} +
+ ) } From dc913a4cdd44a18bfe56c0981bb74ceb55bc03b1 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:46:31 +0200 Subject: [PATCH 13/54] feat: make it prettier --- ui/leafwiki-ui/src/features/backup/BackupSettings.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 6f4f8f1f8..fd35ace21 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' -import { Loader2 } from 'lucide-react' +import { CloudUpload, Loader2 } from 'lucide-react' import { useEffect, useRef } from 'react' import { toast } from 'sonner' import { useBackupStore } from '@/stores/backup' @@ -136,7 +136,9 @@ export default function BackupSettings() { > {isPolling ? ( - ) : null} + ) : ( + + )} Push now
From 2f5b42404c5558884f48bc76500a38d5e4d8397b Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:47:21 +0200 Subject: [PATCH 14/54] fix: handle empty commit error gracefully on first start --- internal/backup/repo.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index e91bcdeaf..308a334cc 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -217,6 +217,10 @@ func (r *Repository) RunBackup() error { }, }) if err != nil { + // If it's "nothing to commit" (empty tree), that's fine - just skip + if strings.Contains(err.Error(), "cannot create empty commit") { + return nil + } errMsg := fmt.Errorf("failed to commit: %w", err).Error() r.status.SetError(errMsg) return nil // Never propagate From 4b7bcd95f9ab7c5f99730506e30d4293eac41511 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:53:49 +0200 Subject: [PATCH 15/54] fix: serialize LastBackupAt as null instead of zero time in JSON --- internal/backup/status.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/backup/status.go b/internal/backup/status.go index 3fc582b3e..af579dcfb 100644 --- a/internal/backup/status.go +++ b/internal/backup/status.go @@ -7,14 +7,14 @@ import ( type Status struct { mu sync.RWMutex - LastBackupAt time.Time + LastBackupAt *time.Time LastError string } func (s *Status) SetSuccess(t time.Time) { s.mu.Lock() defer s.mu.Unlock() - s.LastBackupAt = t + s.LastBackupAt = &t s.LastError = "" } @@ -34,6 +34,6 @@ func (s *Status) Snapshot() StatusSnapshot { } type StatusSnapshot struct { - LastBackupAt time.Time `json:"lastBackupAt"` - LastError string `json:"lastError"` + LastBackupAt *time.Time `json:"lastBackupAt,omitempty"` + LastError string `json:"lastError"` } \ No newline at end of file From cd808136faeb2c123f478784d9be1a5853c36c00 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 21:58:17 +0200 Subject: [PATCH 16/54] chore: update backup settings hint with all required env vars --- ui/leafwiki-ui/src/features/backup/BackupSettings.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index fd35ace21..ed4da2d44 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -104,10 +104,11 @@ export default function BackupSettings() { {!enabled && (

- To enable Git backup, set the environment variable{' '} - LEAFWIKI_GIT_BACKUP=true{' '} - and configure{' '} - LEAFWIKI_GIT_BACKUP_REMOTE. + To enable Git backup, set the following environment variables:{' '} + LEAFWIKI_GIT_BACKUP=true,{' '} + LEAFWIKI_GIT_BACKUP_REMOTE,{' '} + and provide your SSH key via{' '} + LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH.

)} From 6a3bd19c69b7975c2f00903fd385299d55acb4a2 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:12:16 +0200 Subject: [PATCH 17/54] chore: only show Last error field when there is an actual error --- .../src/features/backup/BackupSettings.tsx | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index ed4da2d44..3f1e67509 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -102,16 +102,6 @@ export default function BackupSettings() { {enabled ? 'Enabled' : 'Disabled'}
- {!enabled && ( -

- To enable Git backup, set the following environment variables:{' '} - LEAFWIKI_GIT_BACKUP=true,{' '} - LEAFWIKI_GIT_BACKUP_REMOTE,{' '} - and provide your SSH key via{' '} - LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH. -

- )} - {enabled && ( <>
@@ -119,12 +109,12 @@ export default function BackupSettings() { {formatDate(lastBackupAt)}
-
- - - {lastError || '—'} - -
+ {lastError && ( +
+ + {lastError} +
+ )} )} From 5801377874c62ef63d37e07c36663ae0c1e8b9c6 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:23:40 +0200 Subject: [PATCH 18/54] chore: remove separate docker-backup.md doc --- docs/docker-backup.md | 141 ------------------ .../src/features/backup/BackupSettings.tsx | 86 +++++++---- 2 files changed, 60 insertions(+), 167 deletions(-) delete mode 100644 docs/docker-backup.md diff --git a/docs/docker-backup.md b/docs/docker-backup.md deleted file mode 100644 index 847de305b..000000000 --- a/docs/docker-backup.md +++ /dev/null @@ -1,141 +0,0 @@ -# Docker Git Backup - -LeafWiki supports automated Git backup of your wiki content to a remote Git repository. - -## Overview - -The backup feature pushes your `root/` (pages) and `assets/` (attachments) directories to a remote Git repository on a configurable schedule. - -## Configuration - -Enable git backup via environment variables: - -| Variable | Description | Required | -|---|---|---| -| `LEAFWIKI_GIT_BACKUP` | Set to `"true"` to enable backup | Yes | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL (e.g. `git@github.com:user/wiki-backup.git`) | Yes | -| `LEAFWIKI_GIT_BACKUP_BRANCH` | Remote branch to push to (default: `main`) | No | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name (default: `LeafWiki Backup`) | No | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email (default: `backup@leafwiki.local`) | No | -| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes (default: `60`) | No | - -## SSH Key Authentication - -The backup uses SSH to authenticate with your Git remote. Provide the private key via: - -### Option 1: Docker Secret (Recommended for production) - -```yaml -services: - leafwiki: - image: ghcr.io/perber/leafwiki:latest - environment: - LEAFWIKI_GIT_BACKUP: "true" - LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" - LEAFWIKI_GIT_BACKUP_BRANCH: "main" - LEAFWIKI_GIT_BACKUP_INTERVAL: "60" - secrets: - - backup_ssh_key - -secrets: - backup_ssh_key: - file: ./backup_key -``` - -The SSH key will be mounted at `/run/secrets/backup_ssh_key` inside the container. - -### Option 2: File Mount - -Mount your SSH key into the container: - -```yaml -services: - leafwiki: - image: ghcr.io/perber/leafwiki:latest - environment: - LEAFWIKI_GIT_BACKUP: "true" - LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" - LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH: "/secrets/backup_key" - volumes: - - ./backup_key:/secrets/backup_key:ro -``` - -## Docker Compose Example - -```yaml -services: - leafwiki: - image: ghcr.io/perber/leafwiki:latest - environment: - LEAFWIKI_JWT_SECRET: "${JWT_SECRET}" - LEAFWIKI_ADMIN_PASSWORD: "${ADMIN_PASSWORD}" - LEAFWIKI_GIT_BACKUP: "true" - LEAFWIKI_GIT_BACKUP_REMOTE: "git@github.com:user/wiki-backup.git" - LEAFWIKI_GIT_BACKUP_BRANCH: "main" - LEAFWIKI_GIT_BACKUP_INTERVAL: "60" - secrets: - - backup_ssh_key - -secrets: - backup_ssh_key: - file: ./backup_key - -volumes: - - ./data:/app/data -``` - -## Repository Setup - -Before enabling backup, ensure your remote repository is initialized: - -```bash -# Create a bare repository on GitHub/GitLab -# The first time LeafWiki starts with git-backup enabled, -# it will create an initial commit with your existing content -``` - -## What Gets Backed Up - -Only the following directories are staged and committed: -- `data/root/` — all wiki pages (Markdown files) -- `data/assets/` — uploaded attachments - -The following are explicitly excluded (not backed up): -- `*.db` — SQLite database (search index, metadata) -- `*.db-shm`, `*.db-wal` — SQLite WAL files -- `*.tmp`, `.tmp-*` — temporary files - -## Admin UI - -Once enabled, a "Backup" section appears in the admin settings panel at `/settings/backup`. You can: -- View the current backup status -- See the last backup time and any errors -- Trigger an immediate backup with "Push now" - -## Troubleshooting - -### SSH Key Permissions - -Ensure your SSH private key has correct permissions: -```bash -chmod 600 backup_key -``` - -### First Backup Fails - -If the first backup fails, check: -1. SSH key is correctly mounted/readable -2. The remote repository exists and you have push access -3. SSH known_hosts is configured for the Git host - -### Verify Backup is Working - -Check the logs: -```bash -docker compose logs -f leafwiki | grep -i backup -``` - -Or use the admin API: -```bash -curl -u admin:PASSWORD http://localhost:8080/api/admin/backup/status -``` \ No newline at end of file diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 3f1e67509..25c52e5dc 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -1,6 +1,5 @@ import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { CloudUpload, Loader2 } from 'lucide-react' +import { CloudUpload, Loader2, TriangleAlert } from 'lucide-react' import { useEffect, useRef } from 'react' import { toast } from 'sonner' import { useBackupStore } from '@/stores/backup' @@ -50,7 +49,6 @@ export default function BackupSettings() { await loadStatus() }, POLL_INTERVAL_MS) } - return () => { if (pollingRef.current) { clearInterval(pollingRef.current) @@ -85,7 +83,10 @@ export default function BackupSettings() { {isLoading && (
- +
+ + Loading backup status… +
)} @@ -94,44 +95,77 @@ export default function BackupSettings() {

Git Backup

- Automatically pushes changes to the configured remote repository. + Automatically pushes wiki changes to the configured remote Git + repository. Configure the target repository and credentials in + your server settings.

-
- - {enabled ? 'Enabled' : 'Disabled'} +
+ Status + {enabled ? ( + + Enabled + + ) : ( + + Disabled + + )}
{enabled && ( <> -
- - {formatDate(lastBackupAt)} +
+ Last backup + + {isPolling ? ( + + + Waiting for backup to complete… + + ) : ( + formatDate(lastBackupAt) + )} +
{lastError && ( -
- - {lastError} +
+ + + Last error + + {lastError}
)} )} + + {!enabled && ( +

+ Git backup is not enabled. To enable it, configure a remote + repository in your server environment settings. +

+ )}
{enabled && ( -
- +
+

Manual Backup

+

+ Trigger an immediate push of all current wiki content to the + remote repository without waiting for the next scheduled sync. +

+
+ +
)} From edbd97c532c3dabcd55c6cc48ff6a5907f7bbbf7 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:24:29 +0200 Subject: [PATCH 19/54] chore: document git backup feature in README --- readme.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/readme.md b/readme.md index 116b3fbeb..3c8bde441 100644 --- a/readme.md +++ b/readme.md @@ -122,6 +122,7 @@ It is intended as a pragmatic migration helper, not a fully automatic migration - Admin, editor, and viewer roles - Branding options such as logo, favicon, and site name - Dark mode and mobile-friendly UI +- Git backup to a remote repository via SSH (scheduled automatic pushes) Revision history and link refactoring are currently available behind feature flags: `--enable-revision` and `--enable-link-refactor`. @@ -325,6 +326,14 @@ If you are just getting started, the most important options are usually: | `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | | `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | | `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `--git-backup` | Enable automated git backup to a remote repository | `false` | - | +| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | +| `--git-backup-branch` | Git branch to push to | `main` | - | +| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | - | +| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | - | +| `--git-backup-interval` | Backup interval in minutes | `60` | - | +| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | - | +| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | - | > When using the official Docker image, `LEAFWIKI_HOST` defaults to `0.0.0.0` if neither a `--host` flag nor `LEAFWIKI_HOST` is provided, as the container entrypoint sets this automatically. @@ -354,6 +363,14 @@ This is especially useful in containerized or production environments. | `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | | `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | | `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | - | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | +| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | - | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | - | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | - | +| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | - | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | - | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | - | These environment variables override the default values and are especially useful in containerized or production environments. From 816c86d2caead166bcfb3c6c6c60eb20791f38b1 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:25:10 +0200 Subject: [PATCH 20/54] chore: formatting --- readme.md | 136 +++++++++++++++++++++++++++--------------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/readme.md b/readme.md index 3c8bde441..9d30fccac 100644 --- a/readme.md +++ b/readme.md @@ -306,34 +306,34 @@ If you are just getting started, the most important options are usually: ### CLI Flags -| Flag | Description | Default | Available since | -|---------------------------------|------------------------------------------------------------------------|---------------|-------------------| -| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | -| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | -| `--port` | Port the server listens on | `8080` | – | -| `--data-dir` | Directory where data is stored | `./data` | – | -| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | -| `--public-access` | Allow public read-only access | `false` | – | -| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | -| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS)| `""` | v0.6.0 | -| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | -| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | -| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `--git-backup` | Enable automated git backup to a remote repository | `false` | - | -| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | -| `--git-backup-branch` | Git branch to push to | `main` | - | -| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | - | -| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | - | -| `--git-backup-interval` | Backup interval in minutes | `60` | - | -| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | - | -| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | - | +| Flag | Description | Default | Available since | +| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | +| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | +| `--port` | Port the server listens on | `8080` | – | +| `--data-dir` | Directory where data is stored | `./data` | – | +| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | +| `--public-access` | Allow public read-only access | `false` | – | +| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | +| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | +| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | +| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `--git-backup` | Enable automated git backup to a remote repository | `false` | - | +| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | +| `--git-backup-branch` | Git branch to push to | `main` | - | +| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | - | +| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | - | +| `--git-backup-interval` | Backup interval in minutes | `60` | - | +| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | - | +| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | - | > When using the official Docker image, `LEAFWIKI_HOST` defaults to `0.0.0.0` if neither a `--host` flag nor `LEAFWIKI_HOST` is provided, as the container entrypoint sets this automatically. @@ -343,34 +343,34 @@ If you are just getting started, the most important options are usually: The same configuration options can also be provided via environment variables. This is especially useful in containerized or production environments. -| Variable | Description | Default | Available since | -|----------------------------------------|-------------------------------------------------------------------------|------------|-----------------| -| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1`| - | -| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | -| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | -| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | -| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | -| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | -| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | -| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | -| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | -| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | -| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | - | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | -| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | - | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | - | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | - | -| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | - | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | - | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | - | +| Variable | Description | Default | Available since | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1` | - | +| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | +| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | +| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | +| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | +| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | +| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | +| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | +| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | +| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | - | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | +| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | - | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | - | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | - | +| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | - | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | - | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | - | These environment variables override the default values and are especially useful in containerized or production environments. @@ -459,18 +459,18 @@ The backend binds to `127.0.0.1` by default. Use `--host=0.0.0.0` only if you in ### Keyboard Shortcuts -| Action | Shortcut | -|----------------------------|--------------------------------------------| -| Switch to Edit Mode | `Ctrl + E` (or `Cmd + E`) | -| Save Page | `Ctrl + S` (or `Cmd + S`) | -| Switch to Search Pane | `Ctrl + Shift + F` (or `Cmd + Shift + F`) | -| Switch to Navigation Pane | `Ctrl + Shift + E` (or `Cmd + Shift + E`) | -| Go to Page | `Ctrl + Alt + P` (or `Cmd + Option + P`) | -| Bold Text | `Ctrl + B` (or `Cmd + B`) | -| Italic Text | `Ctrl + I` (or `Cmd + I`) | -| Headline 1 | `Ctrl + Alt + 1` (or `Cmd + Alt + 1`) | -| Headline 2 | `Ctrl + Alt + 2` (or `Cmd + Alt + 2`) | -| Headline 3 | `Ctrl + Alt + 3` (or `Cmd + Alt + 3`) | +| Action | Shortcut | +| ------------------------- | ----------------------------------------- | +| Switch to Edit Mode | `Ctrl + E` (or `Cmd + E`) | +| Save Page | `Ctrl + S` (or `Cmd + S`) | +| Switch to Search Pane | `Ctrl + Shift + F` (or `Cmd + Shift + F`) | +| Switch to Navigation Pane | `Ctrl + Shift + E` (or `Cmd + Shift + E`) | +| Go to Page | `Ctrl + Alt + P` (or `Cmd + Option + P`) | +| Bold Text | `Ctrl + B` (or `Cmd + B`) | +| Italic Text | `Ctrl + I` (or `Cmd + I`) | +| Headline 1 | `Ctrl + Alt + 1` (or `Cmd + Alt + 1`) | +| Headline 2 | `Ctrl + Alt + 2` (or `Cmd + Alt + 2`) | +| Headline 3 | `Ctrl + Alt + 3` (or `Cmd + Alt + 3`) | `Ctrl+V` / `Cmd+V` for pasting images or files is also supported in the editor. `Esc` can be used to exit modals, dialogs or the edit mode. From 102394303c6bfac06c5f7a07d246d3cc3d8430db Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:30:29 +0200 Subject: [PATCH 21/54] fix: push to configured branch instead of remote's default branch --- internal/backup/repo.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 308a334cc..d06bcf800 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -264,9 +264,11 @@ func (r *Repository) push(commitHash string) error { } } - // Push - go-git handles refspec automatically + // Push HEAD to the configured remote branch + refSpec := config.RefSpec("HEAD:refs/heads/" + r.cfg.Branch) err = remote.Push(&gogit.PushOptions{ - Auth: auth, + Auth: auth, + RefSpecs: []config.RefSpec{refSpec}, }) if err != nil { slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL) From d7b57a9f42f98a6514fe142431b1d9970e0f6430 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:37:22 +0200 Subject: [PATCH 22/54] fix: treat already up-to-date as successful backup (not an error) --- internal/backup/repo.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index d06bcf800..a6a35c529 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -271,6 +271,11 @@ func (r *Repository) push(commitHash string) error { RefSpecs: []config.RefSpec{refSpec}, }) if err != nil { + // "already up-to-date" means the remote already has this commit - not an error + if err.Error() == "already up-to-date" { + slog.Default().Info("backup skipped - already up-to-date") + return nil + } slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL) return fmt.Errorf("failed to push: %w", err) } From a010cfa28149160a713087606bd3afca58386414 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 22:59:50 +0200 Subject: [PATCH 23/54] fix: use strings.Contains for already up-to-date error check --- internal/backup/repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index a6a35c529..1ff140ac1 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -272,7 +272,7 @@ func (r *Repository) push(commitHash string) error { }) if err != nil { // "already up-to-date" means the remote already has this commit - not an error - if err.Error() == "already up-to-date" { + if strings.Contains(err.Error(), "already up-to-date") { slog.Default().Info("backup skipped - already up-to-date") return nil } From bf0a9d01f8df1532c3a61c244acce76ff7a1f042 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 23:05:25 +0200 Subject: [PATCH 24/54] fix: use case-insensitive check for already up-to-date error --- internal/backup/repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 1ff140ac1..0da482d14 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -272,7 +272,7 @@ func (r *Repository) push(commitHash string) error { }) if err != nil { // "already up-to-date" means the remote already has this commit - not an error - if strings.Contains(err.Error(), "already up-to-date") { + if strings.Contains(strings.ToLower(err.Error()), "already up-to-date") { slog.Default().Info("backup skipped - already up-to-date") return nil } From d0d75b31f0f74f7a956dced9b0130bf10f7cfeed Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 23:29:24 +0200 Subject: [PATCH 25/54] feat: update manual push logic --- internal/backup/repo.go | 8 ++++++++ .../src/features/backup/BackupSettings.tsx | 16 +++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 0da482d14..5d7c07e03 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -165,6 +165,7 @@ func (r *Repository) RunBackup() error { if err != nil { errMsg := fmt.Errorf("failed to get worktree: %w", err).Error() r.status.SetError(errMsg) + r.status.SetSuccess(time.Now()) return nil // Never propagate } @@ -173,11 +174,13 @@ func (r *Repository) RunBackup() error { rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) if err != nil { r.status.SetError(fmt.Errorf("failed to compute relative path for root: %w", err).Error()) + r.status.SetSuccess(time.Now()) return nil } assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) if err != nil { r.status.SetError(fmt.Errorf("failed to compute relative path for assets: %w", err).Error()) + r.status.SetSuccess(time.Now()) return nil } @@ -185,12 +188,14 @@ func (r *Repository) RunBackup() error { if _, err := os.Stat(r.cfg.RootDir); err == nil { if _, err := wt.Add(rootRel); err != nil { r.status.SetError(fmt.Errorf("failed to stage root dir: %w", err).Error()) + r.status.SetSuccess(time.Now()) return nil } } if _, err := os.Stat(r.cfg.AssetsDir); err == nil { if _, err := wt.Add(assetsRel); err != nil { r.status.SetError(fmt.Errorf("failed to stage assets dir: %w", err).Error()) + r.status.SetSuccess(time.Now()) return nil } } @@ -200,6 +205,7 @@ func (r *Repository) RunBackup() error { if err != nil { errMsg := fmt.Errorf("failed to get status: %w", err).Error() r.status.SetError(errMsg) + r.status.SetSuccess(time.Now()) return nil // Never propagate } @@ -223,6 +229,7 @@ func (r *Repository) RunBackup() error { } errMsg := fmt.Errorf("failed to commit: %w", err).Error() r.status.SetError(errMsg) + r.status.SetSuccess(time.Now()) return nil // Never propagate } @@ -230,6 +237,7 @@ func (r *Repository) RunBackup() error { if r.cfg.RemoteURL != "" { if err := r.push(commit.String()); err != nil { r.status.SetError(err.Error()) + r.status.SetSuccess(time.Now()) return nil // Never propagate } } diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 25c52e5dc..4fbfdc3ae 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -57,15 +57,21 @@ export default function BackupSettings() { } }, [isPolling, loadStatus]) - // Stop polling when lastBackupAt advances + // Stop polling when lastBackupAt advances or an error occurs useEffect(() => { - if (isPolling && lastBackupAtRef.current !== null && lastBackupAt !== null) { - if (lastBackupAtRef.current !== lastBackupAt) { + if (isPolling) { + const hasNewBackup = lastBackupAtRef.current !== null && lastBackupAt !== null && lastBackupAtRef.current !== lastBackupAt + const hasError = lastError !== '' + if (hasNewBackup || hasError) { stopPolling() - toast.success('Backup completed successfully') + if (hasError) { + toast.error(`Backup failed: ${lastError}`) + } else { + toast.success('Backup completed successfully') + } } } - }, [lastBackupAt, isPolling, stopPolling]) + }, [lastBackupAt, lastError, isPolling, stopPolling]) const handlePush = async () => { try { From 08d0a7a4e649db03f2ef21114f5ca05f5b5406dd Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Thu, 21 May 2026 23:29:33 +0200 Subject: [PATCH 26/54] feat: update manual push logic --- internal/backup/repo.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 5d7c07e03..3d61ded78 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -210,7 +210,8 @@ func (r *Repository) RunBackup() error { } if status.IsClean() { - return nil // Nothing to commit - don't update LastBackupAt + r.status.SetSuccess(time.Now()) + return nil // Nothing to commit } // Commit changes From 526fb4db0194473eb1faaa2c95068cd20e9c9eea Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 14:20:22 +0200 Subject: [PATCH 27/54] feat: add debug logging --- internal/backup/repo.go | 215 +++++++++++++++++++++++++++++++++------- 1 file changed, 178 insertions(+), 37 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 3d61ded78..c31630571 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -10,6 +10,7 @@ import ( gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/ssh" sshcrypto "golang.org/x/crypto/ssh" @@ -26,6 +27,8 @@ type Repository struct { // On first init, stages root/ and assets/ and makes an initial commit. func Init(cfg Config) (*Repository, error) { repoDir := filepath.Dir(cfg.RootDir) + slog.Default().Debug("backup init started", "repoDir", repoDir, "rootDir", cfg.RootDir, "assetsDir", cfg.AssetsDir, "remote", cfg.RemoteURL, "branch", cfg.Branch) + if cfg.RootDir == "" { return nil, fmt.Errorf("RootDir is required") } @@ -46,15 +49,19 @@ func Init(cfg Config) (*Repository, error) { // Try to open existing repo repo, err := gogit.PlainOpen(repoDir) if err == nil { + slog.Default().Debug("opened existing git repo", "repoDir", repoDir) r.repo = repo return r, nil } + slog.Default().Debug("no existing repo found, initialising new one", "repoDir", repoDir, "openErr", err) + // Initialize new repo repo, err = gogit.PlainInit(repoDir, false) if err != nil { return nil, fmt.Errorf("failed to init repo: %w", err) } + slog.Default().Debug("new git repo initialised", "repoDir", repoDir) r.repo = repo // Create initial commit with root/ and assets/ if they exist @@ -67,6 +74,8 @@ func Init(cfg Config) (*Repository, error) { // makeInitialCommit creates the first commit with root/ and assets/ directories. func (r *Repository) makeInitialCommit() error { + slog.Default().Debug("makeInitialCommit: starting") + wt, err := r.repo.Worktree() if err != nil { return err @@ -82,32 +91,46 @@ func (r *Repository) makeInitialCommit() error { if err != nil { return fmt.Errorf("failed to compute relative path for assets: %w", err) } + slog.Default().Debug("makeInitialCommit: resolved relative paths", "rootRel", rootRel, "assetsRel", assetsRel) // Stage root/ and assets/ directories using relative paths // Track if we actually staged any content (files within directories) stagedFiles := false if _, err := os.Stat(r.cfg.RootDir); err == nil { + slog.Default().Debug("makeInitialCommit: staging root dir", "path", rootRel) if _, err := wt.Add(rootRel); err != nil { return fmt.Errorf("failed to stage root dir: %w", err) } // Check if root has any files if hasFiles(r.cfg.RootDir) { stagedFiles = true + slog.Default().Debug("makeInitialCommit: root dir has files, will commit") + } else { + slog.Default().Debug("makeInitialCommit: root dir is empty, skipping") } + } else { + slog.Default().Debug("makeInitialCommit: root dir does not exist, skipping", "path", r.cfg.RootDir, "err", err) } if _, err := os.Stat(r.cfg.AssetsDir); err == nil { + slog.Default().Debug("makeInitialCommit: staging assets dir", "path", assetsRel) if _, err := wt.Add(assetsRel); err != nil { return fmt.Errorf("failed to stage assets dir: %w", err) } // Check if assets has any files if hasFiles(r.cfg.AssetsDir) { stagedFiles = true + slog.Default().Debug("makeInitialCommit: assets dir has files, will commit") + } else { + slog.Default().Debug("makeInitialCommit: assets dir is empty, skipping") } + } else { + slog.Default().Debug("makeInitialCommit: assets dir does not exist, skipping", "path", r.cfg.AssetsDir, "err", err) } // If no files were found in root/assets, skip initial commit // The first RunBackup will create the commit when there's actual content if !stagedFiles { + slog.Default().Debug("makeInitialCommit: no files found in root or assets, skipping initial commit") return nil } @@ -117,8 +140,10 @@ func (r *Repository) makeInitialCommit() error { return err } if status.IsClean() { + slog.Default().Debug("makeInitialCommit: working tree is clean after staging, nothing to commit") return nil // Nothing to commit } + slog.Default().Debug("makeInitialCommit: staged file count", "count", len(status)) commit, err := wt.Commit("Initial commit", &gogit.CommitOptions{ Author: &object.Signature{ @@ -130,19 +155,24 @@ func (r *Repository) makeInitialCommit() error { if err != nil { return fmt.Errorf("failed to commit: %w", err) } + slog.Default().Debug("makeInitialCommit: initial commit created", "hash", commit.String()) // Push to remote if configured if r.cfg.RemoteURL != "" { + slog.Default().Debug("makeInitialCommit: pushing initial commit to remote", "remote", r.cfg.RemoteURL) if err := r.push(commit.String()); err != nil { r.status.SetError(err.Error()) - // Don't return error - initial commit succeeded + slog.Default().Error("initial commit push failed", "error", err, "remote", r.cfg.RemoteURL) + // Don't return error - initial commit succeeded but push failed } + } else { + slog.Default().Debug("makeInitialCommit: no remote configured, skipping push") } return nil } -// hasFiles returns true if the directory contains any files (non-recursive). +// hasFiles returns true if the directory contains any files (recursive). func hasFiles(dir string) bool { entries, err := os.ReadDir(dir) if err != nil { @@ -152,6 +182,24 @@ func hasFiles(dir string) bool { if !entry.IsDir() { return true } + // Check subdirectory contents recursively + if hasFiles(filepath.Join(dir, entry.Name())) { + return true + } + } + return false +} + +// hasStagedChanges returns true if the status map contains any entry where the +// staging area (index) has an actual change: added, modified, deleted, or renamed. +// Untracked files (Staging == '?') are intentionally ignored — they represent +// content outside the directories we back up and should not trigger a commit. +func hasStagedChanges(status gogit.Status) bool { + for _, fileStatus := range status { + switch fileStatus.Staging { + case gogit.Added, gogit.Modified, gogit.Deleted, gogit.Renamed, gogit.Copied: + return true + } } return false } @@ -161,61 +209,96 @@ func hasFiles(dir string) bool { // message format: "backup: " // Returns nil and skips commit+push if the working tree is clean. func (r *Repository) RunBackup() error { + slog.Default().Debug("RunBackup: starting backup cycle") + wt, err := r.repo.Worktree() if err != nil { errMsg := fmt.Errorf("failed to get worktree: %w", err).Error() + slog.Default().Debug("RunBackup: failed to get worktree", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) + r.status.SetSuccess(time.Now()) return nil // Never propagate } - // Compute relative paths from repo root repoDir := filepath.Dir(r.cfg.RootDir) + slog.Default().Debug("RunBackup: resolved repo dir", "repoDir", repoDir) + + // Compute relative paths for the two content directories we back up. + // Only root/ (wiki pages) and assets/ (uploaded files) are included. + // Internal app directories (.leafwiki/, schema.json, search.db-journal, etc.) + // are intentionally excluded — they are application state, not user content. rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) if err != nil { r.status.SetError(fmt.Errorf("failed to compute relative path for root: %w", err).Error()) - r.status.SetSuccess(time.Now()) + r.status.SetSuccess(time.Now()) return nil } assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) if err != nil { r.status.SetError(fmt.Errorf("failed to compute relative path for assets: %w", err).Error()) - r.status.SetSuccess(time.Now()) + r.status.SetSuccess(time.Now()) return nil } + slog.Default().Debug("RunBackup: staging content directories", "rootRel", rootRel, "assetsRel", assetsRel) - // Stage root/ and assets/ directories using relative paths if _, err := os.Stat(r.cfg.RootDir); err == nil { if _, err := wt.Add(rootRel); err != nil { - r.status.SetError(fmt.Errorf("failed to stage root dir: %w", err).Error()) - r.status.SetSuccess(time.Now()) + errMsg := fmt.Errorf("failed to stage root dir: %w", err).Error() + slog.Default().Debug("RunBackup: failed to stage root dir", "error", errMsg) + r.status.SetError(errMsg) + r.status.SetSuccess(time.Now()) return nil } + slog.Default().Debug("RunBackup: staged root dir", "path", rootRel) + } else { + slog.Default().Debug("RunBackup: root dir not found, skipping", "path", r.cfg.RootDir) } if _, err := os.Stat(r.cfg.AssetsDir); err == nil { if _, err := wt.Add(assetsRel); err != nil { - r.status.SetError(fmt.Errorf("failed to stage assets dir: %w", err).Error()) - r.status.SetSuccess(time.Now()) + errMsg := fmt.Errorf("failed to stage assets dir: %w", err).Error() + slog.Default().Debug("RunBackup: failed to stage assets dir", "error", errMsg) + r.status.SetError(errMsg) + r.status.SetSuccess(time.Now()) return nil } + slog.Default().Debug("RunBackup: staged assets dir", "path", assetsRel) + } else { + slog.Default().Debug("RunBackup: assets dir not found, skipping", "path", r.cfg.AssetsDir) } // Check working tree status status, err := wt.Status() if err != nil { errMsg := fmt.Errorf("failed to get status: %w", err).Error() + slog.Default().Debug("RunBackup: failed to get working tree status", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) + r.status.SetSuccess(time.Now()) return nil // Never propagate } - if status.IsClean() { + // hasStagedChanges checks only the staging area (index), ignoring untracked files. + // status.IsClean() returns false for ANY entry — including untracked files outside + // root/ and assets/ — which would cause empty commits every cycle. We only care + // whether the content we explicitly staged above has changed. + staged := hasStagedChanges(status) + slog.Default().Debug("RunBackup: working tree status checked", "hasStagedChanges", staged, "totalStatusEntries", len(status)) + + if !staged { + slog.Default().Info("backup skipped - no staged changes in content directories") r.status.SetSuccess(time.Now()) - return nil // Nothing to commit + return nil + } + + // Log only the staged files (skip untracked noise from other app directories) + for path, fileStatus := range status { + if fileStatus.Staging != gogit.Untracked { + slog.Default().Debug("RunBackup: staged file", "path", path, "staging", string(fileStatus.Staging), "worktree", string(fileStatus.Worktree)) + } } // Commit changes commitMsg := fmt.Sprintf("backup: %s", time.Now().Format(time.RFC3339)) + slog.Default().Debug("RunBackup: committing changes", "message", commitMsg, "author", r.cfg.AuthorName, "email", r.cfg.AuthorEmail) commit, err := wt.Commit(commitMsg, &gogit.CommitOptions{ Author: &object.Signature{ Name: r.cfg.AuthorName, @@ -226,21 +309,27 @@ func (r *Repository) RunBackup() error { if err != nil { // If it's "nothing to commit" (empty tree), that's fine - just skip if strings.Contains(err.Error(), "cannot create empty commit") { + slog.Default().Debug("RunBackup: commit skipped - empty tree") return nil } errMsg := fmt.Errorf("failed to commit: %w", err).Error() + slog.Default().Debug("RunBackup: commit failed", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) + r.status.SetSuccess(time.Now()) return nil // Never propagate } + slog.Default().Debug("RunBackup: commit created", "hash", commit.String(), "message", commitMsg) // Push to remote if r.cfg.RemoteURL != "" { + slog.Default().Debug("RunBackup: pushing to remote", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commit.String()) if err := r.push(commit.String()); err != nil { + slog.Default().Debug("RunBackup: push failed", "error", err) r.status.SetError(err.Error()) - r.status.SetSuccess(time.Now()) return nil // Never propagate } + } else { + slog.Default().Debug("RunBackup: no remote configured, skipping push") } r.status.SetSuccess(time.Now()) @@ -250,15 +339,21 @@ func (r *Repository) RunBackup() error { // push pushes the given commit hash to the configured remote. func (r *Repository) push(commitHash string) error { + slog.Default().Debug("push: starting", "commit", commitHash, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch) + // Build SSH auth + slog.Default().Debug("push: building SSH auth", "sshKeyPath", r.cfg.SSHKeyPath, "hasInlineKey", r.cfg.SSHKey != "") auth, err := r.buildSSHAuth() if err != nil { + slog.Default().Debug("push: SSH auth build failed", "error", err) return fmt.Errorf("failed to build SSH auth: %w", err) } + slog.Default().Debug("push: SSH auth built successfully") // Get remote - use r.repo directly since we're using the repo instance remote, err := r.repo.Remote("origin") if err != nil { + slog.Default().Debug("push: remote 'origin' not found, creating it", "url", r.cfg.RemoteURL) // Remote doesn't exist, create it _, err = r.repo.CreateRemote(&config.RemoteConfig{ Name: "origin", @@ -271,24 +366,78 @@ func (r *Repository) push(commitHash string) error { if err != nil { return fmt.Errorf("failed to get remote: %w", err) } + slog.Default().Debug("push: remote 'origin' created", "url", r.cfg.RemoteURL) + } else { + remoteURLs := remote.Config().URLs + slog.Default().Debug("push: remote 'origin' found", "urls", remoteURLs) + } + + // Resolve local HEAD to verify what we are about to push. + localHead, err := r.repo.Head() + if err != nil { + return fmt.Errorf("failed to resolve local HEAD: %w", err) + } + slog.Default().Debug("push: local HEAD resolved", "hash", localHead.Hash().String(), "branch", localHead.Name().Short()) + + // Fetch the current remote ref so we can accurately detect true up-to-date. + // go-git returns ErrAlreadyUpToDate for empty/fresh remotes (no refs yet), + // which is a false positive — the remote simply has no branch to compare against. + // Listing first gives us ground truth before we decide whether to push. + remoteRefs, fetchErr := remote.List(&gogit.ListOptions{Auth: auth}) + if fetchErr != nil { + slog.Default().Debug("push: could not list remote refs (remote may be empty)", "error", fetchErr) + } + + remoteHead := "" + targetRef := "refs/heads/" + r.cfg.Branch + for _, ref := range remoteRefs { + if ref.Name().String() == targetRef { + remoteHead = ref.Hash().String() + break + } } + slog.Default().Debug("push: remote branch state", "branch", r.cfg.Branch, "remoteHead", remoteHead, "localHead", commitHash) - // Push HEAD to the configured remote branch - refSpec := config.RefSpec("HEAD:refs/heads/" + r.cfg.Branch) + if remoteHead == commitHash { + // Remote genuinely already has this exact commit — nothing to do. + slog.Default().Info("backup skipped - remote already at current commit", "branch", r.cfg.Branch, "commit", commitHash) + return nil + } + + // Delete the local remote-tracking ref before pushing. + // go-git compares local HEAD against refs/remotes/origin/ (the cached + // tracking ref written by previous pushes). If the remote was reset or recreated + // since the last push, the tracking ref still points to a commit the live remote + // no longer has — causing go-git to short-circuit with ErrAlreadyUpToDate before + // even attempting to send the pack. Removing it forces a clean push. + trackingRef := plumbing.NewRemoteReferenceName("origin", r.cfg.Branch) + if rmErr := r.repo.Storer.RemoveReference(trackingRef); rmErr != nil && rmErr != plumbing.ErrReferenceNotFound { + slog.Default().Debug("push: could not remove stale remote tracking ref", "ref", trackingRef.String(), "error", rmErr) + } else { + slog.Default().Debug("push: cleared remote tracking ref", "ref", trackingRef.String()) + } + + // Use the resolved branch ref explicitly rather than HEAD. + // Symbolic HEAD in a force refspec can confuse go-git when the local branch + // name differs from the configured remote branch (e.g. local=master, remote=main). + localBranchRef := localHead.Name().String() // e.g. refs/heads/master + refSpec := config.RefSpec("+" + localBranchRef + ":refs/heads/" + r.cfg.Branch) + slog.Default().Debug("push: pushing with force refspec", "refSpec", string(refSpec), "localBranch", localBranchRef, "remoteBranch", r.cfg.Branch) err = remote.Push(&gogit.PushOptions{ Auth: auth, RefSpecs: []config.RefSpec{refSpec}, + Force: true, }) if err != nil { - // "already up-to-date" means the remote already has this commit - not an error if strings.Contains(strings.ToLower(err.Error()), "already up-to-date") { - slog.Default().Info("backup skipped - already up-to-date") + // Genuine up-to-date: remote caught up between our List call and Push. + slog.Default().Info("backup skipped - already up-to-date on " + r.cfg.Branch + " at remote URL: " + r.cfg.RemoteURL) return nil } - slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL) + slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "refSpec", string(refSpec)) return fmt.Errorf("failed to push: %w", err) } - slog.Default().Info("git push succeeded") + slog.Default().Info("git push succeeded", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commitHash) return nil } @@ -299,13 +448,18 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { // Try SSHKey string first if r.cfg.SSHKey != "" { + slog.Default().Debug("buildSSHAuth: using inline SSH key") privateKey = []byte(r.cfg.SSHKey) } else if r.cfg.SSHKeyPath != "" { + slog.Default().Debug("buildSSHAuth: reading SSH key from file", "path", r.cfg.SSHKeyPath) privateKey, err = os.ReadFile(r.cfg.SSHKeyPath) if err != nil { + slog.Default().Debug("buildSSHAuth: failed to read SSH key file", "path", r.cfg.SSHKeyPath, "error", err) return nil, fmt.Errorf("failed to read SSH key: %w", err) } + slog.Default().Debug("buildSSHAuth: SSH key file read successfully", "path", r.cfg.SSHKeyPath, "size", len(privateKey)) } else { + slog.Default().Debug("buildSSHAuth: no SSH key configured (neither inline nor path)") return nil, fmt.Errorf("no SSH key provided") } @@ -315,6 +469,7 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { slog.Default().Error("failed to parse SSH key", "error", err, "path", r.cfg.SSHKeyPath) return nil, fmt.Errorf("failed to parse SSH key: %w", err) } + slog.Default().Debug("buildSSHAuth: SSH key parsed successfully", "keyType", signer.PublicKey().Type()) // Use InsecureIgnoreHostKey since we don't have a known_hosts file in the container auth := &ssh.PublicKeys{ @@ -322,6 +477,7 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { Signer: signer, } auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + slog.Default().Debug("buildSSHAuth: SSH auth configured with InsecureIgnoreHostKey") return auth, nil } @@ -329,18 +485,3 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { func (r *Repository) Status() StatusSnapshot { return r.status.Snapshot() } - -// getRepoDir returns the parent directory of RootDir (i.e., the git repo root). -func (r *Repository) getRepoDir() string { - return filepath.Dir(r.cfg.RootDir) -} - -// stripPrefix strips the repoDir prefix from a full path to get a relative path. -func stripPrefix(fullPath, repoDir string) string { - rel, _ := filepath.Rel(repoDir, fullPath) - // If rel starts with "..", something is wrong - use just the base name - if strings.HasPrefix(rel, "..") { - return filepath.Base(fullPath) - } - return rel -} \ No newline at end of file From 4b11f056c7deef2677e11dd1c8fcc46588f7cd66 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 14:32:01 +0200 Subject: [PATCH 28/54] fix: error message wipe --- internal/backup/repo.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index c31630571..0df8c9519 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -216,7 +216,6 @@ func (r *Repository) RunBackup() error { errMsg := fmt.Errorf("failed to get worktree: %w", err).Error() slog.Default().Debug("RunBackup: failed to get worktree", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) return nil // Never propagate } @@ -246,7 +245,6 @@ func (r *Repository) RunBackup() error { errMsg := fmt.Errorf("failed to stage root dir: %w", err).Error() slog.Default().Debug("RunBackup: failed to stage root dir", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) return nil } slog.Default().Debug("RunBackup: staged root dir", "path", rootRel) @@ -258,7 +256,6 @@ func (r *Repository) RunBackup() error { errMsg := fmt.Errorf("failed to stage assets dir: %w", err).Error() slog.Default().Debug("RunBackup: failed to stage assets dir", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) return nil } slog.Default().Debug("RunBackup: staged assets dir", "path", assetsRel) @@ -272,7 +269,6 @@ func (r *Repository) RunBackup() error { errMsg := fmt.Errorf("failed to get status: %w", err).Error() slog.Default().Debug("RunBackup: failed to get working tree status", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) return nil // Never propagate } @@ -315,7 +311,6 @@ func (r *Repository) RunBackup() error { errMsg := fmt.Errorf("failed to commit: %w", err).Error() slog.Default().Debug("RunBackup: commit failed", "error", errMsg) r.status.SetError(errMsg) - r.status.SetSuccess(time.Now()) return nil // Never propagate } slog.Default().Debug("RunBackup: commit created", "hash", commit.String(), "message", commitMsg) From dc2903650156e73c14ed1c7e6c6ad1b6e0629c1d Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:40:10 +0200 Subject: [PATCH 29/54] docs: enhance README with backup feature --- readme.md | 228 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 158 insertions(+), 70 deletions(-) diff --git a/readme.md b/readme.md index 9d30fccac..295568508 100644 --- a/readme.md +++ b/readme.md @@ -3,6 +3,8 @@ **LeafWiki helps engineers, self-hosters, and small teams keep long-lived documentation structured, portable, and easy to operate.** Self-hosted. Single Go binary. SQLite-based. Markdown stored on disk. +[![Download](https://img.shields.io/github/v/release/perber/leafwiki?label=release&logo=github)](https://github.com/perber/leafwiki/releases) + If you want something lighter than a large wiki suite, but more structured than scattered notes, LeafWiki is built for that middle ground. LeafWiki is a real wiki application built around Markdown, not a plain Markdown file browser. It provides structured navigation, editing, search, roles, and managed content workflows inside the app. @@ -24,15 +26,7 @@ The goal is not to become an all-in-one workspace. The goal is to give you a wik - Multi-platform builds for Linux, macOS, Windows, and ARM64 - Reverse-proxy friendly with `--base-path` - Public read-only mode available -- Optional revision history and link refactoring behind feature flags - -## Why it fits this workflow - -- Explicit tree navigation instead of flat note feeds -- Markdown content that is easy to back up, move, and version -- Public read-only docs with authenticated editing -- Optimistic locking for concurrent edits -- Optional revision history and safe link refactoring +- Git backup to a remote repository (SSH) with automatic scheduled pushes - Small operational footprint without external database setup ## Good fit @@ -250,7 +244,7 @@ Make sure the mounted data directory (`~/leafwiki-data`) is writable by the user ### Quick start with a binary -Download the latest release binary from GitHub, make it executable, and start the server: +Download the latest release binary from [GitHub Releases](https://github.com/perber/leafwiki/releases), make it executable, and start the server: ```bash chmod +x leafwiki @@ -306,34 +300,38 @@ If you are just getting started, the most important options are usually: ### CLI Flags -| Flag | Description | Default | Available since | -| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | -| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | -| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | -| `--port` | Port the server listens on | `8080` | – | -| `--data-dir` | Directory where data is stored | `./data` | – | -| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | -| `--public-access` | Allow public read-only access | `false` | – | -| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | -| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | -| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | -| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | -| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `--git-backup` | Enable automated git backup to a remote repository | `false` | - | -| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | -| `--git-backup-branch` | Git branch to push to | `main` | - | -| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | - | -| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | - | -| `--git-backup-interval` | Backup interval in minutes | `60` | - | -| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | - | -| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | - | +| Flag | Description | Default | Available since | +| -------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | +| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | +| `--port` | Port the server listens on | `8080` | – | +| `--data-dir` | Directory where data is stored | `./data` | – | +| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | +| `--public-access` | Allow public read-only access | `false` | – | +| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | +| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | +| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | +| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `--enable-http-remote-user` | Enable reverse-proxy authentication via HTTP header | `false` | v0.10.0 | +| `--http-remote-user-header-name` | HTTP header carrying the username from a trusted proxy | `Remote-User` | v0.10.0 | +| `--trusted-proxy-ips` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | +| `--http-remote-user-logout-url` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | +| `--git-backup` | Enable automated git backup to a remote repository | `false` | v0.10.0 | +| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | v0.10.0 | +| `--git-backup-branch` | Git branch to push to | `main` | v0.10.0 | +| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | v0.10.0 | +| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | +| `--git-backup-interval` | Backup interval in minutes | `60` | v0.10.0 | +| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | v0.10.0 | +| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | v0.10.0 | > When using the official Docker image, `LEAFWIKI_HOST` defaults to `0.0.0.0` if neither a `--host` flag nor `LEAFWIKI_HOST` is provided, as the container entrypoint sets this automatically. @@ -343,40 +341,43 @@ If you are just getting started, the most important options are usually: The same configuration options can also be provided via environment variables. This is especially useful in containerized or production environments. -| Variable | Description | Default | Available since | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | -| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1` | - | -| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | -| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | -| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | -| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | -| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | -| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | -| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | -| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | -| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | -| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | - | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | - | -| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | - | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | - | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | - | -| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | - | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | - | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | - | +| Variable | Description | Default | Available since | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1` | - | +| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | +| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | +| `LEAFWIKI_LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | `info` | - | +| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | +| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | +| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | +| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | +| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | +| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | +| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `LEAFWIKI_ENABLE_HTTP_REMOTE_USER` | Enable reverse-proxy authentication via HTTP header | `false` | v0.10.0 | +| `LEAFWIKI_HTTP_REMOTE_USER_HEADER_NAME` | HTTP header carrying the username from a trusted proxy | `Remote-User` | v0.10.0 | +| `LEAFWIKI_TRUSTED_PROXY_IPS` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | +| `LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | v0.10.0 | These environment variables override the default values and are especially useful in containerized or production environments. -> When using the official Docker image, `LEAFWIKI_HOST` defaults to `0.0.0.0` if neither a `--host` flag nor `LEAFWIKI_HOST` is provided, as the container entrypoint sets this automatically. - ### Custom Stylesheet The custom stylesheet feature is available since `v0.8.5`. @@ -400,6 +401,84 @@ With the example above: - With `--base-path=/wiki`, it is served as `/wiki/custom.css` - The stylesheet endpoint is publicly accessible +### Git Backup + +LeafWiki can automatically back up your wiki content to a git repository — either a local one for version history, or a remote one over SSH for off-site redundancy. + +**What is backed up:** + +- `root/` — your wiki pages (Markdown files) +- `assets/` — uploaded images and files + +**What is explicitly excluded:** + +- `.leafwiki/` — internal application state +- `schema.json` — database schema metadata +- `*.db`, `*.db-journal`, `*.db-shm`, `*.db-wal` — SQLite database files (runtime state, not content) +- `*.tmp`, `.tmp-*` — temporary files + +A `.gitignore` is automatically written to the repository root to prevent these files from being committed. + +**How it works:** + +When `--git-backup=true` is set, LeafWiki initialises a git repository in the data directory (alongside `root/` and `assets/`). On first start it stages existing content and creates an initial commit. The backup then runs: + +1. **Immediately on startup** — the scheduler runs a full backup cycle as soon as the server starts. +2. **On a schedule** — by default every 60 minutes (configurable via `--git-backup-interval`). +3. **On demand** — via the admin API (see below). + +Each backup cycle stages all changes in `root/` and `assets/`, commits them with a timestamped message (`backup: `), and pushes to the remote if one is configured. + +**Working without a remote:** + +If only `--git-backup=true` is set without a remote URL, backups are committed to local git history only. This gives you local version tracking without pushing to any external service. To enable remote backups, also set `--git-backup-remote` to an SSH URL (e.g. `git@github.com:user/repo.git`). + +**SSH key authentication:** + +You can provide the SSH private key in two ways: + +- **File path:** `--git-backup-ssh-key-path=/path/to/id_ed25519` +- **Inline PEM:** `--git-backup-ssh-key` (prefer using the environment variable `LEAFWIKI_GIT_BACKUP_SSH_KEY` for this, as command-line arguments may leak in process listings) + +If neither is provided, the backup will fail when a push is attempted. + +**User Interface:** + +When git backup is enabled, administrators can access backup settings via **Settings → Backup** in the navigation toolbar. + +A **"Push now"** button allows admins to trigger an immediate backup without waiting for the next scheduled cycle. After clicking the button, the page polls for status updates and shows a loading spinner until the push completes. + +**Admin API endpoints:** + +When git backup is enabled, two admin-only REST endpoints become available: + +| Method | Endpoint | Description | +| ------ | -------------------------- | ----------------------------------------------------------- | +| GET | `/api/admin/backup/status` | Returns backup status: `{"enabled": true, "status": {...}}` | +| POST | `/api/admin/backup/push` | Triggers an immediate backup push (returns 202 Accepted) | + +The status response includes `lastBackupAt` (ISO 8601 timestamp) and `lastError` (string, empty on success). + +**Restoring from a backup:** + +To restore your wiki from a git backup: + +1. Stop the LeafWiki server. +2. Navigate to your data directory (e.g. `~/leafwiki-data`). +3. If the data directory itself is the git repository root (default setup), use standard git commands: + ```bash + git log --oneline # view backup history + git checkout # restore root/ and assets/ to a specific point + ``` +4. If the repository has a remote, you can also clone it to a different location: + ```bash + git clone restore-dir + cp -r restore-dir/root restore-dir/assets ~/leafwiki-data/ + ``` +5. Restart the LeafWiki server. + +Note that only `root/` and `assets/` are backed up. The SQLite database (user accounts, page metadata, etc.) is not included — restoring content files will recreate page metadata on next access. + ### Security Overview - Since v0.7.0 LeafWiki includes several built-in security mechanisms enabled by default: @@ -440,6 +519,10 @@ For most setups, prefer: ## Quick Start (Dev) +Run the frontend and backend in two separate terminal sessions: + +**Terminal 1 — Frontend (Vite dev server):** + ```bash git clone https://github.com/perber/leafwiki.git cd leafwiki @@ -447,12 +530,17 @@ cd leafwiki cd ui/leafwiki-ui npm install npm run dev +``` -cd ../../cmd/leafwiki +Vite starts on `http://localhost:5173`. + +**Terminal 2 — Backend (Go server):** + +```bash +cd leafwiki/cmd/leafwiki go run main.go --jwt-secret=yoursecret --allow-insecure=true --admin-password=yourpassword ``` -Vite starts on `http://localhost:5173`. The backend binds to `127.0.0.1` by default. Use `--host=0.0.0.0` only if you intentionally need network access. --- From 26679ef8fc00bf6c39d457b22d62ed013bd910ae Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:42:47 +0200 Subject: [PATCH 30/54] feat: add SSHKnownHosts and Duration() method --- internal/backup/config.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/backup/config.go b/internal/backup/config.go index c42904f17..a7e2cb09a 100644 --- a/internal/backup/config.go +++ b/internal/backup/config.go @@ -1,5 +1,7 @@ package backup +import "time" + type Config struct { Enabled bool RootDir string // path to LeafWiki root/ content directory @@ -10,5 +12,11 @@ type Config struct { Branch string // remote branch to push to, default "main" SSHKeyPath string // path to private key file (optional if SSHKey set) SSHKey string // raw PEM private key (env var preferred) + SSHKnownHosts string // known_hosts content for MITM protection (optional) IntervalMinutes int // how often to run the scheduled backup, default 60 +} + +// Duration returns the interval as a time.Duration. +func (c *Config) Duration() time.Duration { + return time.Duration(c.IntervalMinutes) * time.Minute } \ No newline at end of file From f30ca81264741cdc6ab2c13360b13d5c14e07db6 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:43:32 +0200 Subject: [PATCH 31/54] feat: use value-type LastBackupAt and omitempty on LastError --- internal/backup/status.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/backup/status.go b/internal/backup/status.go index af579dcfb..3b958188f 100644 --- a/internal/backup/status.go +++ b/internal/backup/status.go @@ -7,20 +7,21 @@ import ( type Status struct { mu sync.RWMutex - LastBackupAt *time.Time + LastBackupAt time.Time LastError string } func (s *Status) SetSuccess(t time.Time) { s.mu.Lock() defer s.mu.Unlock() - s.LastBackupAt = &t + s.LastBackupAt = t s.LastError = "" } func (s *Status) SetError(err string) { s.mu.Lock() defer s.mu.Unlock() + s.LastBackupAt = time.Time{} // Clear on error s.LastError = err } @@ -34,6 +35,6 @@ func (s *Status) Snapshot() StatusSnapshot { } type StatusSnapshot struct { - LastBackupAt *time.Time `json:"lastBackupAt,omitempty"` - LastError string `json:"lastError"` -} \ No newline at end of file + LastBackupAt time.Time `json:"lastBackupAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} From 55cfe09c6f4cc3adf3efe4fbf7d733c19bcba7c2 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:44:34 +0200 Subject: [PATCH 32/54] fix: add db-journal to gitignore and respect system umask --- internal/backup/gitignore.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/backup/gitignore.go b/internal/backup/gitignore.go index db5222af9..bc5bad73c 100644 --- a/internal/backup/gitignore.go +++ b/internal/backup/gitignore.go @@ -3,10 +3,12 @@ package backup import ( "os" "path/filepath" + "syscall" ) const gitignoreContent = `# LeafWiki runtime files – do not commit *.db +*.db-journal *.db-shm *.db-wal *.tmp @@ -22,5 +24,8 @@ func EnsureGitignore(repoDir string) error { } else if !os.IsNotExist(err) { return err } - return os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644) + // Respect system umask + oldmask := syscall.Umask(0) + defer syscall.Umask(oldmask) + return os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644&^os.FileMode(oldmask)) } \ No newline at end of file From 28858ac2c6191b9b1cba5082883ce083eac9381a Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:44:45 +0200 Subject: [PATCH 33/54] feat: add panic recovery, waitgroup, and race-free trigger --- internal/backup/scheduler.go | 28 +++++++-- internal/backup/scheduler_test.go | 100 ++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 37 deletions(-) diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go index 9b81f523c..2ab9831b7 100644 --- a/internal/backup/scheduler.go +++ b/internal/backup/scheduler.go @@ -1,9 +1,9 @@ package backup import ( + "log/slog" + "sync" "time" - - "golang.org/x/exp/slog" ) // Minimum interval to prevent time.NewTicker(0) panic @@ -15,12 +15,14 @@ type Scheduler struct { ticker *time.Ticker manual chan struct{} done chan struct{} + wg sync.WaitGroup } // NewScheduler creates and starts the background goroutine. -func NewScheduler(repo *Repository, interval time.Duration) *Scheduler { +func NewScheduler(repo *Repository, cfg *Config) *Scheduler { + interval := cfg.Duration() if interval < minInterval { - slog.Default().Warn("backup scheduler interval too small, using minimum", "requested", interval, "using", minInterval) + slog.Warn("backup scheduler interval too small, using minimum", "requested", interval, "using", minInterval) interval = minInterval } s := &Scheduler{ @@ -30,11 +32,21 @@ func NewScheduler(repo *Repository, interval time.Duration) *Scheduler { done: make(chan struct{}), } + s.wg.Add(1) go s.run() return s } func (s *Scheduler) run() { + defer s.wg.Done() + + // Recover from panics to avoid killing the scheduler + defer func() { + if r := recover(); r != nil { + slog.Error("backup scheduler recovered from panic", "panic", r) + } + }() + // Run immediately on start s.repo.RunBackup() @@ -52,10 +64,17 @@ func (s *Scheduler) run() { // TriggerNow signals the scheduler to run a backup immediately, // regardless of the interval. Non-blocking. +// If a backup is already in progress, the signal is queued (buffer size 2). func (s *Scheduler) TriggerNow() { select { case s.manual <- struct{}{}: default: + // Buffer full (backup in progress), try to add more + select { + case s.manual <- struct{}{}: + default: + // Buffer still full, at least 1 signal is pending - don't drop + } } } @@ -68,4 +87,5 @@ func (s *Scheduler) Stop() { default: close(s.done) } + s.wg.Wait() } \ No newline at end of file diff --git a/internal/backup/scheduler_test.go b/internal/backup/scheduler_test.go index 9d3b2e782..ba3c0902d 100644 --- a/internal/backup/scheduler_test.go +++ b/internal/backup/scheduler_test.go @@ -22,11 +22,12 @@ func TestScheduler_TriggerNow(t *testing.T) { } cfg := Config{ - RootDir: rootDir, - AssetsDir: assetsDir, - AuthorName: "Test Author", - AuthorEmail: "test@example.com", - Branch: "main", + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + IntervalMinutes: 10, } repo, err := Init(cfg) @@ -35,7 +36,7 @@ func TestScheduler_TriggerNow(t *testing.T) { } // Create scheduler with a long interval so it won't fire naturally - scheduler := NewScheduler(repo, 10*time.Minute) + scheduler := NewScheduler(repo, &cfg) defer scheduler.Stop() // Add a file to back up so there's something to commit @@ -43,18 +44,41 @@ func TestScheduler_TriggerNow(t *testing.T) { t.Fatalf("failed to write test file: %v", err) } - // Give it a moment to process the initial run - time.Sleep(100 * time.Millisecond) + // Wait for the initial run to complete using a channel + timeout := time.After(2 * time.Second) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if !repo.status.LastBackupAt.IsZero() { + goto afterInitialRun + } + case <-timeout: + t.Fatal("timeout waiting for initial run") + } + } + +afterInitialRun: // TriggerNow should not block scheduler.TriggerNow() - // Give it a moment to process - time.Sleep(100 * time.Millisecond) - - // Verify that status was updated (backup ran) - if repo.status.LastBackupAt.IsZero() { - t.Error("expected LastBackupAt to be set after TriggerNow") + // Wait for TriggerNow to be processed + timeout2 := time.After(2 * time.Second) + ticker2 := time.NewTicker(50 * time.Millisecond) + defer ticker2.Stop() + + initialBackup := repo.status.LastBackupAt + for { + select { + case <-ticker2.C: + if !repo.status.LastBackupAt.IsZero() && !repo.status.LastBackupAt.Equal(initialBackup) { + return // Success + } + case <-timeout2: + t.Fatal("timeout waiting for TriggerNow") + } } } @@ -73,11 +97,12 @@ func TestScheduler_Stop(t *testing.T) { } cfg := Config{ - RootDir: rootDir, - AssetsDir: assetsDir, - AuthorName: "Test Author", - AuthorEmail: "test@example.com", - Branch: "main", + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + IntervalMinutes: 10, } repo, err := Init(cfg) @@ -85,9 +110,9 @@ func TestScheduler_Stop(t *testing.T) { t.Fatalf("Init failed: %v", err) } - scheduler := NewScheduler(repo, 10*time.Minute) + scheduler := NewScheduler(repo, &cfg) - // Stop should not block + // Stop should block until goroutine finishes scheduler.Stop() // Verify we can call Stop multiple times safely @@ -109,11 +134,12 @@ func TestScheduler_RunsOnStart(t *testing.T) { } cfg := Config{ - RootDir: rootDir, - AssetsDir: assetsDir, - AuthorName: "Test Author", - AuthorEmail: "test@example.com", - Branch: "main", + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "test@example.com", + Branch: "main", + IntervalMinutes: 600, } repo, err := Init(cfg) @@ -122,7 +148,7 @@ func TestScheduler_RunsOnStart(t *testing.T) { } // Create scheduler with very long interval - scheduler := NewScheduler(repo, 10*time.Hour) + scheduler := NewScheduler(repo, &cfg) defer scheduler.Stop() // Add a file to back up so there's something to commit @@ -130,11 +156,19 @@ func TestScheduler_RunsOnStart(t *testing.T) { t.Fatalf("failed to write test file: %v", err) } - // Wait a moment for the initial run - time.Sleep(100 * time.Millisecond) - - // Verify that LastBackupAt was set (scheduler ran immediately on start) - if repo.status.LastBackupAt.IsZero() { - t.Error("expected scheduler to run immediately on start") + // Wait for the initial run to complete using a channel + timeout := time.After(2 * time.Second) + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if !repo.status.LastBackupAt.IsZero() { + return // Success + } + case <-timeout: + t.Fatal("timeout waiting for initial run") + } } } \ No newline at end of file From c460800d536f9d56e3973b0a464f318ee6f31088 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 16:43:02 +0200 Subject: [PATCH 34/54] fix: propagate errors, validate author fields, remove force-push --- internal/backup/repo.go | 230 +++++++++++++++++++++-------------- internal/backup/repo_test.go | 50 ++++++++ 2 files changed, 186 insertions(+), 94 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 0df8c9519..f336fa9d4 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -27,7 +27,7 @@ type Repository struct { // On first init, stages root/ and assets/ and makes an initial commit. func Init(cfg Config) (*Repository, error) { repoDir := filepath.Dir(cfg.RootDir) - slog.Default().Debug("backup init started", "repoDir", repoDir, "rootDir", cfg.RootDir, "assetsDir", cfg.AssetsDir, "remote", cfg.RemoteURL, "branch", cfg.Branch) + slog.Debug("backup init started", "repoDir", repoDir, "rootDir", cfg.RootDir, "assetsDir", cfg.AssetsDir, "remote", cfg.RemoteURL, "branch", cfg.Branch) if cfg.RootDir == "" { return nil, fmt.Errorf("RootDir is required") @@ -35,6 +35,12 @@ func Init(cfg Config) (*Repository, error) { if cfg.AssetsDir == "" { return nil, fmt.Errorf("AssetsDir is required") } + if cfg.AuthorName == "" { + return nil, fmt.Errorf("AuthorName is required") + } + if cfg.AuthorEmail == "" { + return nil, fmt.Errorf("AuthorEmail is required") + } // Ensure parent directory exists if err := os.MkdirAll(repoDir, 0755); err != nil { @@ -49,19 +55,19 @@ func Init(cfg Config) (*Repository, error) { // Try to open existing repo repo, err := gogit.PlainOpen(repoDir) if err == nil { - slog.Default().Debug("opened existing git repo", "repoDir", repoDir) + slog.Debug("opened existing git repo", "repoDir", repoDir) r.repo = repo return r, nil } - slog.Default().Debug("no existing repo found, initialising new one", "repoDir", repoDir, "openErr", err) + slog.Debug("no existing repo found, initialising new one", "repoDir", repoDir, "openErr", err) // Initialize new repo repo, err = gogit.PlainInit(repoDir, false) if err != nil { return nil, fmt.Errorf("failed to init repo: %w", err) } - slog.Default().Debug("new git repo initialised", "repoDir", repoDir) + slog.Debug("new git repo initialised", "repoDir", repoDir) r.repo = repo // Create initial commit with root/ and assets/ if they exist @@ -74,7 +80,7 @@ func Init(cfg Config) (*Repository, error) { // makeInitialCommit creates the first commit with root/ and assets/ directories. func (r *Repository) makeInitialCommit() error { - slog.Default().Debug("makeInitialCommit: starting") + slog.Debug("makeInitialCommit: starting") wt, err := r.repo.Worktree() if err != nil { @@ -91,40 +97,54 @@ func (r *Repository) makeInitialCommit() error { if err != nil { return fmt.Errorf("failed to compute relative path for assets: %w", err) } - slog.Default().Debug("makeInitialCommit: resolved relative paths", "rootRel", rootRel, "assetsRel", assetsRel) + slog.Debug("makeInitialCommit: resolved relative paths", "rootRel", rootRel, "assetsRel", assetsRel) // Stage root/ and assets/ directories using relative paths // Track if we actually staged any content (files within directories) stagedFiles := false + rootDirMissing := false + assetsDirMissing := false + if _, err := os.Stat(r.cfg.RootDir); err == nil { - slog.Default().Debug("makeInitialCommit: staging root dir", "path", rootRel) + slog.Debug("makeInitialCommit: staging root dir", "path", rootRel) if _, err := wt.Add(rootRel); err != nil { return fmt.Errorf("failed to stage root dir: %w", err) } // Check if root has any files - if hasFiles(r.cfg.RootDir) { + if hasFilesFlag, err := hasFiles(r.cfg.RootDir); err == nil && hasFilesFlag { stagedFiles = true - slog.Default().Debug("makeInitialCommit: root dir has files, will commit") + slog.Debug("makeInitialCommit: root dir has files, will commit") + } else if err != nil { + slog.Debug("makeInitialCommit: root dir read error, skipping", "path", r.cfg.RootDir, "err", err) } else { - slog.Default().Debug("makeInitialCommit: root dir is empty, skipping") + slog.Debug("makeInitialCommit: root dir is empty, skipping") } } else { - slog.Default().Debug("makeInitialCommit: root dir does not exist, skipping", "path", r.cfg.RootDir, "err", err) + rootDirMissing = true + slog.Debug("makeInitialCommit: root dir does not exist, skipping", "path", r.cfg.RootDir, "err", err) } if _, err := os.Stat(r.cfg.AssetsDir); err == nil { - slog.Default().Debug("makeInitialCommit: staging assets dir", "path", assetsRel) + slog.Debug("makeInitialCommit: staging assets dir", "path", assetsRel) if _, err := wt.Add(assetsRel); err != nil { return fmt.Errorf("failed to stage assets dir: %w", err) } // Check if assets has any files - if hasFiles(r.cfg.AssetsDir) { + if hasFilesFlag, err := hasFiles(r.cfg.AssetsDir); err == nil && hasFilesFlag { stagedFiles = true - slog.Default().Debug("makeInitialCommit: assets dir has files, will commit") + slog.Debug("makeInitialCommit: assets dir has files, will commit") + } else if err != nil { + slog.Debug("makeInitialCommit: assets dir read error, skipping", "path", r.cfg.AssetsDir, "err", err) } else { - slog.Default().Debug("makeInitialCommit: assets dir is empty, skipping") + slog.Debug("makeInitialCommit: assets dir is empty, skipping") } } else { - slog.Default().Debug("makeInitialCommit: assets dir does not exist, skipping", "path", r.cfg.AssetsDir, "err", err) + assetsDirMissing = true + slog.Debug("makeInitialCommit: assets dir does not exist, skipping", "path", r.cfg.AssetsDir, "err", err) + } + + // Warn if both directories are missing + if rootDirMissing && assetsDirMissing { + slog.Warn("makeInitialCommit: both root and assets directories are missing") } // If no files were found in root/assets, skip initial commit @@ -155,39 +175,38 @@ func (r *Repository) makeInitialCommit() error { if err != nil { return fmt.Errorf("failed to commit: %w", err) } - slog.Default().Debug("makeInitialCommit: initial commit created", "hash", commit.String()) + slog.Debug("makeInitialCommit: initial commit created", "hash", commit.String()) // Push to remote if configured if r.cfg.RemoteURL != "" { - slog.Default().Debug("makeInitialCommit: pushing initial commit to remote", "remote", r.cfg.RemoteURL) - if err := r.push(commit.String()); err != nil { - r.status.SetError(err.Error()) - slog.Default().Error("initial commit push failed", "error", err, "remote", r.cfg.RemoteURL) - // Don't return error - initial commit succeeded but push failed - } + slog.Debug("makeInitialCommit: scheduling initial commit push to remote (scheduler will push on next cycle)", "remote", r.cfg.RemoteURL) } else { - slog.Default().Debug("makeInitialCommit: no remote configured, skipping push") + slog.Debug("makeInitialCommit: no remote configured, skipping push") } return nil } // hasFiles returns true if the directory contains any files (recursive). -func hasFiles(dir string) bool { +// Returns an error if the directory cannot be read. +func hasFiles(dir string) (bool, error) { entries, err := os.ReadDir(dir) if err != nil { - return false + slog.Debug("hasFiles: failed to read directory", "dir", dir, "error", err) + return false, err } for _, entry := range entries { if !entry.IsDir() { - return true + return true, nil } // Check subdirectory contents recursively - if hasFiles(filepath.Join(dir, entry.Name())) { - return true + if hasFilesRecursive, err := hasFiles(filepath.Join(dir, entry.Name())); hasFilesRecursive { + return true, nil + } else if err != nil { + return false, err } } - return false + return false, nil } // hasStagedChanges returns true if the status map contains any entry where the @@ -197,7 +216,7 @@ func hasFiles(dir string) bool { func hasStagedChanges(status gogit.Status) bool { for _, fileStatus := range status { switch fileStatus.Staging { - case gogit.Added, gogit.Modified, gogit.Deleted, gogit.Renamed, gogit.Copied: + case gogit.Added, gogit.Modified, gogit.Deleted, gogit.Renamed: return true } } @@ -209,18 +228,18 @@ func hasStagedChanges(status gogit.Status) bool { // message format: "backup: " // Returns nil and skips commit+push if the working tree is clean. func (r *Repository) RunBackup() error { - slog.Default().Debug("RunBackup: starting backup cycle") + slog.Debug("RunBackup: starting backup cycle") wt, err := r.repo.Worktree() if err != nil { errMsg := fmt.Errorf("failed to get worktree: %w", err).Error() - slog.Default().Debug("RunBackup: failed to get worktree", "error", errMsg) + slog.Debug("RunBackup: failed to get worktree", "error", errMsg) r.status.SetError(errMsg) - return nil // Never propagate + return fmt.Errorf("failed to get worktree: %w", err) } repoDir := filepath.Dir(r.cfg.RootDir) - slog.Default().Debug("RunBackup: resolved repo dir", "repoDir", repoDir) + slog.Debug("RunBackup: resolved repo dir", "repoDir", repoDir) // Compute relative paths for the two content directories we back up. // Only root/ (wiki pages) and assets/ (uploaded files) are included. @@ -228,48 +247,58 @@ func (r *Repository) RunBackup() error { // are intentionally excluded — they are application state, not user content. rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) if err != nil { - r.status.SetError(fmt.Errorf("failed to compute relative path for root: %w", err).Error()) - r.status.SetSuccess(time.Now()) - return nil + errMsg := fmt.Errorf("failed to compute relative path for root: %w", err).Error() + r.status.SetError(errMsg) + return fmt.Errorf("failed to compute relative path for root: %w", err) } assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) if err != nil { - r.status.SetError(fmt.Errorf("failed to compute relative path for assets: %w", err).Error()) - r.status.SetSuccess(time.Now()) - return nil + errMsg := fmt.Errorf("failed to compute relative path for assets: %w", err).Error() + r.status.SetError(errMsg) + return fmt.Errorf("failed to compute relative path for assets: %w", err) } - slog.Default().Debug("RunBackup: staging content directories", "rootRel", rootRel, "assetsRel", assetsRel) + slog.Debug("RunBackup: staging content directories", "rootRel", rootRel, "assetsRel", assetsRel) + + rootDirMissing := false + assetsDirMissing := false if _, err := os.Stat(r.cfg.RootDir); err == nil { if _, err := wt.Add(rootRel); err != nil { errMsg := fmt.Errorf("failed to stage root dir: %w", err).Error() - slog.Default().Debug("RunBackup: failed to stage root dir", "error", errMsg) + slog.Debug("RunBackup: failed to stage root dir", "error", errMsg) r.status.SetError(errMsg) - return nil + return fmt.Errorf("failed to stage root dir: %w", err) } - slog.Default().Debug("RunBackup: staged root dir", "path", rootRel) + slog.Debug("RunBackup: staged root dir", "path", rootRel) } else { - slog.Default().Debug("RunBackup: root dir not found, skipping", "path", r.cfg.RootDir) + rootDirMissing = true + slog.Debug("RunBackup: root dir not found, skipping", "path", r.cfg.RootDir) } if _, err := os.Stat(r.cfg.AssetsDir); err == nil { if _, err := wt.Add(assetsRel); err != nil { errMsg := fmt.Errorf("failed to stage assets dir: %w", err).Error() - slog.Default().Debug("RunBackup: failed to stage assets dir", "error", errMsg) + slog.Debug("RunBackup: failed to stage assets dir", "error", errMsg) r.status.SetError(errMsg) - return nil + return fmt.Errorf("failed to stage assets dir: %w", err) } - slog.Default().Debug("RunBackup: staged assets dir", "path", assetsRel) + slog.Debug("RunBackup: staged assets dir", "path", assetsRel) } else { - slog.Default().Debug("RunBackup: assets dir not found, skipping", "path", r.cfg.AssetsDir) + assetsDirMissing = true + slog.Debug("RunBackup: assets dir not found, skipping", "path", r.cfg.AssetsDir) + } + + // Warn if both directories are missing + if rootDirMissing && assetsDirMissing { + slog.Warn("RunBackup: both root and assets directories are missing") } // Check working tree status status, err := wt.Status() if err != nil { errMsg := fmt.Errorf("failed to get status: %w", err).Error() - slog.Default().Debug("RunBackup: failed to get working tree status", "error", errMsg) + slog.Debug("RunBackup: failed to get working tree status", "error", errMsg) r.status.SetError(errMsg) - return nil // Never propagate + return fmt.Errorf("failed to get status: %w", err) } // hasStagedChanges checks only the staging area (index), ignoring untracked files. @@ -277,10 +306,10 @@ func (r *Repository) RunBackup() error { // root/ and assets/ — which would cause empty commits every cycle. We only care // whether the content we explicitly staged above has changed. staged := hasStagedChanges(status) - slog.Default().Debug("RunBackup: working tree status checked", "hasStagedChanges", staged, "totalStatusEntries", len(status)) + slog.Debug("RunBackup: working tree status checked", "hasStagedChanges", staged, "totalStatusEntries", len(status)) if !staged { - slog.Default().Info("backup skipped - no staged changes in content directories") + slog.Info("backup skipped - no staged changes in content directories") r.status.SetSuccess(time.Now()) return nil } @@ -288,13 +317,13 @@ func (r *Repository) RunBackup() error { // Log only the staged files (skip untracked noise from other app directories) for path, fileStatus := range status { if fileStatus.Staging != gogit.Untracked { - slog.Default().Debug("RunBackup: staged file", "path", path, "staging", string(fileStatus.Staging), "worktree", string(fileStatus.Worktree)) + slog.Debug("RunBackup: staged file", "path", path, "staging", string(fileStatus.Staging), "worktree", string(fileStatus.Worktree)) } } // Commit changes commitMsg := fmt.Sprintf("backup: %s", time.Now().Format(time.RFC3339)) - slog.Default().Debug("RunBackup: committing changes", "message", commitMsg, "author", r.cfg.AuthorName, "email", r.cfg.AuthorEmail) + slog.Debug("RunBackup: committing changes", "message", commitMsg, "author", r.cfg.AuthorName, "email", r.cfg.AuthorEmail) commit, err := wt.Commit(commitMsg, &gogit.CommitOptions{ Author: &object.Signature{ Name: r.cfg.AuthorName, @@ -305,50 +334,50 @@ func (r *Repository) RunBackup() error { if err != nil { // If it's "nothing to commit" (empty tree), that's fine - just skip if strings.Contains(err.Error(), "cannot create empty commit") { - slog.Default().Debug("RunBackup: commit skipped - empty tree") + slog.Debug("RunBackup: commit skipped - empty tree") return nil } errMsg := fmt.Errorf("failed to commit: %w", err).Error() - slog.Default().Debug("RunBackup: commit failed", "error", errMsg) + slog.Debug("RunBackup: commit failed", "error", errMsg) r.status.SetError(errMsg) - return nil // Never propagate + return fmt.Errorf("failed to commit: %w", err) } - slog.Default().Debug("RunBackup: commit created", "hash", commit.String(), "message", commitMsg) + slog.Debug("RunBackup: commit created", "hash", commit.String(), "message", commitMsg) // Push to remote if r.cfg.RemoteURL != "" { - slog.Default().Debug("RunBackup: pushing to remote", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commit.String()) + slog.Debug("RunBackup: pushing to remote", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commit.String()) if err := r.push(commit.String()); err != nil { - slog.Default().Debug("RunBackup: push failed", "error", err) + slog.Debug("RunBackup: push failed", "error", err) r.status.SetError(err.Error()) - return nil // Never propagate + return fmt.Errorf("push failed: %w", err) } } else { - slog.Default().Debug("RunBackup: no remote configured, skipping push") + slog.Debug("RunBackup: no remote configured, skipping push") } r.status.SetSuccess(time.Now()) - slog.Default().Info("backup pushed to remote") + slog.Info("backup pushed to remote") return nil } // push pushes the given commit hash to the configured remote. func (r *Repository) push(commitHash string) error { - slog.Default().Debug("push: starting", "commit", commitHash, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch) + slog.Debug("push: starting", "commit", commitHash, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch) // Build SSH auth - slog.Default().Debug("push: building SSH auth", "sshKeyPath", r.cfg.SSHKeyPath, "hasInlineKey", r.cfg.SSHKey != "") + slog.Debug("push: building SSH auth", "sshKeyPath", r.cfg.SSHKeyPath, "hasInlineKey", r.cfg.SSHKey != "") auth, err := r.buildSSHAuth() if err != nil { - slog.Default().Debug("push: SSH auth build failed", "error", err) + slog.Debug("push: SSH auth build failed", "error", err) return fmt.Errorf("failed to build SSH auth: %w", err) } - slog.Default().Debug("push: SSH auth built successfully") + slog.Debug("push: SSH auth built successfully") // Get remote - use r.repo directly since we're using the repo instance remote, err := r.repo.Remote("origin") if err != nil { - slog.Default().Debug("push: remote 'origin' not found, creating it", "url", r.cfg.RemoteURL) + slog.Debug("push: remote 'origin' not found, creating it", "url", r.cfg.RemoteURL) // Remote doesn't exist, create it _, err = r.repo.CreateRemote(&config.RemoteConfig{ Name: "origin", @@ -361,10 +390,10 @@ func (r *Repository) push(commitHash string) error { if err != nil { return fmt.Errorf("failed to get remote: %w", err) } - slog.Default().Debug("push: remote 'origin' created", "url", r.cfg.RemoteURL) + slog.Debug("push: remote 'origin' created", "url", r.cfg.RemoteURL) } else { remoteURLs := remote.Config().URLs - slog.Default().Debug("push: remote 'origin' found", "urls", remoteURLs) + slog.Debug("push: remote 'origin' found", "urls", remoteURLs) } // Resolve local HEAD to verify what we are about to push. @@ -372,7 +401,7 @@ func (r *Repository) push(commitHash string) error { if err != nil { return fmt.Errorf("failed to resolve local HEAD: %w", err) } - slog.Default().Debug("push: local HEAD resolved", "hash", localHead.Hash().String(), "branch", localHead.Name().Short()) + slog.Debug("push: local HEAD resolved", "hash", localHead.Hash().String(), "branch", localHead.Name().Short()) // Fetch the current remote ref so we can accurately detect true up-to-date. // go-git returns ErrAlreadyUpToDate for empty/fresh remotes (no refs yet), @@ -380,7 +409,7 @@ func (r *Repository) push(commitHash string) error { // Listing first gives us ground truth before we decide whether to push. remoteRefs, fetchErr := remote.List(&gogit.ListOptions{Auth: auth}) if fetchErr != nil { - slog.Default().Debug("push: could not list remote refs (remote may be empty)", "error", fetchErr) + slog.Debug("push: could not list remote refs (remote may be empty)", "error", fetchErr) } remoteHead := "" @@ -391,11 +420,11 @@ func (r *Repository) push(commitHash string) error { break } } - slog.Default().Debug("push: remote branch state", "branch", r.cfg.Branch, "remoteHead", remoteHead, "localHead", commitHash) + slog.Debug("push: remote branch state", "branch", r.cfg.Branch, "remoteHead", remoteHead, "localHead", commitHash) if remoteHead == commitHash { // Remote genuinely already has this exact commit — nothing to do. - slog.Default().Info("backup skipped - remote already at current commit", "branch", r.cfg.Branch, "commit", commitHash) + slog.Info("backup skipped - remote already at current commit", "branch", r.cfg.Branch, "commit", commitHash) return nil } @@ -407,32 +436,31 @@ func (r *Repository) push(commitHash string) error { // even attempting to send the pack. Removing it forces a clean push. trackingRef := plumbing.NewRemoteReferenceName("origin", r.cfg.Branch) if rmErr := r.repo.Storer.RemoveReference(trackingRef); rmErr != nil && rmErr != plumbing.ErrReferenceNotFound { - slog.Default().Debug("push: could not remove stale remote tracking ref", "ref", trackingRef.String(), "error", rmErr) + slog.Debug("push: could not remove stale remote tracking ref", "ref", trackingRef.String(), "error", rmErr) } else { - slog.Default().Debug("push: cleared remote tracking ref", "ref", trackingRef.String()) + slog.Debug("push: cleared remote tracking ref", "ref", trackingRef.String()) } // Use the resolved branch ref explicitly rather than HEAD. // Symbolic HEAD in a force refspec can confuse go-git when the local branch // name differs from the configured remote branch (e.g. local=master, remote=main). localBranchRef := localHead.Name().String() // e.g. refs/heads/master - refSpec := config.RefSpec("+" + localBranchRef + ":refs/heads/" + r.cfg.Branch) - slog.Default().Debug("push: pushing with force refspec", "refSpec", string(refSpec), "localBranch", localBranchRef, "remoteBranch", r.cfg.Branch) + refSpec := config.RefSpec(localBranchRef + ":refs/heads/" + r.cfg.Branch) + slog.Debug("push: pushing", "refSpec", string(refSpec), "localBranch", localBranchRef, "remoteBranch", r.cfg.Branch) err = remote.Push(&gogit.PushOptions{ Auth: auth, RefSpecs: []config.RefSpec{refSpec}, - Force: true, }) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "already up-to-date") { // Genuine up-to-date: remote caught up between our List call and Push. - slog.Default().Info("backup skipped - already up-to-date on " + r.cfg.Branch + " at remote URL: " + r.cfg.RemoteURL) + slog.Info("backup skipped - already up-to-date on " + r.cfg.Branch + " at remote URL: " + r.cfg.RemoteURL) return nil } - slog.Default().Error("git push failed", "error", err, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "refSpec", string(refSpec)) + slog.Error("git push failed", "error", err, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "refSpec", string(refSpec)) return fmt.Errorf("failed to push: %w", err) } - slog.Default().Info("git push succeeded", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commitHash) + slog.Info("git push succeeded", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commitHash) return nil } @@ -443,36 +471,50 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { // Try SSHKey string first if r.cfg.SSHKey != "" { - slog.Default().Debug("buildSSHAuth: using inline SSH key") + slog.Debug("buildSSHAuth: using inline SSH key") privateKey = []byte(r.cfg.SSHKey) } else if r.cfg.SSHKeyPath != "" { - slog.Default().Debug("buildSSHAuth: reading SSH key from file", "path", r.cfg.SSHKeyPath) + slog.Debug("buildSSHAuth: reading SSH key from file", "path", r.cfg.SSHKeyPath) privateKey, err = os.ReadFile(r.cfg.SSHKeyPath) if err != nil { - slog.Default().Debug("buildSSHAuth: failed to read SSH key file", "path", r.cfg.SSHKeyPath, "error", err) + slog.Debug("buildSSHAuth: failed to read SSH key file", "path", r.cfg.SSHKeyPath, "error", err) return nil, fmt.Errorf("failed to read SSH key: %w", err) } - slog.Default().Debug("buildSSHAuth: SSH key file read successfully", "path", r.cfg.SSHKeyPath, "size", len(privateKey)) + slog.Debug("buildSSHAuth: SSH key file read successfully", "path", r.cfg.SSHKeyPath, "size", len(privateKey)) } else { - slog.Default().Debug("buildSSHAuth: no SSH key configured (neither inline nor path)") + slog.Debug("buildSSHAuth: no SSH key configured (neither inline nor path)") return nil, fmt.Errorf("no SSH key provided") } // Parse the private key using x/crypto/ssh signer, err := sshcrypto.ParsePrivateKey(privateKey) if err != nil { - slog.Default().Error("failed to parse SSH key", "error", err, "path", r.cfg.SSHKeyPath) + slog.Error("failed to parse SSH key", "error", err, "path", r.cfg.SSHKeyPath) return nil, fmt.Errorf("failed to parse SSH key: %w", err) } - slog.Default().Debug("buildSSHAuth: SSH key parsed successfully", "keyType", signer.PublicKey().Type()) + slog.Debug("buildSSHAuth: SSH key parsed successfully", "keyType", signer.PublicKey().Type()) - // Use InsecureIgnoreHostKey since we don't have a known_hosts file in the container auth := &ssh.PublicKeys{ User: "git", Signer: signer, } - auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() - slog.Default().Debug("buildSSHAuth: SSH auth configured with InsecureIgnoreHostKey") + + // Use known hosts for MITM protection if provided + if r.cfg.SSHKnownHosts != "" { + // ParseKnownHosts returns: marker, hosts, pubKey, comment, rest, err + _, _, pubKey, _, _, err := sshcrypto.ParseKnownHosts([]byte(r.cfg.SSHKnownHosts)) + if err != nil { + slog.Warn("buildSSHAuth: failed to parse SSHKnownHosts, falling back to insecure mode", "error", err) + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + } else { + // Use FixedHostKey from x/crypto/ssh for MITM protection + auth.HostKeyCallback = sshcrypto.FixedHostKey(pubKey) + slog.Debug("buildSSHAuth: SSH auth configured with FixedHostKey") + } + } else { + slog.Warn("buildSSHAuth: no SSHKnownHosts provided, connection will be insecure (no MITM protection)") + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + } return auth, nil } diff --git a/internal/backup/repo_test.go b/internal/backup/repo_test.go index 8d2018cd1..d2ff6a8ee 100644 --- a/internal/backup/repo_test.go +++ b/internal/backup/repo_test.go @@ -331,4 +331,54 @@ func TestInit_RequiresAssetsDir(t *testing.T) { if err == nil { t.Error("expected error for empty AssetsDir") } +} + +func TestInit_RequiresAuthorName(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "", + AuthorEmail: "test@example.com", + } + _, err = Init(cfg) + if err == nil { + t.Error("expected error for empty AuthorName") + } +} + +func TestInit_RequiresAuthorEmail(t *testing.T) { + tmpDir := t.TempDir() + rootDir := filepath.Join(tmpDir, "root") + assetsDir := filepath.Join(tmpDir, "assets") + err := os.MkdirAll(rootDir, 0755) + if err != nil { + t.Fatalf("failed to create root dir: %v", err) + } + err = os.MkdirAll(assetsDir, 0755) + if err != nil { + t.Fatalf("failed to create assets dir: %v", err) + } + + cfg := Config{ + RootDir: rootDir, + AssetsDir: assetsDir, + AuthorName: "Test Author", + AuthorEmail: "", + } + _, err = Init(cfg) + if err == nil { + t.Error("expected error for empty AuthorEmail") + } } \ No newline at end of file From 5a08da91677eeea1f2308f7a38b2527d5160c1bf Mon Sep 17 00:00:00 2001 From: perber Date: Fri, 22 May 2026 18:12:27 +0200 Subject: [PATCH 35/54] Add backupRoutes to wiki service structure --- internal/wiki/wiki.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go index 145502a4e..88ad7a48b 100644 --- a/internal/wiki/wiki.go +++ b/internal/wiki/wiki.go @@ -62,8 +62,8 @@ type Wiki struct { links *links.LinkService tags *tags.TagsService props *properties.PropertiesService + backupRoutes *wikibackup.Routes log *slog.Logger - } const SYSTEM_USER_ID = "system" From 16020e4c76d618fc640580ae8205cf6b11a28a3b Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 21:22:02 +0200 Subject: [PATCH 36/54] fix: cmd/leafwiki/main.go:344:53 --- cmd/leafwiki/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index aabd614b4..a4ba2790e 100644 --- a/cmd/leafwiki/main.go +++ b/cmd/leafwiki/main.go @@ -341,7 +341,7 @@ func main() { if err != nil { fail("git backup init failed: %v", err) } - backupScheduler = backup.NewScheduler(backupRepo, time.Duration(gitBackupInterval)*time.Minute) + backupScheduler = backup.NewScheduler(backupRepo, &backup.Config{IntervalMinutes: gitBackupInterval}) defer backupScheduler.Stop() w.SetBackupRoutes(wikibackup.NewRoutes(backupRepo, backupScheduler, w.AuthService())) } From 76f4c9fd0d6635cea399b34a4619855d18aad267 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 22:18:56 +0200 Subject: [PATCH 37/54] fix: linter --- internal/backup/scheduler.go | 12 +++++++++--- .../src/features/backup/BackupSettings.tsx | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go index 2ab9831b7..55a7a0f97 100644 --- a/internal/backup/scheduler.go +++ b/internal/backup/scheduler.go @@ -48,14 +48,20 @@ func (s *Scheduler) run() { }() // Run immediately on start - s.repo.RunBackup() + if err := s.repo.RunBackup(); err != nil { + slog.Error("backup failed", "error", err) + } for { select { case <-s.ticker.C: - s.repo.RunBackup() + if err := s.repo.RunBackup(); err != nil { + slog.Error("backup failed", "error", err) + } case <-s.manual: - s.repo.RunBackup() + if err := s.repo.RunBackup(); err != nil { + slog.Error("backup failed", "error", err) + } case <-s.done: return } diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 4fbfdc3ae..2488c275c 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -55,7 +55,7 @@ export default function BackupSettings() { pollingRef.current = null } } - }, [isPolling, loadStatus]) + }, [isPolling, loadStatus, lastBackupAt]) // Stop polling when lastBackupAt advances or an error occurs useEffect(() => { @@ -77,7 +77,7 @@ export default function BackupSettings() { try { await triggerPush() toast.success('Backup triggered') - } catch (err) { + } catch (_err) { toast.error('Failed to trigger backup') } } From a6edb959dcf74596f95b90090281e250e50c4806 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 22:21:51 +0200 Subject: [PATCH 38/54] fix: linter --- ui/leafwiki-ui/src/features/backup/BackupSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 2488c275c..639e5a3e1 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -77,7 +77,7 @@ export default function BackupSettings() { try { await triggerPush() toast.success('Backup triggered') - } catch (_err) { + } catch { toast.error('Failed to trigger backup') } } From 82ee157afe3db6fd3b650646931b51579c2f2cdd Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Fri, 22 May 2026 22:34:31 +0200 Subject: [PATCH 39/54] lint: prettier --- .../src/features/backup/BackupSettings.tsx | 15 +++++++++------ .../src/features/backup/useToolbarActions.ts | 2 +- ui/leafwiki-ui/src/lib/api/backup.ts | 2 +- ui/leafwiki-ui/src/stores/backup.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 639e5a3e1..7c000f7fe 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -60,7 +60,10 @@ export default function BackupSettings() { // Stop polling when lastBackupAt advances or an error occurs useEffect(() => { if (isPolling) { - const hasNewBackup = lastBackupAtRef.current !== null && lastBackupAt !== null && lastBackupAtRef.current !== lastBackupAt + const hasNewBackup = + lastBackupAtRef.current !== null && + lastBackupAt !== null && + lastBackupAtRef.current !== lastBackupAt const hasError = lastError !== '' if (hasNewBackup || hasError) { stopPolling() @@ -89,7 +92,7 @@ export default function BackupSettings() { {isLoading && (
-
+
Loading backup status…
@@ -123,9 +126,9 @@ export default function BackupSettings() { <>
Last backup - + {isPolling ? ( - + Waiting for backup to complete… @@ -138,10 +141,10 @@ export default function BackupSettings() { {lastError && (
- + Last error - {lastError} + {lastError}
)} diff --git a/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts b/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts index 3752f4e2d..018e0a491 100644 --- a/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts +++ b/ui/leafwiki-ui/src/features/backup/useToolbarActions.ts @@ -10,4 +10,4 @@ export function useToolbarActions() { useEffect(() => { setButtons([]) }, [setButtons]) -} \ No newline at end of file +} diff --git a/ui/leafwiki-ui/src/lib/api/backup.ts b/ui/leafwiki-ui/src/lib/api/backup.ts index 66ae5d529..651ec28e1 100644 --- a/ui/leafwiki-ui/src/lib/api/backup.ts +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -24,4 +24,4 @@ export async function triggerBackupPush(): Promise { await fetchWithAuth(BACKUP_PUSH_URL, { method: 'POST', }) -} \ No newline at end of file +} diff --git a/ui/leafwiki-ui/src/stores/backup.ts b/ui/leafwiki-ui/src/stores/backup.ts index dc771b001..b4f5d1b3f 100644 --- a/ui/leafwiki-ui/src/stores/backup.ts +++ b/ui/leafwiki-ui/src/stores/backup.ts @@ -1,5 +1,9 @@ import { create } from 'zustand' -import { fetchBackupStatus, triggerBackupPush, BackupStatusResponse } from '@/lib/api/backup' +import { + fetchBackupStatus, + triggerBackupPush, + BackupStatusResponse, +} from '@/lib/api/backup' interface BackupState { enabled: boolean @@ -47,4 +51,4 @@ export const useBackupStore = create((set, get) => ({ stopPolling: () => { set({ isPolling: false }) }, -})) \ No newline at end of file +})) From 04386fad34c5c622ea1d444dcfeea0ab4a6d6480 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 13:50:40 +0200 Subject: [PATCH 40/54] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 295568508..44ecd0f0c 100644 --- a/readme.md +++ b/readme.md @@ -366,8 +366,8 @@ This is especially useful in containerized or production environments. | `LEAFWIKI_HTTP_REMOTE_USER_HEADER_NAME` | HTTP header carrying the username from a trusted proxy | `Remote-User` | v0.10.0 | | `LEAFWIKI_TRUSTED_PROXY_IPS` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | | `LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup to a remote repository | `false` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup (local commits; optional remote push over SSH) | `false` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (optional; required only to push off-site) | `""` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | From 5e46fc8d9e79732592e9d16755879080a63045d4 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:03:49 +0200 Subject: [PATCH 41/54] chore: run go mod tidy to remove unused golang.org/x/exp dependency --- go.mod | 1 - 1 file changed, 1 deletion(-) diff --git a/go.mod b/go.mod index e0eb155fe..458319b1c 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.51.0 - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 ) From 9362e852f98ae2211e3bb6658d293dd92f9b0a7b Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:37 +0200 Subject: [PATCH 42/54] fix(backup): fix infinite polling loop on first-ever backup --- ui/leafwiki-ui/src/features/backup/BackupSettings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 7c000f7fe..85bf27082 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -61,7 +61,6 @@ export default function BackupSettings() { useEffect(() => { if (isPolling) { const hasNewBackup = - lastBackupAtRef.current !== null && lastBackupAt !== null && lastBackupAtRef.current !== lastBackupAt const hasError = lastError !== '' From 073938a4e597a589a106a478e6524ef16dfc7d8c Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:40 +0200 Subject: [PATCH 43/54] fix(backup): use fetchWithAuth instead of raw fetch --- ui/leafwiki-ui/src/lib/api/backup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/leafwiki-ui/src/lib/api/backup.ts b/ui/leafwiki-ui/src/lib/api/backup.ts index 651ec28e1..03b84167c 100644 --- a/ui/leafwiki-ui/src/lib/api/backup.ts +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -13,7 +13,7 @@ export interface BackupStatusResponse { } export async function fetchBackupStatus(): Promise { - const res = await fetch(`${API_BASE_URL}${BACKUP_STATUS_URL}`, { + const res = await fetchWithAuth(`${API_BASE_URL}${BACKUP_STATUS_URL}`, { credentials: 'include', }) if (!res.ok) throw new Error('Failed to fetch backup status') From 7e10231452cb3bc5ffd00edf546bc21268975603 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:42 +0200 Subject: [PATCH 44/54] fix(backup): add .leafwiki/ and schema.json to gitignore, remove syscall.Umask --- internal/backup/gitignore.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/backup/gitignore.go b/internal/backup/gitignore.go index bc5bad73c..71e1d4441 100644 --- a/internal/backup/gitignore.go +++ b/internal/backup/gitignore.go @@ -3,7 +3,6 @@ package backup import ( "os" "path/filepath" - "syscall" ) const gitignoreContent = `# LeafWiki runtime files – do not commit @@ -13,19 +12,18 @@ const gitignoreContent = `# LeafWiki runtime files – do not commit *.db-wal *.tmp .tmp-* +.leafwiki/ +schema.json ` // EnsureGitignore writes a .gitignore to repoDir if it does not already exist. func EnsureGitignore(repoDir string) error { gitignorePath := filepath.Join(repoDir, ".gitignore") if _, err := os.Stat(gitignorePath); err == nil { - // File exists, do not overwrite return nil } else if !os.IsNotExist(err) { return err } - // Respect system umask - oldmask := syscall.Umask(0) - defer syscall.Umask(oldmask) - return os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644&^os.FileMode(oldmask)) + // os.WriteFile already respects the process umask — no manual umask needed + return os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644) } \ No newline at end of file From 9496372cc970ef9aea72f3694c9801f0cfac034a Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:45 +0200 Subject: [PATCH 45/54] fix(backup): don't set LastBackupAt on no-op skip, split misleading log message --- internal/backup/repo.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index f336fa9d4..e73740a3f 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -310,7 +310,7 @@ func (r *Repository) RunBackup() error { if !staged { slog.Info("backup skipped - no staged changes in content directories") - r.status.SetSuccess(time.Now()) + // Don't update LastBackupAt — no actual backup occurred return nil } @@ -356,8 +356,12 @@ func (r *Repository) RunBackup() error { slog.Debug("RunBackup: no remote configured, skipping push") } + if r.cfg.RemoteURL != "" { + slog.Info("backup committed and pushed to remote") + } else { + slog.Info("backup committed locally (no remote configured)") + } r.status.SetSuccess(time.Now()) - slog.Info("backup pushed to remote") return nil } From a7f5ba6139f9503288cde75eea326527e1592a3f Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:47 +0200 Subject: [PATCH 46/54] fix(backup): simplify TriggerNow to single non-blocking send --- internal/backup/scheduler.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go index 55a7a0f97..4c522ae3e 100644 --- a/internal/backup/scheduler.go +++ b/internal/backup/scheduler.go @@ -70,17 +70,10 @@ func (s *Scheduler) run() { // TriggerNow signals the scheduler to run a backup immediately, // regardless of the interval. Non-blocking. -// If a backup is already in progress, the signal is queued (buffer size 2). func (s *Scheduler) TriggerNow() { select { case s.manual <- struct{}{}: default: - // Buffer full (backup in progress), try to add more - select { - case s.manual <- struct{}{}: - default: - // Buffer still full, at least 1 signal is pending - don't drop - } } } From 3554fc2603c6e7d4050ffc437c35d5b6fc5b717e Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:05:50 +0200 Subject: [PATCH 47/54] fix(backup): replace direct repo.status.LastBackupAt with repo.Status().LastBackupAt --- internal/backup/scheduler_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/backup/scheduler_test.go b/internal/backup/scheduler_test.go index ba3c0902d..c1dbf0b8c 100644 --- a/internal/backup/scheduler_test.go +++ b/internal/backup/scheduler_test.go @@ -52,7 +52,7 @@ func TestScheduler_TriggerNow(t *testing.T) { for { select { case <-ticker.C: - if !repo.status.LastBackupAt.IsZero() { + if !repo.Status().LastBackupAt.IsZero() { goto afterInitialRun } case <-timeout: @@ -69,11 +69,11 @@ afterInitialRun: ticker2 := time.NewTicker(50 * time.Millisecond) defer ticker2.Stop() - initialBackup := repo.status.LastBackupAt + initialBackup := repo.Status().LastBackupAt for { select { case <-ticker2.C: - if !repo.status.LastBackupAt.IsZero() && !repo.status.LastBackupAt.Equal(initialBackup) { + if !repo.Status().LastBackupAt.IsZero() && !repo.Status().LastBackupAt.Equal(initialBackup) { return // Success } case <-timeout2: @@ -164,7 +164,7 @@ func TestScheduler_RunsOnStart(t *testing.T) { for { select { case <-ticker.C: - if !repo.status.LastBackupAt.IsZero() { + if !repo.Status().LastBackupAt.IsZero() { return // Success } case <-timeout: From 5bcbcaeec1d6abccc93d64ddcbfce6dca5048911 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:06:06 +0200 Subject: [PATCH 48/54] fix: wire SSHKnownHosts from CLI/env, make remote optional --- cmd/leafwiki/main.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index a4ba2790e..f7257aabb 100644 --- a/cmd/leafwiki/main.go +++ b/cmd/leafwiki/main.go @@ -61,6 +61,7 @@ func writeUsage(w io.Writer) { --git-backup-branch Git branch to push to (default: main) --git-backup-ssh-key-path Path to SSH private key for git backup --git-backup-ssh-key Raw SSH private key for git backup (env var preferred) + --git-backup-ssh-known-hosts Path to known_hosts file for SSH host key verification (MITM protection) --git-backup-interval Git backup interval in minutes (default: 60) Environment variables: @@ -94,6 +95,7 @@ func writeUsage(w io.Writer) { LEAFWIKI_GIT_BACKUP_BRANCH LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH LEAFWIKI_GIT_BACKUP_SSH_KEY + LEAFWIKI_GIT_BACKUP_SSH_KNOWN_HOSTS LEAFWIKI_GIT_BACKUP_INTERVAL `); err != nil { panic(err) @@ -157,6 +159,7 @@ type cliFlags struct { gitBackupBranch *string gitBackupSSHKeyPath *string gitBackupSSHKey *string + gitBackupSSHKnownHosts *string gitBackupInterval *int } @@ -191,7 +194,8 @@ func registerFlags(fs *flag.FlagSet) *cliFlags { gitBackupBranch: fs.String("git-backup-branch", "", "git branch to push to (default: main)"), gitBackupSSHKeyPath: fs.String("git-backup-ssh-key-path", "", "path to SSH private key for git backup"), gitBackupSSHKey: fs.String("git-backup-ssh-key", "", "raw SSH private key for git backup (env var preferred)"), - gitBackupInterval: fs.Int("git-backup-interval", 0, "git backup interval in minutes (default: 60)"), + gitBackupSSHKnownHosts: fs.String("git-backup-ssh-known-hosts", "", "path to known_hosts file for SSH host key verification (MITM protection)"), + gitBackupInterval: fs.Int("git-backup-interval", 0, "git backup interval in minutes (default: 60)"), } } @@ -242,15 +246,14 @@ func main() { gitBackupSSHKeyPath := resolveString("git-backup-ssh-key-path", *flags.gitBackupSSHKeyPath, visited, "LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH", "") gitBackupSSHKey := resolveString("git-backup-ssh-key", *flags.gitBackupSSHKey, visited, "LEAFWIKI_GIT_BACKUP_SSH_KEY", "") gitBackupInterval := resolveInt("git-backup-interval", *flags.gitBackupInterval, visited, "LEAFWIKI_GIT_BACKUP_INTERVAL", 60) + gitBackupSSHKnownHosts := resolveString("git-backup-ssh-known-hosts", *flags.gitBackupSSHKnownHosts, visited, "LEAFWIKI_GIT_BACKUP_SSH_KNOWN_HOSTS", "") trustedProxies, err := authmw.ParseTrustedProxies(trustedProxyIPsRaw) if err != nil { fail("invalid --trusted-proxy-ips value", "error", err) } // Validate git backup configuration - if gitBackupEnabled && gitBackupRemote == "" { - fail("git-backup-remote is required when git-backup is enabled. Set it using --git-backup-remote or LEAFWIKI_GIT_BACKUP_REMOTE.") - } + // Note: git-backup-remote is now optional (local-only mode is supported) args := flag.Args() if len(args) > 0 { @@ -336,6 +339,7 @@ func main() { Branch: gitBackupBranch, SSHKeyPath: gitBackupSSHKeyPath, SSHKey: gitBackupSSHKey, + SSHKnownHosts: gitBackupSSHKnownHosts, IntervalMinutes: gitBackupInterval, }) if err != nil { From 1e1bbefec7b4c3814e50c5e1c8ad20191d2d3851 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:06:09 +0200 Subject: [PATCH 49/54] chore: add LEAFWIKI_GIT_BACKUP_SSH_KNOWN_HOSTS entry --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 6d1925650..bca894264 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,7 @@ LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH= # Example: LEAFWIKI_GIT_BACKUP_SSH_KEY="-----BEGIN OPENSSH PRIVATE KEY-----\n..." LEAFWIKI_GIT_BACKUP_SSH_KEY= +# Path to known_hosts file for SSH host key verification (MITM protection) +# If not set, host key checking is skipped (insecure) +LEAFWIKI_GIT_BACKUP_SSH_KNOWN_HOSTS= + From 99c2ff0beaeb4e0470584aa0cd8dad41f9b6382b Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:06:11 +0200 Subject: [PATCH 50/54] docs: update description to say remote is optional --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 44ecd0f0c..db6bc6e53 100644 --- a/readme.md +++ b/readme.md @@ -325,7 +325,7 @@ If you are just getting started, the most important options are usually: | `--trusted-proxy-ips` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | | `--http-remote-user-logout-url` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | | `--git-backup` | Enable automated git backup to a remote repository | `false` | v0.10.0 | -| `--git-backup-remote` | SSH remote URL for the backup repository (required when backup is enabled) | `""` | v0.10.0 | +| `--git-backup-remote` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.10.0 | | `--git-backup-branch` | Git branch to push to | `main` | v0.10.0 | | `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | v0.10.0 | | `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | @@ -367,7 +367,7 @@ This is especially useful in containerized or production environments. | `LEAFWIKI_TRUSTED_PROXY_IPS` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | | `LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP` | Enable automated git backup (local commits; optional remote push over SSH) | `false` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (optional; required only to push off-site) | `""` | v0.10.0 | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | v0.10.0 | | `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | From 18eb8697ea31e1347b2e325af455417bd34c2c64 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:17:40 +0200 Subject: [PATCH 51/54] chore: remove obselete comment --- internal/backup/repo.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/backup/repo.go b/internal/backup/repo.go index e73740a3f..292f95772 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -310,7 +310,6 @@ func (r *Repository) RunBackup() error { if !staged { slog.Info("backup skipped - no staged changes in content directories") - // Don't update LastBackupAt — no actual backup occurred return nil } From b8b12b872a426ebaec0e6c350fecc12f60bea8a6 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sat, 23 May 2026 15:53:22 +0200 Subject: [PATCH 52/54] fix: type casting --- ui/leafwiki-ui/src/lib/api/backup.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/leafwiki-ui/src/lib/api/backup.ts b/ui/leafwiki-ui/src/lib/api/backup.ts index 03b84167c..275e72a65 100644 --- a/ui/leafwiki-ui/src/lib/api/backup.ts +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -16,8 +16,7 @@ export async function fetchBackupStatus(): Promise { const res = await fetchWithAuth(`${API_BASE_URL}${BACKUP_STATUS_URL}`, { credentials: 'include', }) - if (!res.ok) throw new Error('Failed to fetch backup status') - return res.json() + return res as BackupStatusResponse } export async function triggerBackupPush(): Promise { From b9a4c297023025b958d90c0339ff763580d7733b Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sun, 24 May 2026 14:29:42 +0200 Subject: [PATCH 53/54] feat: refactoring --- cmd/leafwiki/main.go | 2 +- internal/backup/repo.go | 100 ++++++++---------- internal/backup/repo_test.go | 3 + internal/backup/scheduler.go | 53 +++++----- internal/backup/scheduler_test.go | 80 ++++++-------- internal/backup/status.go | 12 ++- .../src/features/backup/BackupSettings.tsx | 2 +- ui/leafwiki-ui/src/lib/api/backup.ts | 5 +- 8 files changed, 119 insertions(+), 138 deletions(-) diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index f7257aabb..eb3e61aa7 100644 --- a/cmd/leafwiki/main.go +++ b/cmd/leafwiki/main.go @@ -345,7 +345,7 @@ func main() { if err != nil { fail("git backup init failed: %v", err) } - backupScheduler = backup.NewScheduler(backupRepo, &backup.Config{IntervalMinutes: gitBackupInterval}) + backupScheduler = backup.NewScheduler(backupRepo, time.Duration(gitBackupInterval)*time.Minute) defer backupScheduler.Stop() w.SetBackupRoutes(wikibackup.NewRoutes(backupRepo, backupScheduler, w.AuthService())) } diff --git a/internal/backup/repo.go b/internal/backup/repo.go index 292f95772..9048f23b3 100644 --- a/internal/backup/repo.go +++ b/internal/backup/repo.go @@ -18,17 +18,15 @@ import ( // Repository wraps a git repository with backup-specific state. type Repository struct { - cfg Config - repo *gogit.Repository - status *Status + cfg Config + repoDir string + repo *gogit.Repository + status *Status } // Init opens an existing repo at repoDir or initialises a new one. // On first init, stages root/ and assets/ and makes an initial commit. func Init(cfg Config) (*Repository, error) { - repoDir := filepath.Dir(cfg.RootDir) - slog.Debug("backup init started", "repoDir", repoDir, "rootDir", cfg.RootDir, "assetsDir", cfg.AssetsDir, "remote", cfg.RemoteURL, "branch", cfg.Branch) - if cfg.RootDir == "" { return nil, fmt.Errorf("RootDir is required") } @@ -42,14 +40,18 @@ func Init(cfg Config) (*Repository, error) { return nil, fmt.Errorf("AuthorEmail is required") } + repoDir := filepath.Dir(filepath.Clean(cfg.RootDir)) + slog.Debug("backup init started", "repoDir", repoDir, "rootDir", cfg.RootDir, "assetsDir", cfg.AssetsDir, "remote", cfg.RemoteURL, "branch", cfg.Branch) + // Ensure parent directory exists if err := os.MkdirAll(repoDir, 0755); err != nil { return nil, fmt.Errorf("failed to create repo directory: %w", err) } r := &Repository{ - cfg: cfg, - status: &Status{}, + cfg: cfg, + repoDir: repoDir, + status: &Status{}, } // Try to open existing repo @@ -70,6 +72,10 @@ func Init(cfg Config) (*Repository, error) { slog.Debug("new git repo initialised", "repoDir", repoDir) r.repo = repo + if err := EnsureGitignore(repoDir); err != nil { + return nil, fmt.Errorf("failed to write .gitignore: %w", err) + } + // Create initial commit with root/ and assets/ if they exist if err := r.makeInitialCommit(); err != nil { return nil, fmt.Errorf("failed to make initial commit: %w", err) @@ -88,12 +94,11 @@ func (r *Repository) makeInitialCommit() error { } // Compute relative paths from repo root - repoDir := filepath.Dir(r.cfg.RootDir) - rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) + rootRel, err := filepath.Rel(r.repoDir, r.cfg.RootDir) if err != nil { return fmt.Errorf("failed to compute relative path for root: %w", err) } - assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) + assetsRel, err := filepath.Rel(r.repoDir, r.cfg.AssetsDir) if err != nil { return fmt.Errorf("failed to compute relative path for assets: %w", err) } @@ -150,7 +155,7 @@ func (r *Repository) makeInitialCommit() error { // If no files were found in root/assets, skip initial commit // The first RunBackup will create the commit when there's actual content if !stagedFiles { - slog.Default().Debug("makeInitialCommit: no files found in root or assets, skipping initial commit") + slog.Debug("makeInitialCommit: no files found in root or assets, skipping initial commit") return nil } @@ -160,10 +165,10 @@ func (r *Repository) makeInitialCommit() error { return err } if status.IsClean() { - slog.Default().Debug("makeInitialCommit: working tree is clean after staging, nothing to commit") + slog.Debug("makeInitialCommit: working tree is clean after staging, nothing to commit") return nil // Nothing to commit } - slog.Default().Debug("makeInitialCommit: staged file count", "count", len(status)) + slog.Debug("makeInitialCommit: staged file count", "count", len(status)) commit, err := wt.Commit("Initial commit", &gogit.CommitOptions{ Author: &object.Signature{ @@ -238,20 +243,13 @@ func (r *Repository) RunBackup() error { return fmt.Errorf("failed to get worktree: %w", err) } - repoDir := filepath.Dir(r.cfg.RootDir) - slog.Debug("RunBackup: resolved repo dir", "repoDir", repoDir) - - // Compute relative paths for the two content directories we back up. - // Only root/ (wiki pages) and assets/ (uploaded files) are included. - // Internal app directories (.leafwiki/, schema.json, search.db-journal, etc.) - // are intentionally excluded — they are application state, not user content. - rootRel, err := filepath.Rel(repoDir, r.cfg.RootDir) + rootRel, err := filepath.Rel(r.repoDir, r.cfg.RootDir) if err != nil { errMsg := fmt.Errorf("failed to compute relative path for root: %w", err).Error() r.status.SetError(errMsg) return fmt.Errorf("failed to compute relative path for root: %w", err) } - assetsRel, err := filepath.Rel(repoDir, r.cfg.AssetsDir) + assetsRel, err := filepath.Rel(r.repoDir, r.cfg.AssetsDir) if err != nil { errMsg := fmt.Errorf("failed to compute relative path for assets: %w", err).Error() r.status.SetError(errMsg) @@ -310,6 +308,7 @@ func (r *Repository) RunBackup() error { if !staged { slog.Info("backup skipped - no staged changes in content directories") + r.status.SetSuccess(time.Now()) return nil } @@ -334,6 +333,7 @@ func (r *Repository) RunBackup() error { // If it's "nothing to commit" (empty tree), that's fine - just skip if strings.Contains(err.Error(), "cannot create empty commit") { slog.Debug("RunBackup: commit skipped - empty tree") + r.status.SetSuccess(time.Now()) return nil } errMsg := fmt.Errorf("failed to commit: %w", err).Error() @@ -406,31 +406,6 @@ func (r *Repository) push(commitHash string) error { } slog.Debug("push: local HEAD resolved", "hash", localHead.Hash().String(), "branch", localHead.Name().Short()) - // Fetch the current remote ref so we can accurately detect true up-to-date. - // go-git returns ErrAlreadyUpToDate for empty/fresh remotes (no refs yet), - // which is a false positive — the remote simply has no branch to compare against. - // Listing first gives us ground truth before we decide whether to push. - remoteRefs, fetchErr := remote.List(&gogit.ListOptions{Auth: auth}) - if fetchErr != nil { - slog.Debug("push: could not list remote refs (remote may be empty)", "error", fetchErr) - } - - remoteHead := "" - targetRef := "refs/heads/" + r.cfg.Branch - for _, ref := range remoteRefs { - if ref.Name().String() == targetRef { - remoteHead = ref.Hash().String() - break - } - } - slog.Debug("push: remote branch state", "branch", r.cfg.Branch, "remoteHead", remoteHead, "localHead", commitHash) - - if remoteHead == commitHash { - // Remote genuinely already has this exact commit — nothing to do. - slog.Info("backup skipped - remote already at current commit", "branch", r.cfg.Branch, "commit", commitHash) - return nil - } - // Delete the local remote-tracking ref before pushing. // go-git compares local HEAD against refs/remotes/origin/ (the cached // tracking ref written by previous pushes). If the remote was reset or recreated @@ -502,17 +477,30 @@ func (r *Repository) buildSSHAuth() (ssh.AuthMethod, error) { Signer: signer, } - // Use known hosts for MITM protection if provided + // Use known hosts for MITM protection if provided. + // NewKnownHostsCallback expects a file path, so we write the raw content to a temp file. if r.cfg.SSHKnownHosts != "" { - // ParseKnownHosts returns: marker, hosts, pubKey, comment, rest, err - _, _, pubKey, _, _, err := sshcrypto.ParseKnownHosts([]byte(r.cfg.SSHKnownHosts)) - if err != nil { - slog.Warn("buildSSHAuth: failed to parse SSHKnownHosts, falling back to insecure mode", "error", err) + tmpFile, tmpErr := os.CreateTemp("", "known_hosts_*") + if tmpErr != nil { + slog.Warn("buildSSHAuth: failed to create temp file for SSHKnownHosts, falling back to insecure mode", "error", tmpErr) auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() } else { - // Use FixedHostKey from x/crypto/ssh for MITM protection - auth.HostKeyCallback = sshcrypto.FixedHostKey(pubKey) - slog.Debug("buildSSHAuth: SSH auth configured with FixedHostKey") + defer os.Remove(tmpFile.Name()) + if _, writeErr := tmpFile.WriteString(r.cfg.SSHKnownHosts); writeErr != nil { + tmpFile.Close() + slog.Warn("buildSSHAuth: failed to write SSHKnownHosts to temp file, falling back to insecure mode", "error", writeErr) + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + } else { + tmpFile.Close() + knownHostsCallback, err := ssh.NewKnownHostsCallback(tmpFile.Name()) + if err != nil { + slog.Warn("buildSSHAuth: failed to parse SSHKnownHosts, falling back to insecure mode", "error", err) + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + } else { + auth.HostKeyCallback = knownHostsCallback + slog.Debug("buildSSHAuth: SSH auth configured with known hosts callback") + } + } } } else { slog.Warn("buildSSHAuth: no SSHKnownHosts provided, connection will be insecure (no MITM protection)") diff --git a/internal/backup/repo_test.go b/internal/backup/repo_test.go index d2ff6a8ee..35d66f490 100644 --- a/internal/backup/repo_test.go +++ b/internal/backup/repo_test.go @@ -153,6 +153,9 @@ func TestRunBackup_NothingToCommit(t *testing.T) { if status.LastError != "" { t.Errorf("expected no error, got %s", status.LastError) } + if status.LastBackupAt == nil { + t.Error("expected LastBackupAt to be set after backup run, got nil") + } } func TestRunBackup_StagesAndCommits(t *testing.T) { diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go index 4c522ae3e..931b5868c 100644 --- a/internal/backup/scheduler.go +++ b/internal/backup/scheduler.go @@ -16,11 +16,11 @@ type Scheduler struct { manual chan struct{} done chan struct{} wg sync.WaitGroup + closeOnce sync.Once } // NewScheduler creates and starts the background goroutine. -func NewScheduler(repo *Repository, cfg *Config) *Scheduler { - interval := cfg.Duration() +func NewScheduler(repo *Repository, interval time.Duration) *Scheduler { if interval < minInterval { slog.Warn("backup scheduler interval too small, using minimum", "requested", interval, "using", minInterval) interval = minInterval @@ -31,6 +31,7 @@ func NewScheduler(repo *Repository, cfg *Config) *Scheduler { manual: make(chan struct{}, 1), done: make(chan struct{}), } + s.manual <- struct{}{} // pre-seed: first select fires immediately s.wg.Add(1) go s.run() @@ -40,29 +41,28 @@ func NewScheduler(repo *Repository, cfg *Config) *Scheduler { func (s *Scheduler) run() { defer s.wg.Done() - // Recover from panics to avoid killing the scheduler - defer func() { - if r := recover(); r != nil { - slog.Error("backup scheduler recovered from panic", "panic", r) - } - }() - - // Run immediately on start - if err := s.repo.RunBackup(); err != nil { - slog.Error("backup failed", "error", err) - } - for { - select { - case <-s.ticker.C: - if err := s.repo.RunBackup(); err != nil { - slog.Error("backup failed", "error", err) + var done bool + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("backup scheduler recovered from panic, will retry on next tick", "panic", r) + } + }() + select { + case <-s.ticker.C: + if err := s.repo.RunBackup(); err != nil { + slog.Error("backup failed", "error", err) + } + case <-s.manual: + if err := s.repo.RunBackup(); err != nil { + slog.Error("backup failed", "error", err) + } + case <-s.done: + done = true } - case <-s.manual: - if err := s.repo.RunBackup(); err != nil { - slog.Error("backup failed", "error", err) - } - case <-s.done: + }() + if done { return } } @@ -80,11 +80,8 @@ func (s *Scheduler) TriggerNow() { // Stop shuts down the goroutine cleanly. func (s *Scheduler) Stop() { s.ticker.Stop() - select { - case <-s.done: - // Already closed - default: + s.closeOnce.Do(func() { close(s.done) - } + }) s.wg.Wait() } \ No newline at end of file diff --git a/internal/backup/scheduler_test.go b/internal/backup/scheduler_test.go index c1dbf0b8c..168405b22 100644 --- a/internal/backup/scheduler_test.go +++ b/internal/backup/scheduler_test.go @@ -7,6 +7,24 @@ import ( "time" ) +func waitForBackup(t *testing.T, repo *Repository, timeout time.Duration) time.Time { + t.Helper() + deadline := time.After(timeout) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + last := repo.Status().LastBackupAt + if last != nil && !last.IsZero() { + return *last + } + case <-deadline: + t.Fatal("timeout waiting for backup") + } + } +} + func TestScheduler_TriggerNow(t *testing.T) { tmpDir := t.TempDir() rootDir := filepath.Join(tmpDir, "root") @@ -35,45 +53,30 @@ func TestScheduler_TriggerNow(t *testing.T) { t.Fatalf("Init failed: %v", err) } - // Create scheduler with a long interval so it won't fire naturally - scheduler := NewScheduler(repo, &cfg) - defer scheduler.Stop() - - // Add a file to back up so there's something to commit + // Add a file BEFORE starting the scheduler so there's something to back up if err := os.WriteFile(filepath.Join(rootDir, "test.txt"), []byte("content"), 0644); err != nil { t.Fatalf("failed to write test file: %v", err) } - // Wait for the initial run to complete using a channel - timeout := time.After(2 * time.Second) - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() + // Create scheduler with a long interval so it won't fire naturally + scheduler := NewScheduler(repo, cfg.Duration()) + defer scheduler.Stop() - for { - select { - case <-ticker.C: - if !repo.Status().LastBackupAt.IsZero() { - goto afterInitialRun - } - case <-timeout: - t.Fatal("timeout waiting for initial run") - } - } + // Wait for the initial run to complete + initialBackup := waitForBackup(t, repo, 2*time.Second) -afterInitialRun: // TriggerNow should not block scheduler.TriggerNow() // Wait for TriggerNow to be processed timeout2 := time.After(2 * time.Second) - ticker2 := time.NewTicker(50 * time.Millisecond) - defer ticker2.Stop() + tick2 := time.NewTicker(50 * time.Millisecond) + defer tick2.Stop() - initialBackup := repo.Status().LastBackupAt for { select { - case <-ticker2.C: - if !repo.Status().LastBackupAt.IsZero() && !repo.Status().LastBackupAt.Equal(initialBackup) { + case <-tick2.C: + if last := repo.Status().LastBackupAt; last != nil && !last.IsZero() && !last.Equal(initialBackup) { return // Success } case <-timeout2: @@ -110,7 +113,7 @@ func TestScheduler_Stop(t *testing.T) { t.Fatalf("Init failed: %v", err) } - scheduler := NewScheduler(repo, &cfg) + scheduler := NewScheduler(repo, cfg.Duration()) // Stop should block until goroutine finishes scheduler.Stop() @@ -147,28 +150,15 @@ func TestScheduler_RunsOnStart(t *testing.T) { t.Fatalf("Init failed: %v", err) } - // Create scheduler with very long interval - scheduler := NewScheduler(repo, &cfg) - defer scheduler.Stop() - - // Add a file to back up so there's something to commit + // Add a file BEFORE starting the scheduler so there's something to back up if err := os.WriteFile(filepath.Join(rootDir, "test.txt"), []byte("content"), 0644); err != nil { t.Fatalf("failed to write test file: %v", err) } - // Wait for the initial run to complete using a channel - timeout := time.After(2 * time.Second) - ticker := time.NewTicker(50 * time.Millisecond) - defer ticker.Stop() + // Create scheduler with very long interval + scheduler := NewScheduler(repo, cfg.Duration()) + defer scheduler.Stop() - for { - select { - case <-ticker.C: - if !repo.Status().LastBackupAt.IsZero() { - return // Success - } - case <-timeout: - t.Fatal("timeout waiting for initial run") - } - } + // Wait for the initial run to complete + waitForBackup(t, repo, 2*time.Second) } \ No newline at end of file diff --git a/internal/backup/status.go b/internal/backup/status.go index 3b958188f..78bf5ca5a 100644 --- a/internal/backup/status.go +++ b/internal/backup/status.go @@ -21,20 +21,24 @@ func (s *Status) SetSuccess(t time.Time) { func (s *Status) SetError(err string) { s.mu.Lock() defer s.mu.Unlock() - s.LastBackupAt = time.Time{} // Clear on error s.LastError = err } func (s *Status) Snapshot() StatusSnapshot { s.mu.RLock() defer s.mu.RUnlock() + var lastBackupAt *time.Time + if !s.LastBackupAt.IsZero() { + t := s.LastBackupAt + lastBackupAt = &t + } return StatusSnapshot{ - LastBackupAt: s.LastBackupAt, + LastBackupAt: lastBackupAt, LastError: s.LastError, } } type StatusSnapshot struct { - LastBackupAt time.Time `json:"lastBackupAt,omitempty"` - LastError string `json:"lastError,omitempty"` + LastBackupAt *time.Time `json:"lastBackupAt,omitempty"` + LastError string `json:"lastError,omitempty"` } diff --git a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx index 85bf27082..a8a40373c 100644 --- a/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -55,7 +55,7 @@ export default function BackupSettings() { pollingRef.current = null } } - }, [isPolling, loadStatus, lastBackupAt]) + }, [isPolling, loadStatus]) // Stop polling when lastBackupAt advances or an error occurs useEffect(() => { diff --git a/ui/leafwiki-ui/src/lib/api/backup.ts b/ui/leafwiki-ui/src/lib/api/backup.ts index 275e72a65..1a8d31033 100644 --- a/ui/leafwiki-ui/src/lib/api/backup.ts +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -1,4 +1,3 @@ -import { API_BASE_URL } from '../config' import { fetchWithAuth } from './auth' const BACKUP_STATUS_URL = '/api/admin/backup/status' @@ -13,7 +12,7 @@ export interface BackupStatusResponse { } export async function fetchBackupStatus(): Promise { - const res = await fetchWithAuth(`${API_BASE_URL}${BACKUP_STATUS_URL}`, { + const res = await fetchWithAuth(BACKUP_STATUS_URL, { credentials: 'include', }) return res as BackupStatusResponse @@ -23,4 +22,4 @@ export async function triggerBackupPush(): Promise { await fetchWithAuth(BACKUP_PUSH_URL, { method: 'POST', }) -} +} \ No newline at end of file From 3192fdd59e826d104a16db70f899ea1fb2ba2c22 Mon Sep 17 00:00:00 2001 From: Eduard Schwarzkopf <48969167+EduardSchwarzkopf@users.noreply.github.com> Date: Sun, 24 May 2026 17:19:41 +0200 Subject: [PATCH 54/54] chore: fix versions --- readme.md | 122 +++++++++++++++++++++++++----------------------------- 1 file changed, 57 insertions(+), 65 deletions(-) diff --git a/readme.md b/readme.md index db6bc6e53..3c69fdc62 100644 --- a/readme.md +++ b/readme.md @@ -300,38 +300,34 @@ If you are just getting started, the most important options are usually: ### CLI Flags -| Flag | Description | Default | Available since | -| -------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | -| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | -| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | -| `--port` | Port the server listens on | `8080` | – | -| `--data-dir` | Directory where data is stored | `./data` | – | -| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | -| `--public-access` | Allow public read-only access | `false` | – | -| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | -| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | -| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | -| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | -| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `--enable-http-remote-user` | Enable reverse-proxy authentication via HTTP header | `false` | v0.10.0 | -| `--http-remote-user-header-name` | HTTP header carrying the username from a trusted proxy | `Remote-User` | v0.10.0 | -| `--trusted-proxy-ips` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | -| `--http-remote-user-logout-url` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | -| `--git-backup` | Enable automated git backup to a remote repository | `false` | v0.10.0 | -| `--git-backup-remote` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.10.0 | -| `--git-backup-branch` | Git branch to push to | `main` | v0.10.0 | -| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | v0.10.0 | -| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | -| `--git-backup-interval` | Backup interval in minutes | `60` | v0.10.0 | -| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | v0.10.0 | -| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | v0.10.0 | +| Flag | Description | Default | Available since | +| ------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `--jwt-secret` | Secret used for signing JWTs (required) | – | – | +| `--host` | Host/IP address the server binds to | `127.0.0.1` | – | +| `--port` | Port the server listens on | `8080` | – | +| `--data-dir` | Directory where data is stored | `./data` | – | +| `--admin-password` | Initial admin password *(used only if no admin exists)* (required) | – | – | +| `--public-access` | Allow public read-only access | `false` | – | +| `--hide-link-metadata-section` | Hide link metadata section | `false` | – | +| `--inject-code-in-header` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `--custom-stylesheet` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${base-path}/custom.css` | `""` | v0.8.5 | +| `--allow-insecure` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `--access-token-timeout` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `--refresh-token-timeout` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `--disable-auth` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `--base-path` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `--max-asset-upload-size` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `--enable-revision` | Enable revision history / page history | `false` | v0.9.0 | +| `--enable-link-refactor` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `--max-revision-history` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `--git-backup` | Enable automated git backup to a remote repository | `false` | v0.11.0 | +| `--git-backup-remote` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.11.0 | +| `--git-backup-branch` | Git branch to push to | `main` | v0.11.0 | +| `--git-backup-author-name` | Git commit author name | `LeafWiki Backup` | v0.11.0 | +| `--git-backup-author-email` | Git commit author email | `backup@leafwiki.local` | v0.11.0 | +| `--git-backup-interval` | Backup interval in minutes | `60` | v0.11.0 | +| `--git-backup-ssh-key-path` | Path to SSH private key file | `""` | v0.11.0 | +| `--git-backup-ssh-key` | Raw SSH private key (PEM) | `""` | v0.11.0 | > When using the official Docker image, `LEAFWIKI_HOST` defaults to `0.0.0.0` if neither a `--host` flag nor `LEAFWIKI_HOST` is provided, as the container entrypoint sets this automatically. @@ -341,39 +337,35 @@ If you are just getting started, the most important options are usually: The same configuration options can also be provided via environment variables. This is especially useful in containerized or production environments. -| Variable | Description | Default | Available since | -| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | -| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1` | - | -| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | -| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | -| `LEAFWIKI_LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | `info` | - | -| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | -| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | -| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | -| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | -| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | -| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | -| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | -| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | -| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | -| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | -| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | -| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | -| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | -| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | -| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | -| `LEAFWIKI_ENABLE_HTTP_REMOTE_USER` | Enable reverse-proxy authentication via HTTP header | `false` | v0.10.0 | -| `LEAFWIKI_HTTP_REMOTE_USER_HEADER_NAME` | HTTP header carrying the username from a trusted proxy | `Remote-User` | v0.10.0 | -| `LEAFWIKI_TRUSTED_PROXY_IPS` | Comma-separated list of trusted proxy IPs/CIDRs (e.g. `127.0.0.1,172.18.0.0/16`) | `""` | v0.10.0 | -| `LEAFWIKI_HTTP_REMOTE_USER_LOGOUT_URL` | URL the frontend redirects to after logout in proxy-auth mode | `""` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup (local commits; optional remote push over SSH) | `false` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | v0.10.0 | -| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | v0.10.0 | +| Variable | Description | Default | Available since | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------- | +| `LEAFWIKI_HOST` | Host/IP address the server binds to | `127.0.0.1` | - | +| `LEAFWIKI_PORT` | Port the server listens on | `8080` | - | +| `LEAFWIKI_DATA_DIR` | Path to the data storage directory | `./data` | - | +| `LEAFWIKI_LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | `info` | - | +| `LEAFWIKI_ADMIN_PASSWORD` | Initial admin password *(used only if no admin exists yet)* (required) | – | - | +| `LEAFWIKI_JWT_SECRET` | Secret used to sign JWT tokens *(required)* | – | - | +| `LEAFWIKI_PUBLIC_ACCESS` | Allow public read-only access | `false` | - | +| `LEAFWIKI_HIDE_LINK_METADATA_SECTION` | Hide link metadata section | `false` | - | +| `LEAFWIKI_INJECT_CODE_IN_HEADER` | Raw HTML/JS code injected into tag (e.g., analytics, custom CSS) | `""` | v0.6.0 | +| `LEAFWIKI_CUSTOM_STYLESHEET` | Path to a `.css` file inside the data dir, served publicly as `/custom.css` or `${LEAFWIKI_BASE_PATH}/custom.css` | `""` | v0.8.5 | +| `LEAFWIKI_ALLOW_INSECURE` | ⚠️ Allows insecure HTTP usage for auth cookies (required for plain HTTP) | `false` | v0.7.0 | +| `LEAFWIKI_ACCESS_TOKEN_TIMEOUT` | Access token timeout duration (e.g. 24h, 15m) | `15m` | v0.7.0 | +| `LEAFWIKI_REFRESH_TOKEN_TIMEOUT` | Refresh token timeout duration (e.g. 168h, 7d) | `7d` | v0.7.0 | +| `LEAFWIKI_DISABLE_AUTH` | ⚠️ Disable authentication & authorization (internal networks only!) | `false` | v0.7.0 | +| `LEAFWIKI_BASE_PATH` | URL prefix when served behind a reverse proxy (e.g. /wiki) | `""` | v0.8.2 | +| `LEAFWIKI_MAX_ASSET_UPLOAD_SIZE` | Maximum size for asset uploads (e.g. `50MiB`, `50MB`, `52428800`) | `50MiB` | v0.8.5 | +| `LEAFWIKI_ENABLE_REVISION` | Enable revision history / page history | `false` | v0.9.0 | +| `LEAFWIKI_ENABLE_LINK_REFACTOR` | Enable link refactoring dialog and rewrite flow | `false` | v0.9.0 | +| `LEAFWIKI_MAX_REVISION_HISTORY` | Maximum revisions kept per page; `0` means unlimited | `100` | v0.9.0 | +| `LEAFWIKI_GIT_BACKUP` | Enable automated git backup (local commits; optional remote push over SSH) | `false` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_REMOTE` | SSH remote URL for the backup repository (optional; omit to use local-only git history) | `""` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_BRANCH` | Git branch to push to | `main` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_NAME` | Git commit author name | `LeafWiki Backup` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_AUTHOR_EMAIL` | Git commit author email | `backup@leafwiki.local` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_INTERVAL` | Backup interval in minutes | `60` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY_PATH` | Path to SSH private key file | `""` | v0.11.0 | +| `LEAFWIKI_GIT_BACKUP_SSH_KEY` | Raw SSH private key (PEM) | `""` | v0.11.0 | These environment variables override the default values and are especially useful in containerized or production environments.