diff --git a/.env.example b/.env.example index bf2fb3071..bca894264 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,34 @@ 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= + +# 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= diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index 90d358576..eb3e61aa7 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,15 @@ 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-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: LEAFWIKI_HOST @@ -76,6 +88,15 @@ 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_SSH_KNOWN_HOSTS + LEAFWIKI_GIT_BACKUP_INTERVAL `); err != nil { panic(err) } @@ -131,6 +152,15 @@ type cliFlags struct { httpRemoteUserHeader *string trustedProxyIPs *string httpRemoteUserLogoutURL *string + gitBackup *bool + gitBackupAuthorName *string + gitBackupAuthorEmail *string + gitBackupRemote *string + gitBackupBranch *string + gitBackupSSHKeyPath *string + gitBackupSSHKey *string + gitBackupSSHKnownHosts *string + gitBackupInterval *int } func registerFlags(fs *flag.FlagSet) *cliFlags { @@ -157,6 +187,15 @@ 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)"), + 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)"), } } @@ -199,11 +238,23 @@ 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) + 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 + // Note: git-backup-remote is now optional (local-only mode is supported) + args := flag.Args() if len(args) > 0 { switch args[0] { @@ -271,6 +322,34 @@ 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, + SSHKnownHosts: gitBackupSSHKnownHosts, + 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 ce9e520f1..fba9452aa 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.0 require ( github.com/dustin/go-humanize v1.0.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 @@ -16,22 +17,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 @@ -39,17 +51,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.54.0 // indirect golang.org/x/sys v0.45.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 79d4e3560..14bffd5bf 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,8 +17,12 @@ 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= @@ -19,6 +34,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= @@ -33,6 +58,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= @@ -48,12 +75,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= @@ -69,8 +103,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= @@ -85,7 +125,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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= @@ -98,6 +140,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= @@ -114,18 +158,30 @@ golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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= diff --git a/internal/backup/config.go b/internal/backup/config.go new file mode 100644 index 000000000..a7e2cb09a --- /dev/null +++ b/internal/backup/config.go @@ -0,0 +1,22 @@ +package backup + +import "time" + +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) + 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 diff --git a/internal/backup/gitignore.go b/internal/backup/gitignore.go new file mode 100644 index 000000000..71e1d4441 --- /dev/null +++ b/internal/backup/gitignore.go @@ -0,0 +1,29 @@ +package backup + +import ( + "os" + "path/filepath" +) + +const gitignoreContent = `# LeafWiki runtime files – do not commit +*.db +*.db-journal +*.db-shm +*.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 { + return nil + } else if !os.IsNotExist(err) { + return err + } + // os.WriteFile already respects the process umask — no manual umask needed + 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..9048f23b3 --- /dev/null +++ b/internal/backup/repo.go @@ -0,0 +1,515 @@ +package backup + +import ( + "fmt" + "log/slog" + "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" + "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 + 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) { + if cfg.RootDir == "" { + return nil, fmt.Errorf("RootDir is required") + } + 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") + } + + 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, + repoDir: repoDir, + status: &Status{}, + } + + // Try to open existing repo + repo, err := gogit.PlainOpen(repoDir) + if err == nil { + slog.Debug("opened existing git repo", "repoDir", repoDir) + r.repo = repo + return r, nil + } + + 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.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) + } + + return r, nil +} + +// makeInitialCommit creates the first commit with root/ and assets/ directories. +func (r *Repository) makeInitialCommit() error { + slog.Debug("makeInitialCommit: starting") + + wt, err := r.repo.Worktree() + if err != nil { + return err + } + + // Compute relative paths from repo root + 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(r.repoDir, r.cfg.AssetsDir) + if err != nil { + return fmt.Errorf("failed to compute relative path for assets: %w", err) + } + 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.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 hasFilesFlag, err := hasFiles(r.cfg.RootDir); err == nil && hasFilesFlag { + stagedFiles = true + 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.Debug("makeInitialCommit: root dir is empty, skipping") + } + } else { + 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.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 hasFilesFlag, err := hasFiles(r.cfg.AssetsDir); err == nil && hasFilesFlag { + stagedFiles = true + 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.Debug("makeInitialCommit: assets dir is empty, skipping") + } + } else { + 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 + // The first RunBackup will create the commit when there's actual content + if !stagedFiles { + slog.Debug("makeInitialCommit: no files found in root or assets, skipping initial commit") + return nil + } + + // Check if there's anything to commit + status, err := wt.Status() + if err != nil { + return err + } + if status.IsClean() { + slog.Debug("makeInitialCommit: working tree is clean after staging, nothing to commit") + return nil // Nothing to commit + } + slog.Debug("makeInitialCommit: staged file count", "count", len(status)) + + 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) + } + slog.Debug("makeInitialCommit: initial commit created", "hash", commit.String()) + + // Push to remote if configured + if r.cfg.RemoteURL != "" { + slog.Debug("makeInitialCommit: scheduling initial commit push to remote (scheduler will push on next cycle)", "remote", r.cfg.RemoteURL) + } else { + slog.Debug("makeInitialCommit: no remote configured, skipping push") + } + + return nil +} + +// hasFiles returns true if the directory contains any files (recursive). +// Returns an error if the directory cannot be read. +func hasFiles(dir string) (bool, error) { + entries, err := os.ReadDir(dir) + if err != nil { + slog.Debug("hasFiles: failed to read directory", "dir", dir, "error", err) + return false, err + } + for _, entry := range entries { + if !entry.IsDir() { + return true, nil + } + // Check subdirectory contents recursively + if hasFilesRecursive, err := hasFiles(filepath.Join(dir, entry.Name())); hasFilesRecursive { + return true, nil + } else if err != nil { + return false, err + } + } + return false, nil +} + +// 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: + 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 { + 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.Debug("RunBackup: failed to get worktree", "error", errMsg) + r.status.SetError(errMsg) + return fmt.Errorf("failed to get worktree: %w", err) + } + + 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(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) + return fmt.Errorf("failed to compute relative path for assets: %w", err) + } + 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.Debug("RunBackup: failed to stage root dir", "error", errMsg) + r.status.SetError(errMsg) + return fmt.Errorf("failed to stage root dir: %w", err) + } + slog.Debug("RunBackup: staged root dir", "path", rootRel) + } else { + 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.Debug("RunBackup: failed to stage assets dir", "error", errMsg) + r.status.SetError(errMsg) + return fmt.Errorf("failed to stage assets dir: %w", err) + } + slog.Debug("RunBackup: staged assets dir", "path", assetsRel) + } else { + 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.Debug("RunBackup: failed to get working tree status", "error", errMsg) + r.status.SetError(errMsg) + return fmt.Errorf("failed to get status: %w", err) + } + + // 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.Debug("RunBackup: working tree status checked", "hasStagedChanges", staged, "totalStatusEntries", len(status)) + + if !staged { + slog.Info("backup skipped - no staged changes in content directories") + r.status.SetSuccess(time.Now()) + 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.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.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, + Email: r.cfg.AuthorEmail, + When: time.Now(), + }, + }) + 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.Debug("RunBackup: commit skipped - empty tree") + r.status.SetSuccess(time.Now()) + return nil + } + errMsg := fmt.Errorf("failed to commit: %w", err).Error() + slog.Debug("RunBackup: commit failed", "error", errMsg) + r.status.SetError(errMsg) + return fmt.Errorf("failed to commit: %w", err) + } + slog.Debug("RunBackup: commit created", "hash", commit.String(), "message", commitMsg) + + // Push to remote + if r.cfg.RemoteURL != "" { + 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.Debug("RunBackup: push failed", "error", err) + r.status.SetError(err.Error()) + return fmt.Errorf("push failed: %w", err) + } + } else { + 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()) + return nil +} + +// push pushes the given commit hash to the configured remote. +func (r *Repository) push(commitHash string) error { + slog.Debug("push: starting", "commit", commitHash, "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch) + + // Build SSH auth + slog.Debug("push: building SSH auth", "sshKeyPath", r.cfg.SSHKeyPath, "hasInlineKey", r.cfg.SSHKey != "") + auth, err := r.buildSSHAuth() + if err != nil { + slog.Debug("push: SSH auth build failed", "error", err) + return fmt.Errorf("failed to build SSH auth: %w", err) + } + 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.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", + 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) + } + slog.Debug("push: remote 'origin' created", "url", r.cfg.RemoteURL) + } else { + remoteURLs := remote.Config().URLs + slog.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.Debug("push: local HEAD resolved", "hash", localHead.Hash().String(), "branch", localHead.Name().Short()) + + // 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.Debug("push: could not remove stale remote tracking ref", "ref", trackingRef.String(), "error", rmErr) + } else { + 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.Debug("push: pushing", "refSpec", string(refSpec), "localBranch", localBranchRef, "remoteBranch", r.cfg.Branch) + err = remote.Push(&gogit.PushOptions{ + Auth: auth, + RefSpecs: []config.RefSpec{refSpec}, + }) + 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.Info("backup skipped - already up-to-date on " + r.cfg.Branch + " at remote URL: " + r.cfg.RemoteURL) + return nil + } + 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.Info("git push succeeded", "remote", r.cfg.RemoteURL, "branch", r.cfg.Branch, "commit", commitHash) + 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 != "" { + slog.Debug("buildSSHAuth: using inline SSH key") + privateKey = []byte(r.cfg.SSHKey) + } else if 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.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.Debug("buildSSHAuth: SSH key file read successfully", "path", r.cfg.SSHKeyPath, "size", len(privateKey)) + } else { + 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.Error("failed to parse SSH key", "error", err, "path", r.cfg.SSHKeyPath) + return nil, fmt.Errorf("failed to parse SSH key: %w", err) + } + slog.Debug("buildSSHAuth: SSH key parsed successfully", "keyType", signer.PublicKey().Type()) + + auth := &ssh.PublicKeys{ + User: "git", + Signer: signer, + } + + // 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 != "" { + 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 { + 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)") + auth.HostKeyCallback = sshcrypto.InsecureIgnoreHostKey() + } + return auth, nil +} + +// Status returns a snapshot of the last backup time and any error. +func (r *Repository) Status() StatusSnapshot { + return r.status.Snapshot() +} diff --git a/internal/backup/repo_test.go b/internal/backup/repo_test.go new file mode 100644 index 000000000..35d66f490 --- /dev/null +++ b/internal/backup/repo_test.go @@ -0,0 +1,387 @@ +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) + } + if status.LastBackupAt == nil { + t.Error("expected LastBackupAt to be set after backup run, got nil") + } +} + +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") + } +} + +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 diff --git a/internal/backup/scheduler.go b/internal/backup/scheduler.go new file mode 100644 index 000000000..931b5868c --- /dev/null +++ b/internal/backup/scheduler.go @@ -0,0 +1,87 @@ +package backup + +import ( + "log/slog" + "sync" + "time" +) + +// Minimum interval to prevent time.NewTicker(0) panic +const minInterval = 1 * time.Minute + +// Scheduler runs periodic git backups. +type Scheduler struct { + repo *Repository + ticker *time.Ticker + manual chan struct{} + done chan struct{} + wg sync.WaitGroup + closeOnce sync.Once +} + +// NewScheduler creates and starts the background goroutine. +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 + } + s := &Scheduler{ + repo: repo, + ticker: time.NewTicker(interval), + 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() + return s +} + +func (s *Scheduler) run() { + defer s.wg.Done() + + for { + 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 + } + }() + if 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() + 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 new file mode 100644 index 000000000..168405b22 --- /dev/null +++ b/internal/backup/scheduler_test.go @@ -0,0 +1,164 @@ +package backup + +import ( + "os" + "path/filepath" + "testing" + "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") + 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", + IntervalMinutes: 10, + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // 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) + } + + // Create scheduler with a long interval so it won't fire naturally + scheduler := NewScheduler(repo, cfg.Duration()) + defer scheduler.Stop() + + // Wait for the initial run to complete + initialBackup := waitForBackup(t, repo, 2*time.Second) + + // TriggerNow should not block + scheduler.TriggerNow() + + // Wait for TriggerNow to be processed + timeout2 := time.After(2 * time.Second) + tick2 := time.NewTicker(50 * time.Millisecond) + defer tick2.Stop() + + for { + select { + case <-tick2.C: + if last := repo.Status().LastBackupAt; last != nil && !last.IsZero() && !last.Equal(initialBackup) { + return // Success + } + case <-timeout2: + t.Fatal("timeout waiting for 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", + IntervalMinutes: 10, + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + scheduler := NewScheduler(repo, cfg.Duration()) + + // Stop should block until goroutine finishes + 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", + IntervalMinutes: 600, + } + + repo, err := Init(cfg) + if err != nil { + t.Fatalf("Init failed: %v", err) + } + + // 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) + } + + // Create scheduler with very long interval + scheduler := NewScheduler(repo, cfg.Duration()) + defer scheduler.Stop() + + // 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 new file mode 100644 index 000000000..78bf5ca5a --- /dev/null +++ b/internal/backup/status.go @@ -0,0 +1,44 @@ +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() + var lastBackupAt *time.Time + if !s.LastBackupAt.IsZero() { + t := s.LastBackupAt + lastBackupAt = &t + } + return StatusSnapshot{ + LastBackupAt: lastBackupAt, + LastError: s.LastError, + } +} + +type StatusSnapshot struct { + LastBackupAt *time.Time `json:"lastBackupAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} 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 e19ed047e..d3944e6f8 100644 --- a/internal/wiki/wiki.go +++ b/internal/wiki/wiki.go @@ -30,6 +30,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 { @@ -59,6 +60,7 @@ type Wiki struct { links *links.LinkService tags *tags.TagsService props *properties.PropertiesService + backupRoutes *wikibackup.Routes log *slog.Logger } @@ -437,7 +439,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, @@ -449,6 +451,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. diff --git a/readme.md b/readme.md index 116b3fbeb..3c69fdc62 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 @@ -122,6 +116,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`. @@ -249,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 @@ -305,26 +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 | +| 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. @@ -334,32 +337,39 @@ 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 | +| 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. -> 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`. @@ -383,6 +393,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: @@ -423,6 +511,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 @@ -430,30 +522,35 @@ 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. --- ### 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. diff --git a/ui/leafwiki-ui/src/components/UserToolbar.tsx b/ui/leafwiki-ui/src/components/UserToolbar.tsx index a611c7cf6..049157dc3 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 new file mode 100644 index 000000000..a8a40373c --- /dev/null +++ b/ui/leafwiki-ui/src/features/backup/BackupSettings.tsx @@ -0,0 +1,184 @@ +import { Button } from '@/components/ui/button' +import { CloudUpload, Loader2, TriangleAlert } 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 + +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) +} + +export default function BackupSettings() { + const { + enabled, + lastBackupAt, + lastError, + isLoading, + isPolling, + loadStatus, + triggerPush, + stopPolling, + } = useBackupStore() + + const pollingRef = useRef | null>(null) + const lastBackupAtRef = useRef(null) + + // reset toolbar actions on mount + useToolbarActions() + 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 or an error occurs + useEffect(() => { + if (isPolling) { + const hasNewBackup = + lastBackupAt !== null && + lastBackupAtRef.current !== lastBackupAt + const hasError = lastError !== '' + if (hasNewBackup || hasError) { + stopPolling() + if (hasError) { + toast.error(`Backup failed: ${lastError}`) + } else { + toast.success('Backup completed successfully') + } + } + } + }, [lastBackupAt, lastError, isPolling, stopPolling]) + + const handlePush = async () => { + try { + await triggerPush() + toast.success('Backup triggered') + } catch { + toast.error('Failed to trigger backup') + } + } + + return ( + <> +
+

Backup Settings

+ + {isLoading && ( +
+
+ + Loading backup status… +
+
+ )} + + {!isLoading && ( + <> +
+

Git Backup

+

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

+ +
+ Status + {enabled ? ( + + Enabled + + ) : ( + + Disabled + + )} +
+ + {enabled && ( + <> +
+ Last backup + + {isPolling ? ( + + + Waiting for backup to complete… + + ) : ( + formatDate(lastBackupAt) + )} + +
+ + {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. +

+
+ +
+
+ )} + + )} +
+ + ) +} 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..018e0a491 --- /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]) +} 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..1a8d31033 --- /dev/null +++ b/ui/leafwiki-ui/src/lib/api/backup.ts @@ -0,0 +1,25 @@ +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 fetchWithAuth(BACKUP_STATUS_URL, { + credentials: 'include', + }) + return res as BackupStatusResponse +} + +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..b4f5d1b3f --- /dev/null +++ b/ui/leafwiki-ui/src/stores/backup.ts @@ -0,0 +1,54 @@ +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 }) + }, +}))