diff --git a/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 b/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 deleted file mode 100644 index 064db2d..0000000 --- a/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -83eb2bd7c3c60ca5b973755355b016262a5b7020d18eb45bf4aa000c1f533053 urbm-2026.06.14.r004-x86_64-1.txz diff --git a/dist/urbm-2026.06.14.r004-x86_64-1.txz b/dist/urbm-2026.06.14.r005-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.14.r004-x86_64-1.txz rename to dist/urbm-2026.06.14.r005-x86_64-1.txz index dc0c5ca..83d094e 100644 Binary files a/dist/urbm-2026.06.14.r004-x86_64-1.txz and b/dist/urbm-2026.06.14.r005-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.14.r005-x86_64-1.txz.sha256 b/dist/urbm-2026.06.14.r005-x86_64-1.txz.sha256 new file mode 100644 index 0000000..5137013 --- /dev/null +++ b/dist/urbm-2026.06.14.r005-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +0d947c4dd0796e1ae0a48aa4f0199d1581f338fe093116879028a7f71f4a12cd urbm-2026.06.14.r005-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 63bf332..898bf1d 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,16 +2,15 @@ - + - + ]> -### 2026.06.14.r004 -- Create a dedicated repository subdirectory from the repository name for every newly configured local, SMB, NFS, or SFTP destination. -- Preserve existing repository locations and safely separate managed network mount points from their repository subdirectories. +### 2026.06.14.r005 +- Remove automatic repository subdirectory creation. The selected or entered path is used directly as the Restic repository location. ### 2026.06.14.r003 - Use URBM consistently as the Unraid navigation and page name instead of the translated Backup / Sicherung labels. diff --git a/internal/model/validate.go b/internal/model/validate.go index 7f33172..1cbc9c1 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -166,12 +166,6 @@ func ValidateRepository(r Repository) error { if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) { return errors.New("managed mount requires remote and absolute mountPoint") } - location := filepath.Clean(r.Location) - mountPoint := filepath.Clean(r.Mount.MountPoint) - relative, err := filepath.Rel(mountPoint, location) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return errors.New("managed repository location must be inside mountPoint") - } } if r.Type == RepositorySFTP && r.CredentialRef != "" { knownHosts := r.Options["knownHostsPath"] diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 69bb2d2..31ebcda 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -79,14 +79,3 @@ func TestRetentionRequiresAtLeastOneRule(t *testing.T) { t.Fatalf("age retention rejected: %v", err) } } - -func TestManagedRepositoryLocationMustRemainInsideMountPoint(t *testing.T) { - repo := Repository{SchemaVersion: 1, ID: "repo", Name: "NAS", Type: RepositorySMB, Location: "/mnt/remotes/nas/NAS", PasswordRef: "password", Mount: &MountConfig{Managed: true, Remote: "//nas/backups", MountPoint: "/mnt/remotes/nas"}} - if err := ValidateRepository(repo); err != nil { - t.Fatalf("repository subdirectory rejected: %v", err) - } - repo.Location = "/mnt/remotes/other/NAS" - if err := ValidateRepository(repo); err == nil { - t.Fatal("repository location outside mountPoint accepted") - } -} diff --git a/internal/platform/mounts.go b/internal/platform/mounts.go index 2a60965..356b5b6 100644 --- a/internal/platform/mounts.go +++ b/internal/platform/mounts.go @@ -22,7 +22,6 @@ type MountManager struct { type MountedRepository struct { Repository model.Repository Mounted bool - MountPoint string CredentialFile string } @@ -33,12 +32,7 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun } if repo.Mount == nil || !repo.Mount.Managed { if _, err := os.Stat(repo.Location); err != nil { - if !os.IsNotExist(err) { - return result, fmt.Errorf("connectivity: external mount unavailable: %w", err) - } - if _, parentErr := os.Stat(filepath.Dir(repo.Location)); parentErr != nil { - return result, fmt.Errorf("connectivity: external mount unavailable: %w", parentErr) - } + return result, fmt.Errorf("connectivity: external mount unavailable: %w", err) } return result, nil } @@ -46,11 +40,6 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun if !strings.HasPrefix(point, "/mnt/remotes/") && !strings.HasPrefix(point, "/mnt/disks/") { return result, errors.New("validation: managed mountPoint must be below /mnt/remotes or /mnt/disks") } - repositoryPath := filepath.Clean(repo.Location) - relative, err := filepath.Rel(point, repositoryPath) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return result, errors.New("validation: managed repository location must be inside its mountPoint") - } if err := os.MkdirAll(point, 0700); err != nil { return result, err } @@ -97,7 +86,7 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun return result, fmt.Errorf("connectivity: mount repository: %w", err) } result.Mounted = true - result.MountPoint = point + result.Repository.Location = point return result, nil } @@ -108,5 +97,5 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e if !mounted.Mounted { return nil } - return exec.CommandContext(ctx, "umount", mounted.MountPoint).Run() + return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run() } diff --git a/internal/platform/mounts_test.go b/internal/platform/mounts_test.go deleted file mode 100644 index 6792c07..0000000 --- a/internal/platform/mounts_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package platform - -import ( - "context" - "path/filepath" - "testing" - - "git.casaderoll.de/michael/urbm/internal/model" -) - -func TestExternalMountAllowsNewRepositorySubdirectory(t *testing.T) { - base := t.TempDir() - repo := model.Repository{Type: model.RepositorySMB, Location: filepath.Join(base, "Photos")} - manager := &MountManager{} - - mounted, err := manager.Prepare(context.Background(), repo) - if err != nil { - t.Fatalf("new repository subdirectory rejected: %v", err) - } - if mounted.Repository.Location != repo.Location { - t.Fatalf("location = %q, want %q", mounted.Repository.Location, repo.Location) - } -} - -func TestExternalMountRejectsMissingBaseDirectory(t *testing.T) { - repo := model.Repository{Type: model.RepositoryNFS, Location: filepath.Join(t.TempDir(), "missing", "Photos")} - manager := &MountManager{} - - if _, err := manager.Prepare(context.Background(), repo); err == nil { - t.Fatal("missing external mount base accepted") - } -} diff --git a/internal/service/service.go b/internal/service/service.go index ad1b08f..dc773f7 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -226,11 +226,6 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize } defer s.mounts.Cleanup(context.Background(), mounted) if initialize { - if mounted.Repository.Type != model.RepositorySFTP { - if err := os.MkdirAll(mounted.Repository.Location, 0750); err != nil { - return fmt.Errorf("repository: create destination directory: %w", err) - } - } return s.restic.Init(ctx, mounted.Repository) } _, err = s.restic.Snapshots(ctx, mounted.Repository) diff --git a/plugin/urbm.plg b/plugin/urbm.plg index ec75efb..5ac5a67 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,16 +2,15 @@ - + ]> -### 2026.06.14.r004 -- Create a dedicated repository subdirectory from the repository name for every newly configured local, SMB, NFS, or SFTP destination. -- Preserve existing repository locations and safely separate managed network mount points from their repository subdirectories. +### 2026.06.14.r005 +- Remove automatic repository subdirectory creation. The selected or entered path is used directly as the Restic repository location. ### 2026.06.14.r003 - Use URBM consistently as the Unraid navigation and page name instead of the translated Backup / Sicherung labels. diff --git a/webgui/URBM.page b/webgui/URBM.page index 6c5b1a7..7ca1bc2 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.14.r004

