diff --git a/backup-ui.js b/backup-ui.js index adeea0b..f78bff1 100644 --- a/backup-ui.js +++ b/backup-ui.js @@ -9,7 +9,7 @@ window.BackupUI=(()=>{ view.querySelector('#backup-import').onsubmit=async e=>{e.preventDefault();if(pending)return;pending=true;message.textContent='Backup entschlüsseln und Quellen prüfen …';try{const file=view.querySelector('#backup-file').files[0];if(!file||file.size>64*1024**2)throw Error('Backup-Datei fehlt oder ist größer als 64 MiB.');const r=await fetch('/api/v1/backup/inspect',{method:'POST',headers:{'Content-Type':'application/octet-stream','X-Athena-Deck':'1','X-Backup-Passphrase':btoa(String.fromCharCode(...new TextEncoder().encode(view.querySelector('#backup-import-password').value)))},body:file});const data=await r.json();if(!r.ok)throw Error(data.error);preview=data;view.querySelector('#backup-import-password').value='';showPreview(panel,data,message);message.textContent='Backup geprüft. Details und Zusatzdienste auswählen.';}catch(e){message.textContent=e.message;}finally{pending=false;}}; poll(view); } - function showPreview(panel,p,message){panel.innerHTML=`

Wiederherstellungsplan

${p.profiles.length} Profile · ${p.models.length} Modelldateien · ${size(p.download_bytes)} fehlende Gewichte · ${p.wireguard?'WireGuard enthalten':'Kein Deck-WireGuard'}

Modelle und Profile anzeigen

${p.profiles.map(esc).join(' · ')}

Zusatzdienste

${p.services.length?p.services.map(c=>``).join(''):'

Keine zugeordneten Container.

'}

Laufzeiten

llama.cpp: ${esc(p.runtimes.llama?.commit||'nicht eingerichtet')} · ComfyUI: ${p.runtimes.image?'vorhandene wiederverwenden / fehlende installieren':'nicht benötigt'} · TTS: ${p.runtimes.tts?'wiederherstellen':'nicht benötigt'}

${p.warnings.map(w=>`

${esc(w)}

`).join('')}${p.blockers.map(w=>`

${esc(w)}

`).join('')}

Danach gilt das bisherige Kennwort des Backups. Der aktuelle Zugang bleibt erhalten, wenn der Haken entfernt wird.

`; + function showPreview(panel,p,message){panel.innerHTML=`

Wiederherstellungsplan

${p.profiles.length} Profile · ${p.models.length} Modelldateien · ${size(p.download_bytes)} fehlende Gewichte · ${p.wireguard?'WireGuard enthalten':'Kein Deck-WireGuard'}

Modelle und Profile anzeigen

${p.profiles.map(esc).join(' · ')}

Zusatzdienste

${p.services.length?p.services.map(c=>``).join(''):'

Keine zugeordneten Container.

'}

Laufzeiten

llama.cpp: ${esc(p.runtimes.llama?.commit||'nicht eingerichtet')} · ComfyUI: ${p.runtimes.image?'vorhandene wiederverwenden / fehlende installieren':'nicht benötigt'} · TTS: ${p.runtimes.tts?'wiederherstellen':'nicht benötigt'} · LTX Original: ${p.runtimes.ltx_original?'wiederherstellen':'nicht benötigt'}

${p.warnings.map(w=>`

${esc(w)}

`).join('')}${p.blockers.map(w=>`

${esc(w)}

`).join('')}

Danach gilt das bisherige Kennwort des Backups. Der aktuelle Zugang bleibt erhalten, wenn der Haken entfernt wird.

`; panel.querySelector('#backup-restore').onclick=async()=>{if(pending)return;if(!panel.querySelector('#backup-confirm').checked){message.textContent='Bitte den angezeigten Wiederherstellungsplan bestätigen.';return;}pending=true;try{await request('restore',{id:p.id,services:[...panel.querySelectorAll('.backup-service:checked')].map(x=>x.value),confirm:true,restore_credentials:panel.querySelector('#backup-credentials').checked});panel.innerHTML='';preview=null;message.textContent='Wiederherstellung gestartet.';}catch(e){message.textContent=e.message;}finally{pending=false;}}; } async function poll(view){if(!view.querySelector('#backup-page'))return;const panel=view.querySelector('#backup-progress');try{const r=await fetch('/api/v1/backup');if(!r.ok)throw Error('Restore-Status nicht erreichbar.');const {job,preview:p,checkpoints=[]}=await r.json();const saved=view.querySelector('#backup-checkpoints');saved.innerHTML='

Lokale Rückfallstände

'+checkpoints.map(name=>`

`).join('');saved.querySelectorAll('[data-checkpoint]').forEach(b=>b.onclick=async()=>{try{const r=await request('checkpoint',{name:b.dataset.checkpoint});const p=await r.json();preview=p;showPreview(view.querySelector('#backup-preview'),p,view.querySelector('#backup-message'));}catch(e){view.querySelector('#backup-message').textContent=e.message;}});if(p&&!preview){preview=p;showPreview(view.querySelector('#backup-preview'),p,view.querySelector('#backup-message'));}if(!job){panel.innerHTML='

Wiederherstellung

Noch kein Restore gestartet.

';}else{const running=job.state==='running';panel.innerHTML=`

${job.state==='complete'?'Alles wiederhergestellt':job.state==='partial'?'Wiederherstellung mit offenen Punkten':'Wiederherstellung · '+esc(job.state)}

${esc(job.phase)}

${job.completed}/${job.total} Schritte

${job.current?.total?`

${size(job.current.bytes||0)} / ${size(job.current.total)}${job.current.bytes_per_second?' · '+(job.current.bytes_per_second/1024**2).toFixed(1)+' MiB/s':''}${job.current.eta_seconds!=null?' · noch etwa '+Math.ceil(job.current.eta_seconds/60)+' Minuten':''}

`:''}${running?'':''}`;if(running)panel.querySelector('#backup-cancel').onclick=async()=>{try{await request('cancel',{});view.querySelector('#backup-message').textContent='Abbruch angefordert; laufender Container-Schritt wird sicher beendet.';}catch(e){view.querySelector('#backup-message').textContent=e.message;}};if(job.state==='complete'&&jobId!==job.id){jobId=job.id;applyDeckTheme(job.theme);}}}catch(e){view.querySelector('#backup-message').textContent=e.message;}setTimeout(()=>poll(view),1500);} diff --git a/deploy/docker_backup.py b/deploy/docker_backup.py index fd74809..658bbfe 100644 --- a/deploy/docker_backup.py +++ b/deploy/docker_backup.py @@ -89,7 +89,9 @@ def validate(c): targets.add(target) if m.get('index')!=i or type(m.get('read_only')) is not bool or type(m.get('was_file')) is not bool:raise ValueError('Ungültige Volume-Einstellung.') rel=m.get('deck_path') - if rel is not None and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Dateien dürfen nur als bekannte, lesende Modell-/Token-Volumes eingebunden werden.') + media={'video/original-work/remote-inputs/deskweb':('/data/inputs',False),'video/original-work/outputs':('/data/outputs',True)} + native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file'] + if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.') for name,data in m.get('files',{}).items(): if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.') if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.') @@ -150,7 +152,10 @@ def restore(manager,c): if m['deck_path']=='video/comfy-client-token' and not source.exists(): import secrets source.parent.mkdir(parents=True,exist_ok=True);source.write_text(secrets.token_urlsafe(48)+'\n');source.chmod(0o600);os.chown(source,65534,65534) - elif m['deck_path']!='video/comfy-client-token':source.mkdir(parents=True,exist_ok=True) + elif m['deck_path']!='video/comfy-client-token': + source.mkdir(parents=True,exist_ok=True) + if m['deck_path'].startswith('video/original-work/'): + is_input=m['deck_path'].endswith('/deskweb');os.chown(source,1000 if is_input else 65534,65534);source.chmod(0o2770 if is_input else 0o2750) elif m['was_file']: if 'file' not in m['files']:raise ValueError('Benötigte Volume-Datei fehlt im Backup.') source.write_bytes(base64.b64decode(m['files']['file']));source.chmod(0o600) diff --git a/test_backup.py b/test_backup.py index b993176..6003352 100644 --- a/test_backup.py +++ b/test_backup.py @@ -82,6 +82,11 @@ class ContainerTests(unittest.TestCase): for change in (lambda x:x.update(unsupported=True),lambda x:x.update(network='host'),lambda x:x['ports']['8000/tcp'][0].update(HostIp='0.0.0.0'),lambda x:x.update(mounts=[dict(index=0,target='/var/run/docker.sock',deck_path=None,read_only=True,was_file=True,files={})])): bad=copy.deepcopy(c);change(bad) with self.assertRaises(ValueError):validate_container(bad) + def test_only_deskweb_can_mount_its_specific_writable_media_folder(self): + c=self.config();c.update(name='ltx-deskweb',mounts=[dict(index=0,target='/data/inputs',deck_path='video/original-work/remote-inputs/deskweb',read_only=False,was_file=False,files={})]);validate_container(c) + for change in (lambda x:x.update(name='other-ui'),lambda x:x['mounts'][0].update(deck_path='models'),lambda x:x['mounts'][0].update(deck_path='video/original-work'),lambda x:x['mounts'][0].update(target='/data/outputs')): + bad=copy.deepcopy(c);change(bad) + with self.assertRaises(ValueError):validate_container(bad) def test_never_replaces_existing_foreign_container(self): with tempfile.TemporaryDirectory() as d: manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))