diff --git a/README.md b/README.md index 9776e54..e33847b 100644 --- a/README.md +++ b/README.md @@ -140,3 +140,11 @@ sichtbaren Hinweis. Bei GGUF-Dateinamen wird die Endung für die Repositorysuche entfernt. Findet die Chat-Suche nichts, werden zusätzlich als `conversational` gekennzeichnete GGUF-Repositories ohne `text-generation`-Tag geprüft. Das ist keine globale Suche nach beliebigen Dateinamen im Hub. + +### Hugging-Face-Zugang + +Unter **Einstellungen → Zugang → Hugging Face** einen eigenen `hf_…`-Token mit Leserechten speichern, ersetzen oder entfernen. Bei freigabepflichtigen Modellen vorher auf der Modellkarte mit demselben HF-Konto Zugang beantragen. Deck akzeptiert keine Lizenzbedingungen automatisch. Siehe [HF-Zugangsrechte](https://huggingface.co/docs/hub/models-gated) und [Token](https://huggingface.co/docs/hub/security-tokens). + +Der Token wird ausschließlich serverseitig unter `models/huggingface.json` im Deck-Zustandsverzeichnis gespeichert (0600, unverschlüsselt); Zustandsbackups entsprechend schützen. Er gilt für Katalogabfragen, Modell- und Komponentendownloads aller Kategorien. HTTP-Weiterleitungen auf andere Hosts erhalten keinen Authorization-Header. Entfernen verhindert neue authentifizierte Anfragen, beendet aber keinen bereits autorisierten Download. Download abbrechen bei Bedarf separat nutzen. + +Interne API (nur angemeldete Administratorsitzung): `GET /api/v1/huggingface` liefert ausschließlich `{configured: boolean}`; `POST` mit `{token: "hf_…"}` speichert/ersetzt, mit `{token: null}` entfernt. POST benötigt `X-Athena-Deck: 1`. Kein API-Endpunkt liefert den gespeicherten Token zurück. HTTP 401/403 von HF ergeben Hinweise auf Token, Leserechte und Modellfreigabe; die tatsächliche Freigabe wird beim Download geprüft. diff --git a/access-ui.js b/access-ui.js index 117d723..da1d926 100644 --- a/access-ui.js +++ b/access-ui.js @@ -11,5 +11,13 @@ async function accessPage(){ document.querySelector('#token-generate').onclick=()=>{document.querySelector('#new-token').value=accessToken();document.querySelector('#access-message').textContent='Neuen Token vor dem Speichern kopieren.'}; document.querySelector('#token-show').onclick=()=>{const input=document.querySelector('#new-token');input.type=input.type==='password'?'text':'password';document.querySelector('#token-show').textContent=input.type==='password'?'Token anzeigen':'Token verbergen';}; document.querySelector('#token-copy').onclick=async()=>{try{const token=document.querySelector('#new-token').value;if(!token)throw Error();await navigator.clipboard.writeText(token);document.querySelector('#access-message').textContent='Token kopiert. Bitte sicher speichern.';}catch{document.querySelector('#access-message').textContent='Kopieren nicht möglich. Bitte einen eigenen Token aus dem Passwortmanager verwenden.'}}; + const hub=document.createElement('section');hub.className='card';hub.innerHTML=`

Hugging Face · Modelldownloads

Zugang wird geprüft …

Für private oder freigabepflichtige Modelle: Hugging-Face-Token erstellen ↗ mit Leserechten für das gewünschte Repository. Die Freigabe beantragst du auf dessen Modellkarte mit demselben Konto.

Separater Zugang für Hugging Face, nicht der Deck-API-Token. Speicherung auf Athena in einer Datei mit Zugriff nur für den Dienstbenutzer (0600), unverschlüsselt. Der gespeicherte Wert wird nie an den Browser zurückgegeben. Bereits laufende Downloads können nach Entfernen weiterlaufen.

