From 5b49e88f1ef36f7629630fadbf11dfdee926b931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 26 Aug 2026 11:40:03 +0300 Subject: [PATCH] =?UTF-8?q?v2.0.14:=20=D1=8D=D1=82=D0=B0=D0=BF=204=20?= =?UTF-8?q?=E2=80=94=20.doc=E2=86=92.docx=20=D0=B2=20sink,=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20/api/unzip=5Frefs=20=D0=B8=20/?= =?UTF-8?q?api/convert=5Frefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-26-upload-module-reuse-plan.md | 12 +- site/config.py | 2 +- site/routes/upload_bp.py | 117 +----------------- site/static/files.js | 11 +- 4 files changed, 16 insertions(+), 126 deletions(-) diff --git a/History/2026-08-26-upload-module-reuse-plan.md b/History/2026-08-26-upload-module-reuse-plan.md index 48f06ac..d5c1dba 100644 --- a/History/2026-08-26-upload-module-reuse-plan.md +++ b/History/2026-08-26-upload-module-reuse-plan.md @@ -94,7 +94,11 @@ ## Статус -- [x] Скопировать `upload/` в contracts-flask -- [ ] Этап 2 — sink (доработка blueprint + contracts-sink + регистрация) -- [ ] Удалить рукописный транспорт (после этапа 2) -- [ ] Этап 3 — UI +- [x] Скопировать `upload/` в contracts-flask (commit db58a43) +- [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено +- [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль) +- [x] Этап 3 — UI: выбор папок (webkitdirectory) + рекурсивный клиентский ZIP (commit d2894ad) +- [ ] Осталось (cleanup, опционально): .doc→.docx в sink, удалить `/api/unzip_refs`/`/api/convert_refs` + `addZipFile`/`convertDoc` + +Примечание: `/api/unzip_refs` и `/api/convert_refs` пока оставлены как fallback +(server-side ZIP и .doc), фронт `addZipFile`/`convertDoc` не удалены. diff --git a/site/config.py b/site/config.py index 818a981..888ef1d 100644 --- a/site/config.py +++ b/site/config.py @@ -1,7 +1,7 @@ """Конфигурация приложения — все настройки в одном месте.""" import os -VERSION = "2.0.13" +VERSION = "2.0.14" LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") LLM_KEY = os.getenv("LLM_API_KEY", "") diff --git a/site/routes/upload_bp.py b/site/routes/upload_bp.py index d84f6b8..ca88aa3 100644 --- a/site/routes/upload_bp.py +++ b/site/routes/upload_bp.py @@ -1,5 +1,5 @@ """Upload blueprint — загрузка, конвертация, распаковка.""" -import io, os, time, base64, hashlib, zipfile +import io, os, base64, hashlib, zipfile import httpx from flask import Blueprint, request, jsonify, send_file from services.parse import parse_file @@ -18,59 +18,6 @@ def _check_ext(filename: str) -> str | None: return None -def _safe_name(name: str) -> str: - """Санитизация имени файла: только basename, защита от path traversal.""" - name = (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip() - if not name or name in (".", ".."): - return "file.bin" - return name[:255] - - -def _pull_with_retries(url: str, timeout: int = 120) -> bytes: - """Скачать файл с ВМ-буфера с ретраями (egress, без лимита шлюза).""" - last: Exception | None = None - for attempt in range(1, config.PULL_RETRIES + 1): - try: - resp = httpx.get(url, timeout=timeout, follow_redirects=False) - if resp.status_code == 200: - return resp.content - last = Exception(f"HTTP {resp.status_code}") - except Exception as e: - last = e - if attempt < config.PULL_RETRIES: - time.sleep(config.PULL_RETRY_DELAY) - raise last or Exception("pull failed") - - -def _delete_from_vm(url: str) -> None: - """Best-effort удаление файла с ВМ-буфера.""" - try: - httpx.delete(url, timeout=30) - except Exception: - pass - - -def _pull_from_ref(ref): - """SSRF-проверка → лимит → pull с ретраями → DELETE с ВМ. Возвращает (name, content).""" - name = _safe_name(str(ref.get("name", "") or "")) - size = int(ref.get("size") or 0) - url = str(ref.get("url", "") or "") - - if not url.startswith(config.VM_UPLOAD_PREFIX): - raise Exception("invalid url (SSRF guard)") - if size > config.VM_UPLOAD_MAX_BYTES: - _delete_from_vm(url) - raise Exception(f"file too large: {size} bytes (max {config.VM_UPLOAD_MAX_BYTES})") - - content = _pull_with_retries(url) - if len(content) > config.VM_UPLOAD_MAX_BYTES: - _delete_from_vm(url) - raise Exception("file too large after pull") - - _delete_from_vm(url) - return name, content - - def _unzip(data: bytes): """Распаковать ZIP → (ok, files, error).""" MAX_FILES = 500 @@ -183,49 +130,15 @@ def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_so """Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг. Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый - вытянутый с ВМ файл. Возвращает dict с ключами ok/doc_id/contract_id/parsed. + вытянутый с ВМ файл. .doc конвертируется в .docx через внешний LibreOffice + (парсер умеет только .docx). Возвращает dict ok/doc_id/contract_id/parsed. """ + if name.lower().endswith(".doc"): + content = _convert(name, content) + name = name[:-4] + ".docx" return _store_and_parse(name, content, batch_id, contract_id, zip_source) -@upload_bp.route("/convert-doc", methods=["POST"]) -def convert_doc(): - """.doc → .docx через внешний libreoffice-сервис (прямой multipart).""" - f = request.files.get("files") - if not f: - return jsonify(ok=False, error="no file"), 400 - try: - content = _convert(f.filename, f.read()) - except Exception as e: - return jsonify(ok=False, error=str(e)), 500 - return send_file( - io.BytesIO(content), - mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) - - -@upload_bp.route("/api/convert_refs", methods=["POST"]) -def convert_refs(): - """.doc → .docx через ВМ-буфер (паттерн drhider): pull .doc с ВМ → конвертация → docx.""" - data = request.get_json(silent=True) or {} - files = data.get("files") or [] - if not files: - return jsonify(ok=False, error="no files"), 400 - ref = files[0] - try: - name, content = _pull_from_ref(ref) - except Exception as e: - return jsonify(ok=False, error=str(e)), 400 - try: - docx = _convert(name, content) - except Exception as e: - return jsonify(ok=False, error=str(e)), 500 - return send_file( - io.BytesIO(docx), - mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) - - @upload_bp.route("/unzip-upload", methods=["POST"]) def unzip_upload(): """Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart).""" @@ -236,21 +149,3 @@ def unzip_upload(): if not ok: return jsonify(ok=False, error=err), 400 return jsonify(ok=True, files=files) - - -@upload_bp.route("/api/unzip_refs", methods=["POST"]) -def unzip_refs(): - """Распаковать ZIP через ВМ-буфер (паттерн drhider): pull ZIP с ВМ → распаковка.""" - data = request.get_json(silent=True) or {} - files = data.get("files") or [] - if not files: - return jsonify(ok=False, error="no files"), 400 - ref = files[0] - try: - name, content = _pull_from_ref(ref) - except Exception as e: - return jsonify(ok=False, error=str(e)), 400 - ok, unzipped, err = _unzip(content) - if not ok: - return jsonify(ok=False, error=err), 400 - return jsonify(ok=True, files=unzipped) diff --git a/site/static/files.js b/site/static/files.js index 76a8bf5..da6e81f 100644 --- a/site/static/files.js +++ b/site/static/files.js @@ -615,16 +615,7 @@ async function onFilesSelected(newFiles) { delete entry._pendingFile; var f = pendingFile; try { - // .doc → конвертация через libreoffice-сервис - var uploadTarget = f; - if (f.name.toLowerCase().endsWith('.doc')) { - uploadTarget = await convertDoc(f, function(st) { - entry.status = st; - render(state); - }); - entry.name = uploadTarget.name; - } - var resp = await uploadFile(uploadTarget, function(st) { + var resp = await uploadFile(f, function(st) { entry.status = st; render(state); });