diff --git a/api/routes.py b/api/routes.py index e001a271..521cb6ac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -413,7 +413,7 @@ from api.workspace import ( _is_blocked_system_path, _workspace_blocked_roots, ) -from api.upload import handle_upload, handle_transcribe +from api.upload import handle_upload, handle_upload_extract, handle_transcribe from api.streaming import _sse, _run_agent_streaming, cancel_stream from api.providers import get_providers, set_provider_key, remove_provider_key from api.onboarding import ( @@ -1196,6 +1196,8 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/upload": return handle_upload(handler) + if parsed.path == "/api/upload/extract": + return handle_upload_extract(handler) if parsed.path == "/api/transcribe": return handle_transcribe(handler) diff --git a/api/upload.py b/api/upload.py index ec1dab38..8daf72c0 100644 --- a/api/upload.py +++ b/api/upload.py @@ -88,6 +88,98 @@ def handle_upload(handler): return j(handler, {'error': 'Upload failed'}, status=500) +def extract_archive(file_bytes: bytes, filename: str, workspace: Path): + """Extract a zip or tar archive into the workspace. + + Returns a dict with ``extracted`` (int), ``files`` (list[str]). + Raises ValueError on zip-slip or unsupported format. + """ + import zipfile, tarfile, io, os + + name = Path(filename).name + stem = Path(filename).stem # strip .zip / .tar.gz etc. + + if name.lower().endswith(('.zip',)): + _mode = 'zip' + elif name.lower().endswith(('.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz')): + _mode = 'tar' + else: + raise ValueError(f'Unsupported archive format: {filename}') + + # Determine destination directory — use archive stem as folder name + dest_dir = safe_resolve_ws(workspace, stem) + # Avoid overwriting existing files by appending a suffix + if dest_dir.exists(): + import string, random + while dest_dir.exists(): + suffix = ''.join(random.choices(string.digits, k=3)) + dest_dir = dest_dir.with_name(stem + '_' + suffix) + dest_dir.mkdir(parents=True, exist_ok=True) + + extracted_files = [] + + if _mode == 'zip': + with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: + for member in zf.infolist(): + # Skip directories + if member.is_dir(): + continue + # Zip-slip protection + member_path = (dest_dir / member.filename).resolve() + if not str(member_path).startswith(str(dest_dir.resolve())): + raise ValueError(f'Zip-slip blocked: {member.filename}') + member_path.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as src, open(member_path, 'wb') as dst: + dst.write(src.read()) + extracted_files.append(str(member_path.relative_to(workspace.resolve()))) + + elif _mode == 'tar': + with tarfile.open(fileobj=io.BytesIO(file_bytes)) as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + # Tar-slip protection + member_path = (dest_dir / member.name).resolve() + if not str(member_path).startswith(str(dest_dir.resolve())): + raise ValueError(f'Tar-slip blocked: {member.name}') + member_path.parent.mkdir(parents=True, exist_ok=True) + with tf.extractfile(member) as src, open(member_path, 'wb') as dst: + if src: + dst.write(src.read()) + extracted_files.append(str(member_path.relative_to(workspace.resolve()))) + + return {'extracted': len(extracted_files), 'files': extracted_files, 'dest': str(dest_dir)} + + +def handle_upload_extract(handler): + """Handle archive upload and extraction.""" + import traceback as _tb + try: + content_type = handler.headers.get('Content-Type', '') + content_length = int(handler.headers.get('Content-Length', 0) or 0) + if content_length > MAX_UPLOAD_BYTES: + return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413) + fields, files = parse_multipart(handler.rfile, content_type, content_length) + session_id = fields.get('session_id', '') + if 'file' not in files: + return j(handler, {'error': 'No file field in request'}, status=400) + filename, file_bytes = files['file'] + if not filename: + return j(handler, {'error': 'No filename in upload'}, status=400) + try: + s = get_session(session_id) + except KeyError: + return j(handler, {'error': 'Session not found'}, status=404) + workspace = Path(s.workspace) + result = extract_archive(file_bytes, filename, workspace) + return j(handler, {'ok': True, **result}) + except ValueError as e: + return j(handler, {'error': str(e)}, status=400) + except Exception: + print('[webui] upload extract error: ' + _tb.format_exc(), flush=True) + return j(handler, {'error': 'Archive extraction failed'}, status=500) + + def handle_transcribe(handler): import traceback as _tb temp_path = None diff --git a/static/i18n.js b/static/i18n.js index ff92eaed..e7f867f0 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -239,6 +239,7 @@ const LOCALES = { empty_dir: '(empty)', upload_failed: 'Upload failed: ', all_uploads_failed: (n) => `All ${n} upload(s) failed`, + archive_extracted: (n, c) => `Extracted ${n} file(s) from ${c} archive(s)`, session_pin: 'Pin conversation', session_unpin: 'Unpin conversation', session_pin_desc: 'Keep this conversation at the top', @@ -865,6 +866,7 @@ const LOCALES = { empty_dir: '(пусто)', upload_failed: 'Не удалось загрузить: ', all_uploads_failed: (n) => `Не удалось загрузить все ${n} файлов`, + archive_extracted: (n, c) => `Извлечено ${n} файл(ов) из ${c} архив(ов)`, settings_title: 'Настройки', settings_save_btn: 'Сохранить настройки', settings_label_model: 'Модель по умолчанию', @@ -1487,6 +1489,7 @@ const LOCALES = { empty_dir: '(vacío)', upload_failed: 'Error al subir: ', all_uploads_failed: (n) => `Fallaron las ${n} subida(s)`, + archive_extracted: (n, c) => `${n} archivo(s) extraído(s) de ${c} archivo(s) comprimido(s)`, // settings panel settings_title: 'Configuración', settings_save_btn: 'Guardar configuración', @@ -2006,6 +2009,7 @@ const LOCALES = { model_not_found_label: 'Modell nicht gefunden', // commands.js cmd_help: 'Verfügbare Befehle auflisten', + archive_extracted: (n, c) => `${n} Datei(en) aus ${c} Archiv(en) entpackt`, cmd_clear: 'Konversationsverlauf löschen', cmd_compress: 'Kontext manuell komprimieren (Nutzung: /compress [Thema])', cmd_compact_alias: 'Alte Alias für /compress', @@ -2641,6 +2645,7 @@ const LOCALES = { // onboarding onboarding_badge: '首次运行', onboarding_title: '欢迎使用 Hermes Web UI', + archive_extracted: (n, c) => `从 ${c} 个压缩包中解压了 ${n} 个文件`, onboarding_lead: '快速引导将验证 Hermes、保存真实的提供商配置、选择工作区和模型,并可选设置密码保护应用。', onboarding_back: '返回', onboarding_continue: '继续', @@ -3179,6 +3184,7 @@ const LOCALES = { settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58', settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a', settings_load_failed: '\u8a2d\u5b9a\u52a0\u8f09\u5931\u6557\uff1a', + archive_extracted: (n, c) => `\u5f9e ${c} \u500b\u58d3\u7e2e\u5305\u4e2d\u89e3\u58d3\u7e2e\u4e86 ${n} \u500b\u6a94\u6848`, settings_saved_pw: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u4fdd\u8b77\u5df2\u555f\u7528\uff0c\u7576\u524d\u700f\u89bd\u5668\u6703\u4fdd\u6301\u767b\u5165', settings_saved_pw_updated: '\u8a2d\u5b9a\u5df2\u5132\u5b58\uff0c\u5bc6\u78bc\u5df2\u66f4\u65b0', // login page @@ -3869,6 +3875,7 @@ const LOCALES = { workspace_auto_create_folder: '폴더가 없으면 생성', folder_add_as_space_btn: '스페이스로 추가', folder_add_as_space_msg: '이 폴더를 워크스페이스 목록의 새 스페이스로 추가할까요?', + archive_extracted: (n, c) => `${c}개 압축 파일에서 ${n}개 파일 압축 해제됨`, folder_add_as_space_title: '스페이스로 추가할까요?', remove_title: 'Remove', empty_dir: '(비어 있음)', diff --git a/static/index.html b/static/index.html index da1c3913..c28a35f5 100644 --- a/static/index.html +++ b/static/index.html @@ -323,7 +323,7 @@