v2.0.10: загрузка через ВМ-буфер (паттерн drhider)
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
- /api/upload_refs: pull файлов с ВМ (SSRF-guard, лимит 50МБ, ретраи, DELETE) - uploadFile: PUT на WebDAV /contracts-upload/ → refs → pull - nginx: location /contracts-upload/ (WebDAV + CORS для managed-фронта)
This commit is contained in:
@@ -92,6 +92,29 @@ server {
|
|||||||
client_max_body_size 100m;
|
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
|
listen 443 ssl; # managed by Certbot
|
||||||
ssl_certificate /etc/letsencrypt/live/contracts.kube5s.ru/fullchain.pem; # 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
|
ssl_certificate_key /etc/letsencrypt/live/contracts.kube5s.ru/privkey.pem; # managed by Certbot
|
||||||
|
|||||||
+13
-1
@@ -1,7 +1,7 @@
|
|||||||
"""Конфигурация приложения — все настройки в одном месте."""
|
"""Конфигурация приложения — все настройки в одном месте."""
|
||||||
import os
|
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_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", "")
|
||||||
@@ -10,3 +10,15 @@ LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
|||||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
API_KEY = os.getenv("API_KEY", "")
|
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")
|
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
|
||||||
|
|||||||
+126
-36
@@ -1,5 +1,5 @@
|
|||||||
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
"""Upload blueprint — загрузка, конвертация, распаковка."""
|
||||||
import io, os, base64, hashlib, zipfile
|
import io, os, time, 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,9 +18,76 @@ 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 _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"])
|
@upload_bp.route("/upload", methods=["POST"])
|
||||||
def upload():
|
def upload():
|
||||||
"""Загрузка одного файла + авто-парсинг → БД."""
|
"""Загрузка одного файла + авто-парсинг → БД (прямой multipart)."""
|
||||||
f = request.files.get("files")
|
f = request.files.get("files")
|
||||||
if not f:
|
if not f:
|
||||||
return jsonify(ok=False, error="no file"), 400
|
return jsonify(ok=False, error="no file"), 400
|
||||||
@@ -30,43 +97,66 @@ def upload():
|
|||||||
return jsonify(ok=False, error=err), 400
|
return jsonify(ok=False, error=err), 400
|
||||||
|
|
||||||
data = f.read()
|
data = f.read()
|
||||||
content_hash = hashlib.sha256(data).hexdigest()[:16]
|
|
||||||
batch_id = request.form.get("batch_id")
|
batch_id = request.form.get("batch_id")
|
||||||
zip_source = request.form.get("zip_source")
|
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")
|
contract_id = request.form.get("contract_id")
|
||||||
return jsonify(
|
|
||||||
ok=True,
|
result = _store_and_parse(f.filename, data, batch_id, contract_id, zip_source, f.content_type or "application/octet-stream")
|
||||||
doc_id=doc["id"],
|
if not result["ok"]:
|
||||||
contract_id=contract_id,
|
return jsonify(ok=result["ok"], error=result.get("error"), doc_id=result.get("doc_id")), 200
|
||||||
parsed={"status": result["status"], "element_count": result.get("element_count", 0)},
|
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"])
|
@upload_bp.route("/convert-doc", methods=["POST"])
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ var VM_API = '';
|
|||||||
var UPLOAD_URL = '/upload';
|
var UPLOAD_URL = '/upload';
|
||||||
var CONVERT_URL = '/convert-doc';
|
var CONVERT_URL = '/convert-doc';
|
||||||
var UNZIP_URL = '/unzip-upload';
|
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) — не использовался
|
// SITE_URL удалён (Фаза 4) — не использовался
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
var fileInput = document.getElementById('fileInput');
|
||||||
|
|||||||
+25
-12
@@ -241,21 +241,18 @@ function convertDoc(file, onProgress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⛔ НЕ МЕНЯТЬ ⛔ uploadFile — fetch-загрузка с честным счётчиком времени.
|
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
|
||||||
*
|
*
|
||||||
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
|
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
|
||||||
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
|
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
|
||||||
* Кэш-бастинг: ?_=Date.now()
|
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
|
||||||
*/
|
*/
|
||||||
function uploadFile(file, onProgress, zipSource) {
|
function uploadFile(file, onProgress, zipSource) {
|
||||||
var startTime = Date.now();
|
var startTime = Date.now();
|
||||||
var phase = 'connecting'; // connecting → uploading
|
var phase = 'connecting'; // connecting → uploading
|
||||||
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||||
var fd = new FormData();
|
var token = crypto.randomUUID();
|
||||||
fd.append('files', file, file.name);
|
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
|
||||||
fd.append('batch_id', state.batchId);
|
|
||||||
if (zipSource) fd.append('zip_source', zipSource);
|
|
||||||
|
|
||||||
// Честный счётчик: каждую секунду обновляем elapsed
|
// Честный счётчик: каждую секунду обновляем elapsed
|
||||||
var timer = setInterval(function() {
|
var timer = setInterval(function() {
|
||||||
@@ -263,17 +260,33 @@ function uploadFile(file, onProgress, zipSource) {
|
|||||||
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||||
}, 1000);
|
}, 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) {
|
.then(function(r) {
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
clearInterval(timer);
|
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);
|
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||||
return data;
|
return res; // {ok, doc_id, contract_id, parsed}
|
||||||
}
|
}
|
||||||
throw new Error(data.error || 'Неизвестная ошибка');
|
throw new Error(data.error || 'Неизвестная ошибка');
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/static/logo.svg" alt="Nubes">
|
<img src="/static/logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.9</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v2.0.10</span></span>
|
||||||
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
|
||||||
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
<span id="stepUpload">○ Загрузка</span><span>→</span>
|
||||||
<span id="stepClassify">○ Классификация</span><span>→</span>
|
<span id="stepClassify">○ Классификация</span><span>→</span>
|
||||||
@@ -218,9 +218,9 @@
|
|||||||
|
|
||||||
<script src="/static/state.js?v=2.0.8"></script>
|
<script src="/static/state.js?v=2.0.8"></script>
|
||||||
<script src="/static/app_utils.js?v=2.0.8"></script>
|
<script src="/static/app_utils.js?v=2.0.8"></script>
|
||||||
<script src="/static/files.js?v=2.0.8"></script>
|
<script src="/static/files.js?v=2.0.10"></script>
|
||||||
<script src="/static/groups.js?v=2.0.8"></script>
|
<script src="/static/groups.js?v=2.0.8"></script>
|
||||||
<script src="/static/compare.js?v=2.0.8"></script>
|
<script src="/static/compare.js?v=2.0.8"></script>
|
||||||
<script src="/static/app.js?v=2.0.8"></script>
|
<script src="/static/app.js?v=2.0.10"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user