v2.0.14: этап 4 — .doc→.docx в sink, удалены /api/unzip_refs и /api/convert_refs
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
This commit is contained in:
+6
-111
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user