`; + document.querySelector('#access-page').insertBefore(hub,document.querySelector('#access-message')); + const hfStatus=hub.querySelector('#hf-status'); + const describe=s=>s.configured?'Token gespeichert. Ob die Modellfreigabe und Leserechte passen, prüft Hugging Face beim Download.':'Kein Hugging-Face-Token gespeichert. Öffentliche Downloads bleiben möglich.'; + try{const r=await fetch('/api/v1/huggingface');if(!r.ok)throw Error();hfStatus.textContent=describe(await r.json());}catch{hfStatus.textContent='Hugging-Face-Zugangsstatus nicht erreichbar.';} + async function saveHub(token){hub.querySelectorAll('button').forEach(b=>b.disabled=true);try{const r=await fetch('/api/v1/huggingface',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({token})}),s=await r.json();if(!r.ok)throw Error(s.error||'Speichern fehlgeschlagen.');hfStatus.textContent=describe(s);}catch(e){hfStatus.textContent=e.message;}finally{hub.querySelector('#hf-token').value='';hub.querySelectorAll('button').forEach(b=>b.disabled=false);}} + hub.querySelector('#hf-form').onsubmit=e=>{e.preventDefault();saveHub(hub.querySelector('#hf-token').value.trim());}; + hub.querySelector('#hf-remove').onclick=()=>saveHub(null); document.querySelector('#logout').onclick=async()=>{const r=await fetch('/api/v1/logout',{method:'POST',headers:{'X-Athena-Deck':'1'}});if(r.ok||r.status===401)location.reload();else document.querySelector('#access-message').textContent='Abmelden fehlgeschlagen. Bitte erneut versuchen.'}; } diff --git a/catalog-ui.js b/catalog-ui.js index e6bd438..72808eb 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -66,7 +66,7 @@ window.CatalogUI = (() => { const queued=file && (state.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===detail.repo&&j.revision===detail.revision&&j.file===file.name); const exists = file && installed(file); const fitsDisk = file && Number.isFinite(state.free_bytes) && file.size <= state.free_bytes - 10*1024**3; - el('hub-download').disabled = !file || !!detail.gated || busy || queued || exists || !fitsDisk; + el('hub-download').disabled = !file || (detail.gated && !detail.auth_configured) || busy || queued || exists || !fitsDisk; el('hub-download').textContent = starting ? 'Download wird vorbereitet …' : exists ? 'Bereits in Bibliothek' : queued ? 'Bereits im Downloadplan' : state.job?.state==='downloading' ? 'In Warteschlange einreihen' : 'Ausgewählte Datei herunterladen'; el('hub-selection').textContent = file ? `${file.name} · ${size(file.size)}${!fitsDisk ? ' · Speicherplatz noch nicht bestätigt oder Reserve unterschritten' : ''}` : 'Wähle eine Datei aus der Liste.'; if(el('hub-fit-result'))el('hub-fit-result').innerHTML=file?.assessment?`${escape(fitText(file.assessment))}

${escape(file.assessment.explanation||'')}

${(file.assessment.gpus||[]).map(g=>`

${escape(g.name)}: ${size(g.capacity_bytes)} nutzbar insgesamt · ${size(g.free_bytes)} aktuell frei (nach Reserve)

`).join('')}${file.assessment.sampled_at?`

Messung: ${escape(new Date(file.assessment.sampled_at*1000).toLocaleTimeString('de-DE'))}

`:''}${file.assessment.deck_ram_limit_bytes?`

Deck-RAM-Limit: ${size(file.assessment.deck_ram_limit_bytes)}

`:''}`:'Wähle eine Datei für die Speicherprüfung. Gesamtbedarf der Laufzeit noch nicht berechnet.'; @@ -83,7 +83,7 @@ window.CatalogUI = (() => { el('hub-files').innerHTML = '

Modelldetails werden geladen …

'; try { const result = await api('/files?'+new URLSearchParams({repo,kind})); if (!root.isConnected || sequence !== detailSequence) return; detail = result; - el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Wähle eine Datei für die Speicherprüfung.
${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Downloads mit Hugging-Face-Zugangsdaten werden noch nicht unterstützt.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; + el('hub-files').innerHTML = `MODELLDETAILS

