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.
`:''}`:'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 = '
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.
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='
`:''}`;
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()))