diff --git a/dist/backupper-0.1.10-x86_64-1.txz.sha256 b/dist/backupper-0.1.10-x86_64-1.txz.sha256 deleted file mode 100644 index 9e7057e..0000000 --- a/dist/backupper-0.1.10-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -85a85800cab7ef561448c8f96d586bd4fd98bd57813241793128a14d2a0d8044 backupper-0.1.10-x86_64-1.txz diff --git a/dist/backupper-0.1.10-x86_64-1.txz b/dist/backupper-0.1.11-x86_64-1.txz similarity index 55% rename from dist/backupper-0.1.10-x86_64-1.txz rename to dist/backupper-0.1.11-x86_64-1.txz index d76c630..13c6e34 100644 Binary files a/dist/backupper-0.1.10-x86_64-1.txz and b/dist/backupper-0.1.11-x86_64-1.txz differ diff --git a/dist/backupper-0.1.11-x86_64-1.txz.sha256 b/dist/backupper-0.1.11-x86_64-1.txz.sha256 new file mode 100644 index 0000000..2b7957b --- /dev/null +++ b/dist/backupper-0.1.11-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +d23bc7b1db4f95e701c1480917590383059eb5c7da6b18a5fc4d4089916f9b24 backupper-0.1.11-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index fb95f48..9dc2610 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 0.1.11 +- Add checkbox folder browsers for backup sources and persistent multi-selection for restores. + ### 0.1.10 - Verify repository edits against the daemon and handle form submission without losing changes. diff --git a/internal/api/server.go b/internal/api/server.go index cddafe6..abe7a76 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -36,6 +36,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret) mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /v1/runs", s.runs) + mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob) mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun) mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository) @@ -116,6 +117,14 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) } +func (s *Server) directories(w http.ResponseWriter, r *http.Request) { + items, err := s.service.BrowseDirectories(r.URL.Query().Get("path")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, items) +} func (s *Server) runJob(w http.ResponseWriter, r *http.Request) { run, err := s.service.EnqueueJob(r.PathValue("id"), 10) diff --git a/internal/platform/browser.go b/internal/platform/browser.go new file mode 100644 index 0000000..b659112 --- /dev/null +++ b/internal/platform/browser.go @@ -0,0 +1,75 @@ +package platform + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" +) + +type DirectoryEntry struct { + Name string `json:"name"` + Path string `json:"path"` +} + +var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"} + +func BrowseDirectories(path string) ([]DirectoryEntry, error) { + clean, err := validateBrowsePath(path) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(clean) + if err != nil { + return nil, err + } + result := make([]DirectoryEntry, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { + result = append(result, DirectoryEntry{Name: entry.Name(), Path: filepath.Join(clean, entry.Name())}) + } + } + sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) }) + return result, nil +} + +func BrowseRoots() []DirectoryEntry { + result := make([]DirectoryEntry, 0, len(browseRoots)) + for _, root := range browseRoots { + if info, err := os.Stat(root); err == nil && info.IsDir() { + result = append(result, DirectoryEntry{Name: root, Path: root}) + } + } + return result +} + +func validateBrowsePath(path string) (string, error) { + if path == "" || path == "/" { + return "", errors.New("validation: select an allowed browse root") + } + clean := filepath.Clean(path) + if !filepath.IsAbs(clean) { + return "", errors.New("validation: browse path must be absolute") + } + allowed := false + for _, root := range browseRoots { + if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) { + allowed = true + break + } + } + if !allowed { + return "", errors.New("validation: browse path is outside allowed Unraid storage roots") + } + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return "", err + } + for _, root := range browseRoots { + if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) { + return resolved, nil + } + } + return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots") +} diff --git a/internal/platform/browser_test.go b/internal/platform/browser_test.go new file mode 100644 index 0000000..eb6af57 --- /dev/null +++ b/internal/platform/browser_test.go @@ -0,0 +1,11 @@ +package platform + +import "testing" + +func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) { + for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} { + if _, err := validateBrowsePath(path); err == nil { + t.Fatalf("unsafe browse path accepted: %q", path) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index f9409e1..b99a042 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -63,6 +63,13 @@ func (s *Service) Stop() { s.queue.Stop() } func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config } func (s *Service) Runs() []model.Run { return s.queue.Snapshot() } +func (s *Service) BrowseDirectories(path string) ([]platform.DirectoryEntry, error) { + if path == "" || path == "/" { + return platform.BrowseRoots(), nil + } + return platform.BrowseDirectories(path) +} + func (s *Service) LastRun(jobID string) time.Time { var latest time.Time for _, run := range s.queue.Snapshot() { diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 3fa4646..73b8e95 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 0.1.11 +- Add checkbox folder browsers for backup sources and persistent multi-selection for restores. + ### 0.1.10 - Verify repository edits against the daemon and handle form submission without losing changes. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 3056b38..32a34af 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,10 +9,10 @@ Tag="backup restic snapshots restore" - +
-

