v2.0.14: этап 4 — .doc→.docx в sink, удалены /api/unzip_refs и /api/convert_refs
Deploy contracts-flask / validate (push) Canceled after 0s

This commit is contained in:
“Naeel”
2026-08-26 11:40:03 +03:00
parent d2894ad7c5
commit 5b49e88f1e
4 changed files with 16 additions and 126 deletions
@@ -94,7 +94,11 @@
## Статус ## Статус
- [x] Скопировать `upload/` в contracts-flask - [x] Скопировать `upload/` в contracts-flask (commit db58a43)
- [ ] Этап 2 — sink (доработка blueprint + contracts-sink + регистрация) - [x] Этап 2 — sink (blueprint + contracts_upload_sink + регистрация), проверено
- [ ] Удалить рукописный транспорт (после этапа 2) - [x] Удалён рукописный `/api/upload_refs` (основной путь теперь через модуль)
- [ ] Этап 3 — UI - [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` не удалены.
+1 -1
View File
@@ -1,7 +1,7 @@
"""Конфигурация приложения — все настройки в одном месте.""" """Конфигурация приложения — все настройки в одном месте."""
import os 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_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions")
LLM_KEY = os.getenv("LLM_API_KEY", "") LLM_KEY = os.getenv("LLM_API_KEY", "")
+6 -111
View File
@@ -1,5 +1,5 @@
"""Upload blueprint — загрузка, конвертация, распаковка.""" """Upload blueprint — загрузка, конвертация, распаковка."""
import io, os, time, base64, hashlib, zipfile import io, os, base64, hashlib, zipfile
import httpx import httpx
from flask import Blueprint, request, jsonify, send_file from flask import Blueprint, request, jsonify, send_file
from services.parse import parse_file from services.parse import parse_file
@@ -18,59 +18,6 @@ def _check_ext(filename: str) -> str | None:
return 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): def _unzip(data: bytes):
"""Распаковать ZIP → (ok, files, error).""" """Распаковать ZIP → (ok, files, error)."""
MAX_FILES = 500 MAX_FILES = 500
@@ -183,49 +130,15 @@ def contracts_upload_sink(name, content, batch_id=None, contract_id=None, zip_so
"""Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг. """Sink для переиспользуемого модуля upload: вставить файл в documents + авто-парсинг.
Вызывается модулем create_upload_refs_blueprint(cfg, sink=...) на каждый Вызывается модулем 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) 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"]) @upload_bp.route("/unzip-upload", methods=["POST"])
def unzip_upload(): def unzip_upload():
"""Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart).""" """Распаковать ZIP → список файлов (base64 для фронтенда, прямой multipart)."""
@@ -236,21 +149,3 @@ def unzip_upload():
if not ok: if not ok:
return jsonify(ok=False, error=err), 400 return jsonify(ok=False, error=err), 400
return jsonify(ok=True, files=files) 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)
+1 -10
View File
@@ -615,16 +615,7 @@ async function onFilesSelected(newFiles) {
delete entry._pendingFile; delete entry._pendingFile;
var f = pendingFile; var f = pendingFile;
try { try {
// .doc → конвертация через libreoffice-сервис var resp = await uploadFile(f, function(st) {
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) {
entry.status = st; entry.status = st;
render(state); render(state);
}); });