diff --git a/deploy/nginx-contracts.conf b/deploy/nginx-contracts.conf index ec898be..135c0b3 100644 --- a/deploy/nginx-contracts.conf +++ b/deploy/nginx-contracts.conf @@ -92,6 +92,29 @@ server { client_max_body_size 100m; } + # ── VM-буфер загрузки contracts (паттерн drhider) ────────────── + # Браузер кладёт файл сюда PUT (мимо шлюза кластера ~64КБ), бэк тянет сам. + # Файлы: /var/www/contracts-upload/ (нужно создать, chown www-data). + # ⚠️ CORS: Access-Control-Allow-Origin должен совпадать с origin фронтенда. + location /contracts-upload/ { + client_max_body_size 1024m; + dav_methods PUT DELETE; + create_full_put_path on; + dav_access user:rw group:rw all:r; + root /var/www; + + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always; + add_header Access-Control-Allow-Methods "PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "*" always; + add_header Content-Length 0; + return 204; + } + add_header Access-Control-Allow-Origin https://contractor.pythonk8s.dev.nubes.ru always; + add_header Access-Control-Allow-Methods "PUT, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "*" always; + } + listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot diff --git a/site/config.py b/site/config.py index 468f479..38f1573 100644 --- a/site/config.py +++ b/site/config.py @@ -1,7 +1,7 @@ """Конфигурация приложения — все настройки в одном месте.""" import os -VERSION = "2.0.9" +VERSION = "2.0.10" LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") LLM_KEY = os.getenv("LLM_API_KEY", "") @@ -10,3 +10,15 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b") MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB API_KEY = os.getenv("API_KEY", "") CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000") + +# ── VM-буфер загрузки (паттерн drhider) ────────────────────────────── +# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ), +# бэк сам тянет его исходящим GET (egress без лимита). +VM_UPLOAD_URL = os.getenv("VM_UPLOAD_URL", "https://contracts.kube5s.ru/contracts-upload/") +# SSRF-защита: тянуть можно ТОЛЬКО с этого префикса. +VM_UPLOAD_PREFIX = os.getenv("VM_UPLOAD_PREFIX", "https://contracts.kube5s.ru/contracts-upload/") +# Лимит на один файл (совпадает с фронтом). +VM_UPLOAD_MAX_BYTES = int(os.getenv("VM_UPLOAD_MAX_BYTES", str(50 * 1024 * 1024))) +# Ретраи pull с ВМ (разовые DNS/сетевые сбои не роняют загрузку). +PULL_RETRIES = int(os.getenv("PULL_RETRIES", "3")) +PULL_RETRY_DELAY = 2.0 diff --git a/site/routes/upload_bp.py b/site/routes/upload_bp.py index 03dad1d..3249ebb 100644 --- a/site/routes/upload_bp.py +++ b/site/routes/upload_bp.py @@ -1,5 +1,5 @@ """Upload blueprint — загрузка, конвертация, распаковка.""" -import io, os, base64, hashlib, zipfile +import io, os, time, base64, hashlib, zipfile import httpx from flask import Blueprint, request, jsonify, send_file from services.parse import parse_file @@ -18,9 +18,76 @@ 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 _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"): + """Общая логика: дедуп → insert в БД → авто-парсинг. Возвращает dict-результат.""" + content_hash = hashlib.sha256(data).hexdigest()[:16] + + # Дедупликация по хешу + if batch_id: + existing = documents.get_by_hash(batch_id, content_hash) + if existing: + return {"ok": False, "error": "duplicate", "doc_id": existing["id"], "duplicate_of": True} + + doc = documents.insert( + filename=filename, + mime_type=mime_type, + original_bytes=base64.b64encode(data).decode(), + batch_id=batch_id, + zip_source=zip_source, + content_hash=content_hash, + ) + + # Авто-парсинг + try: + result = parse_file(filename, data) + if result["status"] == "parsed": + documents.set_parsed(doc["id"], result["elements"]) + parsed = {"status": "parsed", "element_count": result.get("element_count", 0)} + else: + documents.set_error(doc["id"], result.get("error", "parse failed")) + parsed = {"status": "error", "error": result.get("error", "parse failed")} + except Exception as e: + documents.set_error(doc["id"], str(e)) + parsed = {"status": "error", "error": str(e)} + + return {"ok": True, "doc_id": doc["id"], "contract_id": contract_id, "parsed": parsed} + + @upload_bp.route("/upload", methods=["POST"]) def upload(): - """Загрузка одного файла + авто-парсинг → БД.""" + """Загрузка одного файла + авто-парсинг → БД (прямой multipart).""" f = request.files.get("files") if not f: return jsonify(ok=False, error="no file"), 400 @@ -30,43 +97,66 @@ def upload(): return jsonify(ok=False, error=err), 400 data = f.read() - content_hash = hashlib.sha256(data).hexdigest()[:16] batch_id = request.form.get("batch_id") zip_source = request.form.get("zip_source") - - # Дедупликация по хешу - if batch_id: - existing = documents.get_by_hash(batch_id, content_hash) - if existing: - return jsonify(ok=False, error="duplicate", doc_id=existing["id"]) - - doc = documents.insert( - filename=f.filename, - mime_type=f.content_type or "application/octet-stream", - original_bytes=base64.b64encode(data).decode(), - batch_id=batch_id, - zip_source=zip_source, - content_hash=content_hash, - ) - - # Авто-парсинг - try: - result = parse_file(f.filename, data) - if result["status"] == "parsed": - documents.set_parsed(doc["id"], result["elements"]) - else: - documents.set_error(doc["id"], result.get("error", "parse failed")) - except Exception as e: - documents.set_error(doc["id"], str(e)) - result = {"status": "error", "error": str(e)} - contract_id = request.form.get("contract_id") - return jsonify( - ok=True, - doc_id=doc["id"], - contract_id=contract_id, - parsed={"status": result["status"], "element_count": result.get("element_count", 0)}, - ) + + result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream") + if not result["ok"]: + return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200 + return jsonify(ok=True, doc_id=result["doc_id"], contract_id=result["contract_id"], parsed=result["parsed"]) + + +@upload_bp.route("/api/upload_refs", methods=["POST"]) +def upload_refs(): + """Загрузка через ВМ-буфер (паттерн drhider): бэк тянет файлы с ВМ. + + Тело (маленькое, <64КБ): {batch_id, contract_id, zip_source, files:[{name,size,url}]}. + Для каждой ссылки: SSRF-проверка → лимит → pull с ретраями → store+parse → DELETE с ВМ. + """ + data = request.get_json(silent=True) or {} + files = data.get("files") or [] + batch_id = data.get("batch_id") + contract_id = data.get("contract_id") + zip_source = data.get("zip_source") + + if not files: + return jsonify(ok=False, error="no files"), 400 + + results = [] + for ref in files: + name = _safe_name(str(ref.get("name", "") or "")) + size = int(ref.get("size") or 0) + url = str(ref.get("url", "") or "") + + # SSRF-защита: тянуть можно ТОЛЬКО с доверенного ВМ-буфера + if not url.startswith(config.VM_UPLOAD_PREFIX): + results.append({"name": name, "ok": False, "error": "invalid url (SSRF guard)"}) + continue + + # Лимит по заявленному размеру + if size > config.VM_UPLOAD_MAX_BYTES: + _delete_from_vm(url) + results.append({"name": name, "ok": False, "error": f"file too large: {size} bytes (max {config.VM_UPLOAD_MAX_BYTES})", "skipped": True}) + continue + + try: + content = _pull_with_retries(url) + except Exception as e: + results.append({"name": name, "ok": False, "error": f"pull failed: {e}"}) + continue + + # Реальная проверка размера после pull + if len(content) > config.VM_UPLOAD_MAX_BYTES: + _delete_from_vm(url) + results.append({"name": name, "ok": False, "error": "file too large after pull", "skipped": True}) + continue + + _delete_from_vm(url) + stored = _store_and_parse(name, content, batch_id, contract_id, zip_source) + results.append({"name": name, **stored}) + + return jsonify(ok=True, results=results) @upload_bp.route("/convert-doc", methods=["POST"]) diff --git a/site/static/app.js b/site/static/app.js index b477f76..0f09bf8 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -4,6 +4,9 @@ var VM_API = ''; var UPLOAD_URL = '/upload'; var CONVERT_URL = '/convert-doc'; var UNZIP_URL = '/unzip-upload'; +// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза), +// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ. +var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/'; // SITE_URL удалён (Фаза 4) — не использовался var fileInput = document.getElementById('fileInput'); diff --git a/site/static/files.js b/site/static/files.js index 90cf7d9..6f6bea6 100644 --- a/site/static/files.js +++ b/site/static/files.js @@ -241,21 +241,18 @@ function convertDoc(file, onProgress) { } /** - * ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени. + * ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider). * - * v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс. - * fetch() не даёт реальный upload progress, поэтому считаем секунды. - * Кэш-бастинг: ?_=Date.now() + * Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ). + * Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ. + * Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress). */ function uploadFile(file, onProgress, zipSource) { var startTime = Date.now(); var phase = 'connecting'; // connecting → uploading if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 }); - var fd = new FormData(); - fd.append('files', file, file.name); - if (state.contractId) fd.append('contract_id', state.contractId); - fd.append('batch_id', state.batchId); - if (zipSource) fd.append('zip_source', zipSource); + var token = crypto.randomUUID(); + var vmUrl = VM_UPLOAD_URL + token + '_0'; // Честный счётчик: каждую секунду обновляем elapsed var timer = setInterval(function() { @@ -263,17 +260,33 @@ function uploadFile(file, onProgress, zipSource) { if (onProgress) onProgress({ kind: phase, elapsed: elapsed }); }, 1000); - return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd }) + // Фаза 1: PUT файла на ВМ-буфер + return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } }) + .then(function(r) { + if (!r.ok) throw new Error('VM upload HTTP ' + r.status); + phase = 'uploading'; + // Фаза 2: refs на бэкенд → pull с ВМ + var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] }; + if (state.contractId) body.contract_id = state.contractId; + if (zipSource) body.zip_source = zipSource; + return fetch('/api/upload_refs?_=' + Date.now(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function(data) { clearInterval(timer); - if (data.ok) { + if (data.ok && data.results && data.results.length === 1) { + var res = data.results[0]; + if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка'); var elapsed = Math.floor((Date.now() - startTime) / 1000); if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed }); - return data; + return res; // {ok, doc_id, contract_id, parsed} } throw new Error(data.error || 'Неизвестная ошибка'); }) diff --git a/site/templates/index.html b/site/templates/index.html index b1495de..5daa513 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -73,7 +73,7 @@