Backupper 0.1.10

Encrypted Restic backups for Unraid

+

Backupper 0.1.11

Encrypted Restic backups for Unraid

Connecting...
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index 862cbb0..6a1f891 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -63,6 +63,11 @@ .bu-table-actions { align-items: flex-start; min-width: 310px; } .bu-table-actions .bu-button { flex: 0 0 auto; } .bu-warning { color: var(--bu-bad) !important; display: block; font-size: 13px; font-weight: 700; margin-top: 5px; } +.bu-browser { border: 1px solid var(--bu-border); border-radius: 6px; margin-top: 10px; max-height: 430px; overflow: auto; padding: 12px; } +.bu-browser-head { align-items: center; display: flex; gap: 10px; justify-content: space-between; margin-bottom: 10px; } +.bu-selection-list { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; } +.bu-selection { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); font-size: 12px; padding: 4px 8px; } +.bu-browser input[type="checkbox"], .bu-table input[type="checkbox"] { accent-color: var(--bu-accent); height: 20px; width: 20px; } #backupper-app .bu-button { appearance: none; diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 56df8a7..25c348c 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -6,7 +6,7 @@ const health = document.getElementById('bu-health'); const toast = document.getElementById('bu-toast'); const tooltip = document.getElementById('bu-tooltip'); - const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [] }; + const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], sourceBrowserPath: '/', sourceDirectories: [] }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -15,7 +15,7 @@ cron: 'Zeitplan im Cron-Format: Minute Stunde Tag Monat Wochentag. Beispiel: 0 2 * * * startet täglich um 02:00 Uhr; 30 3 * * 0 jeden Sonntag um 03:30 Uhr.', consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', - sources: 'Eine Quelle pro Zeile. Pfad-Beispiel: /mnt/user/Media. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', + sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', @@ -59,6 +59,8 @@ 'refresh': 'Lädt Laufstatus und Historie erneut vom Backupper-Daemon.', 'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.', 'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.', + 'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.', + 'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.', }; const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); @@ -97,6 +99,7 @@ 'delete-repo': 'Löscht nur die Backupper-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.', 'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.', 'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.', + 'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.', 'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Cleanup und Neustart gestoppter Workloads werden trotzdem versucht.', }; return dynamic[name] || `${label} ausführen.`; @@ -213,7 +216,9 @@ } function jobForm(job = {}) { - const source = (job.sources || []).map(s => s.path || `@${s.workloadId}`).join('\n'); + state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path); + const source = (job.sources || []).filter(s=>s.workloadId).map(s=>`@${s.workloadId}`).join('\n'); + state.sourceBrowserPath = '/'; state.sourceDirectories = []; const options = state.config.repositories.map(r => ``).join(''); content.innerHTML = `

${job.id ? 'Edit' : 'New'} backup job

@@ -221,12 +226,34 @@ - +

Backup folders

