v1.0.123: fix ZIP — cgi.FieldStorage for multipart, CORS on all responses, revert Lucee unzip
This commit is contained in:
+37
-23
@@ -279,37 +279,40 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _handle_unzip_upload(self):
|
def _handle_unzip_upload(self):
|
||||||
"""Распаковать ZIP и загрузить каждый файл в Lucee. Только файлы из корня архива."""
|
"""Распаковать ZIP и загрузить каждый файл в Lucee. Только файлы из корня архива."""
|
||||||
import zipfile, io
|
import zipfile, io, cgi, tempfile
|
||||||
|
|
||||||
# Читаем multipart form data
|
# CORS preflight уже обработан nginx, но на всякий случай
|
||||||
|
self._send_cors()
|
||||||
|
|
||||||
|
# Парсим multipart через cgi.FieldStorage
|
||||||
content_type = self.headers.get("Content-Type", "")
|
content_type = self.headers.get("Content-Type", "")
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
|
|
||||||
# Извлекаем ZIP из multipart
|
# Сохраняем тело во временный файл для cgi.FieldStorage
|
||||||
boundary = content_type.split("boundary=")[1].strip()
|
fp = tempfile.TemporaryFile()
|
||||||
parts = body.split(b"--" + boundary.encode())
|
fp.write(body)
|
||||||
zip_data = None
|
fp.seek(0)
|
||||||
for part in parts:
|
|
||||||
if b"filename=" in part:
|
|
||||||
# Отделяем заголовки от тела
|
|
||||||
header_end = part.find(b"\r\n\r\n")
|
|
||||||
if header_end > 0:
|
|
||||||
zip_data = part[header_end+4:]
|
|
||||||
# Убираем trailing \r\n и boundary
|
|
||||||
if zip_data.endswith(b"\r\n"):
|
|
||||||
zip_data = zip_data[:-2]
|
|
||||||
break
|
|
||||||
|
|
||||||
if not zip_data:
|
env = os.environ.copy()
|
||||||
self.send_error(400, "no file in request")
|
env["REQUEST_METHOD"] = "POST"
|
||||||
|
env["CONTENT_TYPE"] = content_type
|
||||||
|
env["CONTENT_LENGTH"] = str(length)
|
||||||
|
|
||||||
|
form = cgi.FieldStorage(fp=fp, environ=env, keep_blank_values=True)
|
||||||
|
file_item = form.getfirst("file") or form.getfirst("files")
|
||||||
|
if not file_item or not file_item.file:
|
||||||
|
self._send_error_cors(400, "no file in request")
|
||||||
|
fp.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
zip_data = file_item.file.read()
|
||||||
|
fp.close()
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
|
||||||
for name in zf.namelist():
|
for name in zf.namelist():
|
||||||
# Только файлы из корня (без папок), пропускаем директории
|
|
||||||
if name.endswith('/') or '/' in name:
|
if name.endswith('/') or '/' in name:
|
||||||
continue
|
continue
|
||||||
content = zf.read(name)
|
content = zf.read(name)
|
||||||
@@ -317,7 +320,6 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if ext not in ('docx', 'doc', 'pdf'):
|
if ext not in ('docx', 'doc', 'pdf'):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Загрузить в Lucee (hex с \x префиксом для PostgreSQL bytea)
|
|
||||||
content_hex = "\\x" + content.hex()
|
content_hex = "\\x" + content.hex()
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{LUCEE_URL}/upload.cfm",
|
f"{LUCEE_URL}/upload.cfm",
|
||||||
@@ -339,18 +341,30 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
results.append({"filename": name, "error": f"HTTP {resp.status_code}"})
|
results.append({"filename": name, "error": f"HTTP {resp.status_code}"})
|
||||||
|
|
||||||
except zipfile.BadZipFile:
|
except zipfile.BadZipFile:
|
||||||
self.send_error(400, "not a valid ZIP file")
|
self._send_error_cors(400, "not a valid ZIP file")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_error(500, str(e))
|
self._send_error_cors(500, str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
|
self._send_cors()
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({"ok": True, "files": results}, ensure_ascii=False).encode())
|
self.wfile.write(json.dumps({"ok": True, "files": results}, ensure_ascii=False).encode())
|
||||||
|
|
||||||
|
def _send_cors(self):
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
|
def _send_error_cors(self, code, message):
|
||||||
|
self.send_response(code)
|
||||||
|
self._send_cors()
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"ok": False, "error": message}, ensure_ascii=False).encode())
|
||||||
|
|
||||||
def _handle_doc_convert(self):
|
def _handle_doc_convert(self):
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
data = self.rfile.read(length)
|
data = self.rfile.read(length)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img src="/nubes-logo.svg" alt="Nubes">
|
<img src="/nubes-logo.svg" alt="Nubes">
|
||||||
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.122 — Lucee</span></span>
|
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.123 — Lucee</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@@ -176,6 +176,7 @@ lucide.createIcons();
|
|||||||
|
|
||||||
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm';
|
var UPLOAD_URL = 'https://contracts.kube5s.ru/lucee/upload.cfm';
|
||||||
var CONVERT_URL = 'https://contracts.kube5s.ru/convert-doc';
|
var CONVERT_URL = 'https://contracts.kube5s.ru/convert-doc';
|
||||||
|
var UNZIP_URL = 'https://contracts.kube5s.ru/unzip-upload';
|
||||||
var SITE_URL = ''; // same origin for api calls
|
var SITE_URL = ''; // same origin for api calls
|
||||||
|
|
||||||
var fileInput = document.getElementById('fileInput');
|
var fileInput = document.getElementById('fileInput');
|
||||||
@@ -258,20 +259,9 @@ fileInput.addEventListener('change', async function() {
|
|||||||
renderTable();
|
renderTable();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Читаем ZIP через FileReader → base64
|
var fd = new FormData();
|
||||||
var zipData = await new Promise(function(resolve, reject) {
|
fd.append('file', f);
|
||||||
var reader = new FileReader();
|
var zipResp = await fetch(UNZIP_URL, { method: 'POST', body: fd });
|
||||||
reader.onload = function() { resolve(reader.result); };
|
|
||||||
reader.onerror = function() { reject(new Error('FileReader failed')); };
|
|
||||||
reader.readAsDataURL(f);
|
|
||||||
});
|
|
||||||
var zipB64 = zipData.split(',')[1]; // убрать префикс data:application/zip;base64,
|
|
||||||
|
|
||||||
var zipResp = await fetch('/unzip.cfm', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({filename: f.name, data: zipB64, contract_id: contractId || ''})
|
|
||||||
});
|
|
||||||
var zipData = await zipResp.json();
|
var zipData = await zipResp.json();
|
||||||
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
<cfsetting showdebugoutput="no" enablecfoutputonly="true">
|
|
||||||
<cfheader name="Content-Type" value="application/json; charset=utf-8">
|
|
||||||
|
|
||||||
<cfif cgi.request_method NEQ "POST">
|
|
||||||
<cfoutput>{"ok":false,"error":"POST required"}</cfoutput><cfabort>
|
|
||||||
</cfif>
|
|
||||||
|
|
||||||
<cfset body = toString(getHttpRequestData().content)>
|
|
||||||
<cfset data = deserializeJSON(body)>
|
|
||||||
<cfset result = {ok: false, error: "invalid request"}>
|
|
||||||
|
|
||||||
<cftry>
|
|
||||||
<cfif NOT structKeyExists(data, "filename") OR NOT structKeyExists(data, "data")>
|
|
||||||
<cfset result.error = "filename and data required">
|
|
||||||
<cfelse>
|
|
||||||
<cfset zipName = data.filename>
|
|
||||||
<cfset zipB64 = data.data>
|
|
||||||
<cfset zipBytes = binaryDecode(zipB64, "base64")>
|
|
||||||
|
|
||||||
<cfset results = []>
|
|
||||||
<cfset contractId = structKeyExists(data, "contract_id") ? data.contract_id : "">
|
|
||||||
|
|
||||||
<cfset zbais = createObject("java", "java.io.ByteArrayInputStream").init(zipBytes)>
|
|
||||||
<cfset zis = createObject("java", "java.util.zip.ZipInputStream").init(zbais)>
|
|
||||||
|
|
||||||
<cfloop condition="true">
|
|
||||||
<cfset entry = zis.getNextEntry()>
|
|
||||||
<cfif entry is null><cfbreak></cfif>
|
|
||||||
<cfset name = entry.getName()>
|
|
||||||
<!--- Только файлы из корня, не директории --->
|
|
||||||
<cfif name.endsWith("/") OR name contains "/">
|
|
||||||
<cfset zis.closeEntry()>
|
|
||||||
<cfcontinue>
|
|
||||||
</cfif>
|
|
||||||
<cfset ext = listLast(name, ".")>
|
|
||||||
<cfif ext NEQ "docx" AND ext NEQ "doc" AND ext NEQ "pdf">
|
|
||||||
<cfset zis.closeEntry()>
|
|
||||||
<cfcontinue>
|
|
||||||
</cfif>
|
|
||||||
|
|
||||||
<!--- Читаем байты файла --->
|
|
||||||
<cfset buf = createObject("java", "java.io.ByteArrayOutputStream").init()>
|
|
||||||
<cfset tmp = createObject("java", "java.lang.reflect.Array").newInstance(
|
|
||||||
createObject("java", "java.lang.Byte").TYPE, 4096)>
|
|
||||||
<cfloop condition="true">
|
|
||||||
<cfset n = zis.read(tmp)>
|
|
||||||
<cfif n LTE 0><cfbreak></cfif>
|
|
||||||
<cfset buf.write(tmp, 0, n)>
|
|
||||||
</cfloop>
|
|
||||||
<cfset fileBytes = buf.toByteArray()>
|
|
||||||
<cfset zis.closeEntry()>
|
|
||||||
|
|
||||||
<cfset mime = "application/octet-stream">
|
|
||||||
<cfif ext EQ "pdf"><cfset mime = "application/pdf">
|
|
||||||
<cfelseif ext EQ "docx"><cfset mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document">
|
|
||||||
<cfelseif ext EQ "doc"><cfset mime = "application/msword">
|
|
||||||
</cfif>
|
|
||||||
|
|
||||||
<cftransaction>
|
|
||||||
<cfif len(contractId)>
|
|
||||||
<cfquery name="oldD" datasource="baza">
|
|
||||||
SELECT s.id as sid, s.document_id FROM supplements s
|
|
||||||
JOIN documents d ON d.id = s.document_id
|
|
||||||
WHERE s.contract_id = <cfqueryparam value="#contractId#" cfsqltype="cf_sql_varchar">
|
|
||||||
AND d.filename = <cfqueryparam value="#name#" cfsqltype="cf_sql_varchar">
|
|
||||||
</cfquery>
|
|
||||||
<cfif oldD.recordCount GT 0>
|
|
||||||
<cfquery datasource="baza">DELETE FROM supplements WHERE id = <cfqueryparam value="#oldD.sid#" cfsqltype="cf_sql_varchar"></cfquery>
|
|
||||||
<cfquery datasource="baza">DELETE FROM documents WHERE id = <cfqueryparam value="#oldD.document_id#" cfsqltype="cf_sql_varchar"></cfquery>
|
|
||||||
</cfif>
|
|
||||||
</cfif>
|
|
||||||
<cfquery datasource="baza">
|
|
||||||
INSERT INTO documents (filename, mime_type, original_bytes, status) VALUES (
|
|
||||||
<cfqueryparam value="#name#" cfsqltype="cf_sql_varchar">,
|
|
||||||
<cfqueryparam value="#mime#" cfsqltype="cf_sql_varchar">,
|
|
||||||
decode(<cfqueryparam value="#binaryEncode(fileBytes, 'hex')#" cfsqltype="cf_sql_varchar">, 'hex'),
|
|
||||||
'uploaded'
|
|
||||||
)
|
|
||||||
</cfquery>
|
|
||||||
<cfquery name="doc" datasource="baza">
|
|
||||||
SELECT id FROM documents WHERE filename=<cfqueryparam value="#name#" cfsqltype="cf_sql_varchar"> AND status='uploaded' ORDER BY created_at DESC LIMIT 1
|
|
||||||
</cfquery>
|
|
||||||
<cfif NOT len(contractId)>
|
|
||||||
<cfquery datasource="baza">
|
|
||||||
INSERT INTO contracts (number, client) VALUES (<cfqueryparam value="б/н #dateFormat(now(),'yyyymmdd')#-#timeFormat(now(),'HHmm')#" cfsqltype="cf_sql_varchar">,'')
|
|
||||||
</cfquery>
|
|
||||||
<cfquery name="cid" datasource="baza">
|
|
||||||
SELECT id FROM contracts ORDER BY created_at DESC LIMIT 1
|
|
||||||
</cfquery>
|
|
||||||
<cfset contractId = cid.id>
|
|
||||||
</cfif>
|
|
||||||
<cfquery datasource="baza">
|
|
||||||
INSERT INTO supplements (contract_id, document_id, type) VALUES (
|
|
||||||
<cfqueryparam value="#contractId#" cfsqltype="cf_sql_varchar">,
|
|
||||||
<cfqueryparam value="#doc.id#" cfsqltype="cf_sql_varchar">,
|
|
||||||
<cfqueryparam value="additional" cfsqltype="cf_sql_varchar">
|
|
||||||
)
|
|
||||||
</cfquery>
|
|
||||||
</cftransaction>
|
|
||||||
|
|
||||||
<cfset arrayAppend(results, {
|
|
||||||
filename: name,
|
|
||||||
doc_id: doc.id,
|
|
||||||
contract_id: contractId,
|
|
||||||
size: arrayLen(fileBytes)
|
|
||||||
})>
|
|
||||||
</cfloop>
|
|
||||||
<cfset zis.close()>
|
|
||||||
<cfset result = {ok: true, files: results, contract_id: contractId}>
|
|
||||||
</cfif>
|
|
||||||
<cfcatch>
|
|
||||||
<cfset result = {ok: false, error: cfcatch.message}>
|
|
||||||
</cfcatch>
|
|
||||||
</cftry>
|
|
||||||
|
|
||||||
<cfoutput>#serializeJSON(result)#</cfoutput>
|
|
||||||
Reference in New Issue
Block a user