Restic Backup Manager für Unraid

+ +

URBM 2026.06.14.r005

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index d147754..b36f331 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -27,7 +27,7 @@ keepWeekly: 'Zusätzliche wöchentliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', - location: 'Bei neuen Repositorys ist dies der Basisordner. URBM ergänzt automatisch einen sicheren Unterordner aus dem Repository-Namen. Beispiel: /mnt/user/Backups und Fotos Zuhause ergeben /mnt/user/Backups/Fotos-Zuhause.', + location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.', password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.', managed: 'Wenn aktiv, mountet URBM SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.', remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.', @@ -117,15 +117,6 @@ const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId; const volatileLocation = (repo) => repo.type === 'local' && (/^\/tmp(?:\/|$)/.test(repo.location) || /^\/run(?:\/|$)/.test(repo.location)); - const repositoryFolderName = (name) => String(name || '') - .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') - .replace(/ß/g, 'ss').replace(/[^a-zA-Z0-9._-]+/g, '-') - .replace(/^[._-]+|[._-]+$/g, '').slice(0, 64); - const repositoryChildLocation = (base, name) => { - const folder = repositoryFolderName(name); - if (!folder) throw new Error('Der Repository-Name muss mindestens einen Buchstaben oder eine Zahl enthalten.'); - return `${String(base || '').replace(/\/+$/,'')}/${folder}`; - }; const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : ''; const actionTooltip = (action, label) => { const [name] = action.split(':'); @@ -429,12 +420,10 @@ content.innerHTML = `

Repository ${repo.id?'bearbeiten':'erstellen'}

- ${repo.id?'':'

Automatischer Repository-Unterordner

URBM erstellt im gewählten Basisordner einen Unterordner aus dem Repository-Namen.

Endgültiger Speicherort: Name und Basisordner eingeben

'}
Restic-Verschlüsselung

Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten.':'Bewahre dieses Passwort sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.'}

${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; enhanceTooltips(content); renderRepositorySpecific(repo.type || 'local', repo); - updateRepositoryLocationPreview(); } function repositoryLocationField(label, value, placeholder) { @@ -445,25 +434,15 @@ const host = document.getElementById('bu-repository-specific'); if (!host) return; if (type === 'local') { - host.innerHTML = `${repositoryLocationField(repo.id?'Lokaler Repository-Ordner':'Lokaler Basisordner',repo.location,'/mnt/user/backups')}
`; + host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}
`; renderRepositoryBrowser(); } else if (type === 'smb' || type === 'nfs') { const managed = repo.mount?.managed === true; - host.innerHTML = `
${type.toUpperCase()}-Ziel
${repositoryLocationField(repo.id?'Bereits eingebundener Repository-Ordner':'Bereits eingebundener Basisordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; + host.innerHTML = `
${type.toUpperCase()}-Ziel
${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; } else { - host.innerHTML = `
SFTP-Ziel
${repositoryLocationField(repo.id?'SFTP-Repository-Adresse':'SFTP-Basisadresse',repo.location,'backup@192.168.1.20:/srv/backups')}
`; + host.innerHTML = `
SFTP-Ziel
${repositoryLocationField('SFTP-Repository-Adresse',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}
`; } enhanceTooltips(host); - updateRepositoryLocationPreview(); - } - - function updateRepositoryLocationPreview() { - const form=document.getElementById('bu-repo-form'); const output=document.getElementById('bu-repository-final-location'); - if(!form||!output||form.elements.id.value) return; - const type=form.elements.type.value; const managed=(type==='smb'||type==='nfs')&&form.elements.managed?.checked; - const base=managed?form.elements.mountPoint?.value:form.elements.location?.value; - try { output.textContent=base&&form.elements.name.value?repositoryChildLocation(base,form.elements.name.value):'Name und Basisordner eingeben'; } - catch(error) { output.textContent=error.message; } } function renderRepositoryBrowser() { @@ -576,7 +555,7 @@ if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); } if (name === 'open-repository-dir') { return loadRepositoryDirectories(decodeURIComponent(a)); } if (name === 'repository-up') { const parent=state.repositoryBrowserPath==='/'?'/':state.repositoryBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRepositoryDirectories(parent==='/mnt'?'/':parent); } - if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; updateRepositoryLocationPreview(); return; } + if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; return; } 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 === 'select-current-restore') { if(!state.restoreIncludes.includes(state.browserPath)) state.restoreIncludes.push(state.browserPath); return render(); } @@ -605,10 +584,8 @@ let location=managed?value('mountPoint'):value('location'); if(!location) throw new Error(managed?'Ein Einhängepunkt ist erforderlich.':'Ein Repository-Speicherort ist erforderlich.'); if(managed&&!value('remote')) throw new Error('Für eine verwaltete Einbindung ist eine Netzwerkfreigabe erforderlich.'); - const isNew=!value('id'); const baseLocation=location; - if(isNew) location=repositoryChildLocation(baseLocation,value('name')); const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):''; - const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:baseLocation}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}}; + const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}}; if(!value('id')&&!f.get('password')) throw new Error('Für ein neues Repository ist ein Passwort erforderlich.'); if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return; if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})}); @@ -637,7 +614,6 @@ const managed=fieldset?.querySelector('.bu-managed-mount'); const external=fieldset?.querySelector('.bu-external-mount'); if(managed) managed.hidden=!e.target.checked; if(external) external.hidden=e.target.checked; - updateRepositoryLocationPreview(); } if (e.target.name === 'scheduleMode') { const weekdays=e.target.closest('.bu-schedule')?.querySelector('.bu-weekdays'); @@ -660,9 +636,6 @@ renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker'); } }); - content.addEventListener('input', e => { - if (e.target.closest('#bu-repo-form') && ['name','location','mountPoint'].includes(e.target.name)) updateRepositoryLocationPreview(); - }); content.addEventListener('submit', e => { const form = e.target.closest('form'); if (!form) return;