Select one or more folders. Open navigates into a folder without selecting it.
+
${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; enhanceTooltips(content); + loadSourceDirectories('/'); + } + + function renderSourceBrowser() { + const host = document.getElementById('bu-source-browser'); + if (!host) return; + const selected = new Set(state.backupSelections); + const rows = state.sourceDirectories.map(item => `${esc(item.name)}
${esc(item.path)}${button('Open',`open-source-dir:${encodeURIComponent(item.path)}`)}`).join(''); + const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`${esc(path)}`).join('') : 'No folders selected.'; + host.innerHTML = `
Folder: ${esc(state.sourceBrowserPath)}
${state.sourceBrowserPath!=='/'?button('Up','source-up'):''}
${chosen}
${rows||''}
SelectFolder
No subfolders found.
`; + enhanceTooltips(host); + } + + async function loadSourceDirectories(path) { + try { + state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); + state.sourceBrowserPath = path; + renderSourceBrowser(); + } catch (error) { + notify(error.message, true); + } } function renderRepositories() { @@ -257,12 +284,14 @@ } function renderFileBrowser() { + const selected = new Set(state.restoreIncludes); const rows = state.files.map(item => { const path = item.path || `${state.browserPath.replace(/\/$/,'')}/${item.name||''}`; const open = item.type === 'dir' ? button('Open',`open-dir:${encodeURIComponent(path)}`) : ''; - return `${esc(item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; + return `${esc(item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; }).join(''); - return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${state.browserPath!=='/'?button('Up','browser-up'):''}${button('Restore selected','restore-selected','primary')}
${rows}
TypeNameSize
`; + const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`${esc(path)}`).join('') : 'Nothing selected.'; + return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${button('Select current folder','select-current-restore')}${state.browserPath!=='/'?button('Up','browser-up'):''}${button(`Restore selected (${state.restoreIncludes.length})`,'restore-selected','primary')}
${chosen}
${rows}
SelectTypeNameSize
`; } function renderRestore() { @@ -303,10 +332,13 @@ if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a} queued`); return load(); } if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; } if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); } - if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); } + if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.restoreIncludes=[]; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); } + if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); } + if (name === 'source-up') { const parent=state.sourceBrowserPath==='/'?'/':state.sourceBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadSourceDirectories(parent==='/mnt'?'/':parent); } if (name === 'open-dir') { state.browserPath=decodeURIComponent(a); state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } if (name === 'browser-up') { state.browserPath=state.browserPath.replace(/\/$/,'').split('/').slice(0,-1).join('/')||'/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } - if (name === 'restore-selected') { state.restoreIncludes=[...document.querySelectorAll('.bu-file-select:checked')].map(x=>x.value); if(!state.restoreIncludes.length) throw new Error('Select at least one file or directory'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); } + if (name === 'select-current-restore') { if(!state.restoreIncludes.includes(state.browserPath)) state.restoreIncludes.push(state.browserPath); return render(); } + if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Select at least one file or directory'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); } if (name === 'delete-job') { if(confirm('Delete this job?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; } if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' copy'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); } if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Repository is still referenced by a job'); if(confirm('Delete this repository?')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; } @@ -317,7 +349,9 @@ async function submit(kind) { if (kind === 'submit-job') { const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id')); - const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v}),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))}; + const manual=lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v}); + const selected=state.backupSelections.map(path=>({path})); + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:[...selected,...manual].filter((item,index,all)=>all.findIndex(other=>(other.path||`@${other.workloadId}`)===(item.path||`@${item.workloadId}`))===index),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') { @@ -339,6 +373,16 @@ document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); + content.addEventListener('change', e => { + if (e.target.classList.contains('bu-source-select')) { + state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value); + renderSourceBrowser(); + } + if (e.target.classList.contains('bu-file-select')) { + state.restoreIncludes = e.target.checked ? [...new Set([...state.restoreIncludes,e.target.value])] : state.restoreIncludes.filter(path=>path!==e.target.value); + render(); + } + }); content.addEventListener('submit', e => { const form = e.target.closest('form'); if (!form) return;