${escape(repo)}

${detail.gated ? 'Zugang beschränkt' : 'Öffentlich'}${detail.files.length} unterstützte Dateien

Vision: ${escape(detail.capabilities?.vision||'Unbekannt')} · Tool-Calling: ${escape(detail.capabilities?.tools||'Unbekannt')}. Hub-Hinweise, keine Laufzeitgarantie; fehlende Angaben bedeuten nicht „nein“.

Lizenz: ${escape(detail.license || 'Nicht angegeben')} · Modellkarte lesen ↗

Wähle eine Datei für die Speicherprüfung.
${detail.gated ? '

Dieses Repository benötigt eine Freigabe. Freigabe auf der Modellkarte beantragen. Deinen Hugging-Face-Token unter Einstellungen → Zugang hinterlegen. Ein gespeicherter Token ersetzt keine Modellfreigabe.

' : ''}

Variante oder Datei auswählen

Einzeldatei-Download auf Athena. Geteilte Modelle und Bild-/Audio-/Video-Pipelines benötigen weitere Dateien; dies installiert noch keinen vollständigen Worker.

Feste Quellversion

${escape(detail.revision)}

`; el('hub-file-filter').oninput = renderFiles; el('hub-format').onchange = renderFiles; renderFiles(); el('hub-download').onclick = async () => { const chosen = el('hub-variants').querySelector('input:checked'); if (!chosen || starting) return; diff --git a/catalog.py b/catalog.py index e3df169..3590c7a 100644 --- a/catalog.py +++ b/catalog.py @@ -10,6 +10,7 @@ import time import uuid import urllib.parse import urllib.request +import urllib.error KINDS={'chat':'text-generation','image':'text-to-image','audio':'text-to-speech','stt':'automatic-speech-recognition','music':'text-to-audio','voice':'audio-to-audio','video':'text-to-video'} @@ -21,13 +22,23 @@ def safe_url(url): class Redirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): - return super().redirect_request(req,fp,code,msg,headers,safe_url(newurl)) + redirected=super().redirect_request(req,fp,code,msg,headers,safe_url(newurl)) + if redirected and urllib.parse.urlsplit(req.full_url).netloc != urllib.parse.urlsplit(newurl).netloc: + redirected.remove_header('Authorization') + return redirected -def remote(url): - return urllib.request.build_opener(Redirect()).open(urllib.request.Request(safe_url(url),headers={'User-Agent':'Athena-Deck/0.5'}),timeout=20) +def remote(url,token=None): + headers={'User-Agent':'Athena-Deck/0.5'} + if token and urllib.parse.urlsplit(safe_url(url)).netloc=='huggingface.co':headers['Authorization']='Bearer '+token + try: + return urllib.request.build_opener(Redirect()).open(urllib.request.Request(safe_url(url),headers=headers),timeout=20) + except urllib.error.HTTPError as exc: + code=exc.code;exc.close() + messages={401:'Hugging Face: Token fehlt oder ist ungültig. Unter Einstellungen → Zugang prüfen.',403:'Hugging Face verweigert den Zugriff. Modellfreigabe auf der Modellkarte beantragen und Leserechte des Tokens prüfen.',404:'Hugging-Face-Repository oder Datei nicht gefunden; bei privaten Repositories Token und Leserechte prüfen.'} + raise ValueError(messages.get(code,'Hugging-Face-Anfrage fehlgeschlagen (HTTP '+str(code)+').')) from None -def metadata(path): - with remote('https://huggingface.co'+path) as r: +def metadata(path,token=None): + with remote('https://huggingface.co'+path,token) as r: raw=r.read(8*1024*1024+1) if len(raw)>8*1024*1024:raise ValueError('Metadaten zu groß; Repository wird noch nicht unterstützt.') return json.loads(raw) @@ -71,6 +82,8 @@ def capabilities(model): class Catalog: def __init__(self,root): + from hub_auth import HubAuth + self.hub_auth=HubAuth(Path(root)/'huggingface.json') self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event() self.cache={};self.cache_lock=threading.Lock();self.history=[];self.pending=[];self.closing=False;self.samples=[] path=self.root/'downloads.json' @@ -85,6 +98,13 @@ class Catalog: self.history.append(dict(id='import-'+entry.parent.name,repo=x['repo'],file=x['file'],kind=x['kind'],state='complete',bytes=x['size'],total=x['size'],created_at=x.get('downloaded_at'),error=None)) except (OSError,ValueError,KeyError):pass if self.history:self._save_history() + def hub_credentials(self,token): + with self.lock: + self.hub_auth.save(token) + with self.cache_lock:self.cache.clear() + return self.hub_auth.status() + def _metadata(self,path): + return metadata(path,self.hub_auth.token()) def _save_history(self): self.root.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.root/'downloads.tmp';temp.write_text(json.dumps(self.history));temp.replace(self.root/'downloads.json') @@ -119,16 +139,16 @@ class Catalog: direct=repo_id(url.path.strip('/')) elif '/' in q:direct=repo_id(q) if direct: - item=metadata('/api/models/'+direct) + item=self._metadata('/api/models/'+direct) return dict(models=[dict(repo=item['id'],downloads=item.get('downloads'),gated=item.get('gated',False),created_at=item.get('createdAt'),capabilities=capabilities(item))],base_only=base_only,scanned=1,notice='Direkt ausgewähltes Repository: Kategorie- und Basismodellfilter werden für diesen Treffer nicht angewendet.') if q.lower().endswith('.gguf'): q=q[:-5];notice='GGUF-Dateiname als Repository-Suchbegriff verwendet. Die Datei wählst du anschließend in den Modelldetails.' query=dict(search=q,filter='gguf' if purpose=='projector' else KINDS[kind],limit=100 if base_only else 20,sort='createdAt' if sort=='newest' else 'downloads',direction=-1) query['expand']=['downloads','createdAt','gated','cardData','tags','pipeline_tag'] - rows=metadata('/api/models?'+urllib.parse.urlencode(query,doseq=True)) + rows=self._metadata('/api/models?'+urllib.parse.urlencode(query,doseq=True)) if not rows and q and kind=='chat' and purpose=='model': fallback=dict(query,filter='gguf',expand=['downloads','createdAt','gated','cardData','tags','pipeline_tag']) - candidates=metadata('/api/models?'+urllib.parse.urlencode(fallback,doseq=True)) + candidates=self._metadata('/api/models?'+urllib.parse.urlencode(fallback,doseq=True)) rows=[x for x in candidates if x.get('pipeline_tag')=='text-generation' or 'conversational' in (x.get('tags') or [])] if rows:notice='GGUF-Sprachmodelle ohne reguläres Kategorie-Tag gefunden. Bei aktiver Basismodellauswahl bleiben Quantisierungen ausgeblendet.' scanned=len(rows) @@ -139,7 +159,7 @@ class Catalog: with self.cache_lock: cached=self.cache.get(repo) if cached and time.monotonic()-cached[0]<300:return cached[1] - data=metadata('/api/models/'+repo+'?blobs=true') + data=self._metadata('/api/models/'+repo+'?blobs=true') revision=data.get('sha','') if not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Keine feste Repository-Version verfügbar.') files=[] @@ -149,7 +169,7 @@ class Catalog: size=f.get('size',f.get('lfs',{}).get('size')) if not isinstance(size,int) or size<1:continue files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256'))) - result=dict(capabilities=capabilities(data),repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo) + result=dict(auth_configured=self.hub_auth.status()['configured'],capabilities=capabilities(data),repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo) with self.cache_lock: if len(self.cache)>=64:self.cache.pop(next(iter(self.cache))) self.cache[repo]=(time.monotonic(),result) @@ -170,7 +190,7 @@ class Catalog: if len(self.pending)>=20:raise ValueError('Warteschlange voll (20 Dateien).') with self.cache_lock:self.cache.pop(repo,None) data=self.files(repo) - if data['gated']:raise ValueError('Zugangsbeschränkte Modelle werden noch nicht unterstützt.') + if data['gated'] and not self.hub_auth.token():raise ValueError('Dieses Repository benötigt eine Freigabe und einen Hugging-Face-Token unter Einstellungen → Zugang.') if revision!=data['revision']:raise ValueError('Repository wurde geändert. Dateiliste neu laden.') item=next((x for x in data['files'] if x['name']==filename),None) if not item:raise ValueError('Datei nicht verfügbar.') @@ -200,7 +220,7 @@ class Catalog: try: digest=hashlib.sha256();received=0 url='https://huggingface.co/'+data['repo']+'/resolve/'+data['revision']+'/'+urllib.parse.quote(item['name'],safe='/') - with remote(url) as r, partial.open('wb') as out: + with remote(url,self.hub_auth.token()) as r, partial.open('wb') as out: while True: if self.cancel.is_set():raise InterruptedError() chunk=r.read(1024*1024) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index f97781f..670a713 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -13,7 +13,7 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \ WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock -COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py /app/ +COPY api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ COPY stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ diff --git a/deploy/install.py b/deploy/install.py index 6746fa3..f4df60f 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' -FILES = ['deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] +FILES = ['deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] def run(*args, check=True, interactive=False): diff --git a/hub_auth.py b/hub_auth.py new file mode 100644 index 0000000..e3559d3 --- /dev/null +++ b/hub_auth.py @@ -0,0 +1,33 @@ +"""Server-side Hub credential, never included in API status or job records.""" +import json +import os +from pathlib import Path +import re +import tempfile +import threading + +class HubAuth: + def __init__(self,path): + self.path=Path(path) + self.lock=threading.RLock() + def token(self): + with self.lock: + try:return json.loads(self.path.read_text())['token'] + except FileNotFoundError:return None + def status(self): + return {'configured':bool(self.token())} + def save(self,token): + if token is not None and (not isinstance(token,str) or not re.fullmatch(r'hf_[A-Za-z0-9]{10,252}',token)): + raise ValueError('Bitte einen gültigen Hugging-Face-Token (hf_…) eingeben.') + with self.lock: + if token is None: + self.path.unlink(missing_ok=True) + return + self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) + fd,name=tempfile.mkstemp(prefix='.hub-',dir=self.path.parent) + try: + with os.fdopen(fd,'w') as out: + json.dump({'token':token},out);out.flush();os.fsync(out.fileno()) + os.replace(name,self.path) + finally: + Path(name).unlink(missing_ok=True) diff --git a/network/install_remote.py b/network/install_remote.py index dfc8b3b..8c7d8b7 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' BASE = Path('/opt/athena-deck') -FILES = {'deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} +FILES = {'deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','image_runtime.py','image_encoder_node.py','docker_support.py','docker-ui.js','deploy/image-requirements.lock','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py', 'runtime-ui.js', 'catalog.py','hub_auth.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'} def run(*args, **kwargs): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/profiles-ui.js b/profiles-ui.js index b389a6b..edcdb25 100644 --- a/profiles-ui.js +++ b/profiles-ui.js @@ -20,11 +20,11 @@ window.ProfilesUI=(()=>{ el('profile-editor').innerHTML='

Komponenten werden geprüft …

'; try{ const data=await api('profiles/components?'+new URLSearchParams({model_id:profile.model_id}));if(!root.isConnected||sequence!==panelSequence)return; - el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

Bildlaufzeit: ${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'} →

${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB

`).join('')||'

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine passende Datei für Textencoder und VAE. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.

