Files
echo-core/dashboard/handlers/files.py
Marius Mutu bf62d6fb4b feat(dashboard): endpoint de download fișiere binare + tool epub-to-audio
- dashboard/handlers/files.py: handle_files_download() servește mp3/wav/zip
  din WORKSPACE_DIR cu Content-Disposition attachment, resolve dedicat
  (nu _resolve_sandboxed, care nu ajunge niciodată la WORKSPACE_DIR)
- dashboard/api.py: rutează /api/files/download
- tools/epub_to_audio.py: tool nou de conversie EPUB → audio
- cron/jobs.json, memory/kb/index.json: stare auto-generată (job runs,
  regenerare index KB)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:50:27 +00:00

157 lines
5.9 KiB
Python

"""File-browser + note-index endpoints (sandbox-enforced)."""
import json
import re
import subprocess
import sys
from urllib.parse import parse_qs, urlparse
import constants
class FilesHandlers:
"""Mixin for /api/files, /api/refresh-index."""
def _resolve_sandboxed(self, path):
"""Resolve `path` against ALLOWED_WORKSPACES. Returns (target, workspace) or (None, None)."""
allowed_dirs = constants.ALLOWED_WORKSPACES
for base in allowed_dirs:
try:
candidate = (base / path).resolve()
if any(str(candidate).startswith(str(d)) for d in allowed_dirs):
return candidate, base
except Exception:
continue
return None, None
def handle_files_get(self):
"""List files or get file content."""
params = parse_qs(urlparse(self.path).query)
path = params.get('path', [''])[0]
action = params.get('action', ['list'])[0]
target, workspace = self._resolve_sandboxed(path)
if target is None:
self.send_json({'error': 'Access denied'}, 403)
return
if action != 'list':
self.send_json({'error': 'Unknown action'}, 400)
return
if not target.exists():
self.send_json({'error': 'Path not found'}, 404)
return
if target.is_file():
try:
content = target.read_text(encoding='utf-8', errors='replace')
self.send_json({
'type': 'file',
'path': path,
'name': target.name,
'content': content[:100000],
'size': target.stat().st_size,
'truncated': target.stat().st_size > 100000,
})
except Exception as e:
self.send_json({'error': str(e)}, 500)
else:
items = []
try:
for item in sorted(target.iterdir()):
stat = item.stat()
item_path = f"{path}/{item.name}" if path else item.name
items.append({
'name': item.name,
'type': 'dir' if item.is_dir() else 'file',
'size': stat.st_size if item.is_file() else None,
'mtime': stat.st_mtime,
'path': item_path,
})
self.send_json({'type': 'dir', 'path': path, 'items': items})
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_files_download(self):
"""Stream a binary file (audio, zip, etc.) from WORKSPACE_DIR as a download.
Dedicated resolution (not `_resolve_sandboxed`, which always resolves
against ALLOWED_WORKSPACES[0]/BASE_DIR and never reaches WORKSPACE_DIR)
so this stays isolated from the shared file-browser sandbox logic.
"""
params = parse_qs(urlparse(self.path).query)
path = params.get('path', [''])[0]
try:
target = (constants.WORKSPACE_DIR / path).resolve()
target.relative_to(constants.WORKSPACE_DIR.resolve())
except (ValueError, OSError):
self.send_json({'error': 'Access denied'}, 403)
return
if not target.is_file():
self.send_json({'error': 'Not found'}, 404)
return
ext = target.suffix.lstrip('.').lower()
ctype = {
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'zip': 'application/zip',
}.get(ext, 'application/octet-stream')
data = target.read_bytes()
self.send_response(200)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.send_header('Content-Disposition', f'attachment; filename="{target.name}"')
self.send_header('Cache-Control', 'public, max-age=3600')
self.end_headers()
self.wfile.write(data)
def handle_files_post(self):
"""Save file content."""
try:
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
data = json.loads(post_data)
path = data.get('path', '')
content = data.get('content', '')
target, workspace = self._resolve_sandboxed(path)
if target is None:
self.send_json({'error': 'Access denied'}, 403)
return
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding='utf-8')
self.send_json({'status': 'saved', 'path': path, 'size': len(content)})
except Exception as e:
self.send_json({'error': str(e)}, 500)
def handle_refresh_index(self):
"""Regenerate memory/kb/index.json by running tools/update_notes_index.py."""
try:
script = constants.TOOLS_DIR / 'update_notes_index.py'
result = subprocess.run(
[sys.executable, str(script)],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
output = result.stdout
total_match = re.search(r'with (\d+) notes', output)
total = int(total_match.group(1)) if total_match else 0
self.send_json({
'success': True,
'message': f'Index regenerat cu {total} notițe',
'total': total,
'output': output,
})
else:
self.send_json({'success': False, 'error': result.stderr or 'Unknown error'}, 500)
except subprocess.TimeoutExpired:
self.send_json({'success': False, 'error': 'Timeout'}, 500)
except Exception as e:
self.send_json({'success': False, 'error': str(e)}, 500)