`:''}

`; + el('profile-editor').innerHTML=`

Komponenten · ${e(profile.name)}

${e(data.message)}

${data.supported?`

Quelle: Modellkarte und Lizenz ↗ · ${e(data.license||'Lizenz nicht angegeben')}

Bildlaufzeit: ${e(data.runtime)} · ${data.runtime_installed?'installiert':'noch nicht eingerichtet'} →

${data.requirements.map(r=>`

${e(r.label)}

${r.candidates.map((f,i)=>`

${e(f.name)} · ${(f.size/1024**3).toFixed(2)} GiB

`).join('')||'

Keine passende Quelldatei verfügbar.

'}
`).join('')}

Lade je eine passende Datei für Textencoder und VAE. Downloads erscheinen im Reiter Downloads. Danach Bibliothek aktualisieren, Dateien auswählen und die Zuordnung speichern. Es startet kein Modell.

`:''}

`; const missing=data.requirements?.filter(r=>!r.available.length)||[]; if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{ const b=el('components-download-all');b.disabled=true;let count=0; - try{for(const r of missing){const f=r.candidates.find(f=>!f.gated);if(!f)throw Error('Keine frei herunterladbare Datei für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'image'});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';} + try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine frei herunterladbare Datei für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:'image'});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';} catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;} };} el('components-close').onclick=()=>el('profile-editor').replaceChildren(); @@ -57,7 +57,7 @@ window.ProfilesUI=(()=>{ const row=document.createElement('div');row.className='note'; row.innerHTML=`

${e(f.name)} · ${(f.size/1024**2).toFixed(1)} MiB

`;target.append(row); const button=row.querySelector('button'),status=row.querySelector('span'); - function update(){const entry=projectors.find(x=>x.repo===detail.repo&&x.revision===detail.revision&&x.file===f.name);button.textContent=entry?'Im Profil auswählen':'Herunterladen';button.disabled=!!detail.gated;return entry;} + function update(){const entry=projectors.find(x=>x.repo===detail.repo&&x.revision===detail.revision&&x.file===f.name);button.textContent=entry?'Im Profil auswählen':'Herunterladen';button.disabled=!!detail.gated&&!detail.auth_configured;return entry;} update();button.onclick=async()=>{button.disabled=true;try{ await refreshLibrary();if(!live(n))return;const entry=update(); if(entry){select.value=entry.id;status.textContent=' Ausgewählt. Profil speichern, um die Zuordnung zu übernehmen.';return;} diff --git a/profiles.py b/profiles.py index f448af1..51bbdff 100644 --- a/profiles.py +++ b/profiles.py @@ -135,7 +135,7 @@ class Profiles: try: self._component(model,role,item['id']);available.append(item) except ValueError:pass - candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated']) for name in info['files'] for f in source['files'] if f['name']==name] + candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name] requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates)) return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(), message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.') diff --git a/server.py b/server.py index 12e9425..f3d4d83 100644 --- a/server.py +++ b/server.py @@ -289,6 +289,7 @@ class Handler(BaseHTTPRequestHandler): data=self.server.profiles.status() for p in data['profiles']:p['execution']=self.execution_setup(p.get('model') or {},p) return self.respond(data) + if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status()) if self.path == '/api/v1/hardware': return self.respond(self.server.hardware.snapshot()) if self.path.startswith('/api/v1/runtime'): @@ -343,6 +344,13 @@ class Handler(BaseHTTPRequestHandler): return self.login() if not self.authenticated(): return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401) + if self.path == '/api/v1/huggingface': + try: + data=self.read_json() + if set(data)!={'token'}:raise ValueError('Ungültige Hugging-Face-Einstellung.') + return self.respond(self.server.catalog.hub_credentials(data['token'])) + except ValueError as exc:return self.respond({'error':str(exc)},400) + except Exception:return self.respond({'error':'Hugging-Face-Zugang konnte nicht gespeichert werden.'},503) if self.path in ('/api/v1/auth/password','/api/v1/auth/token'): return self.change_credentials('password' if self.path.endswith('/password') else 'token') if self.path == '/api/v1/logout': diff --git a/test_auth.py b/test_auth.py index a24841f..83f1bec 100644 --- a/test_auth.py +++ b/test_auth.py @@ -97,6 +97,15 @@ class AccessAPITests(unittest.TestCase): code,_,headers=self.request('/api/v1/login',{'password':password}) self.assertEqual(code,200) return headers['Set-Cookie'].split(';')[0] + def test_hub_credentials_are_session_only_and_never_returned(self): + cookie=self.setup_login();path='/api/v1/huggingface';secret='hf_'+'a'*30 + for value in ({}, {'token':self.token}): + self.assertEqual(self.request(path,**value)[0],401) + self.assertEqual(self.request(path,{'token':secret},**value)[0],401) + code,body,_=self.request(path,{'token':secret},cookie=cookie) + self.assertEqual((code,body),(200,{'configured':True})) + self.assertEqual(self.request(path,cookie=cookie)[1],{'configured':True}) + self.assertEqual(self.request(path,{'token':None},cookie=cookie)[1],{'configured':False}) def test_first_run_and_setup_guard(self): self.assertEqual(self.request('/api/v1/status')[0],401) self.assertFalse(self.request('/api/v1/auth/status')[1]['initialized']) diff --git a/test_hub_auth.py b/test_hub_auth.py new file mode 100644 index 0000000..8778c72 --- /dev/null +++ b/test_hub_auth.py @@ -0,0 +1,44 @@ +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch +from urllib.request import Request +from urllib.error import HTTPError +from hub_auth import HubAuth +from catalog import Catalog, Redirect, remote + +class HubAuthTests(unittest.TestCase): + def test_store_replace_remove_and_permissions(self): + with tempfile.TemporaryDirectory() as d: + a=HubAuth(Path(d)/'key.json');self.assertFalse(a.status()['configured']) + a.save('hf_'+'a'*30);self.assertEqual(a.path.stat().st_mode&0o777,0o600) + self.assertEqual(a.status(),{'configured':True});a.save('hf_'+'b'*30) + self.assertEqual(a.token(),'hf_'+'b'*30);a.save(None);self.assertIsNone(a.token()) + for bad in ['secret','hf_abc\nAuthorization: bad',{},False]: + with self.assertRaises(ValueError):a.save(bad) + def test_redirect_never_leaks_to_cdn(self): + req=Request('https://huggingface.co/a',headers={'Authorization':'Bearer secret'}) + handler=Redirect() + for url,expected in [('https://cdn-lfs.huggingface.co/b',None),('https://huggingface.co/b','Bearer secret')]: + r=handler.redirect_request(req,None,302,'Found',{},url) + self.assertEqual(r.get_header('Authorization'),expected) + with self.assertRaises(ValueError):handler.redirect_request(req,None,302,'Found',{},'https://evil.test/a') + def test_remote_auth_scope_and_safe_errors(self): + with patch('catalog.urllib.request.build_opener') as opener: + for url,expected in [('https://huggingface.co/a','Bearer secret'),('https://cdn-lfs.huggingface.co/a',None)]: + remote(url,'secret');self.assertEqual(opener.return_value.open.call_args.args[0].get_header('Authorization'),expected) + for code in [401,403,404,500]: + opener.return_value.open.side_effect=HTTPError('https://secret',code,'secret',{},io.BytesIO(b'secret')) + with self.assertRaises(ValueError) as err:remote('https://huggingface.co/a','secret') + self.assertNotIn('secret',str(err.exception)) + def test_gated_queue_and_cache(self): + with tempfile.TemporaryDirectory() as d: + c=Catalog(d);data={'repo':'a/b','revision':'a'*40,'gated':'auto','files':[{'name':'x.gguf','size':1}]} + with patch.object(c,'files',return_value=data),patch.object(c,'_next'),patch('catalog.shutil.disk_usage') as disk: + disk.return_value.free=100*1024**3 + with self.assertRaises(ValueError):c.start('a/b','x.gguf','a'*40,'chat') + c.cache['a/b']=(0,data);c.hub_credentials('hf_'+'a'*30);self.assertFalse(c.cache) + self.assertEqual(c.start('a/b','x.gguf','a'*40,'chat')['state'],'queued') + self.assertNotIn('hf_',json.dumps(c.status()))