Files
contracts/convert-service/app.py
T

182 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""LibreOffice converter — Flask HTTP service. .doc → .docx with UI + API."""
import subprocess, tempfile, os
from flask import Flask, request, send_file, render_template_string
app = Flask(__name__)
VERSION = "1.0.0"
HTML = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Конвертер документов — Nubes</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='57' height='57' xml:space='preserve' overflow='hidden'><defs><clipPath id='c0'><rect x='652' y='420' width='64' height='75'/></clipPath><clipPath id='c1'><rect x='652' y='420' width='64' height='70'/></clipPath></defs><g clip-path='url(%23c0)' transform='matrix(1.01357 0 0 1.01357 -664.97 -440.306)'><g clip-path='url(%23c1)'><path d='m114.028 43.149v7.659c0 3.785-3.08 6.863-6.866 6.863h-21.825c-3.786 0-6.866-3.08-6.866-6.864v-2.528l0.056.009V15.815l-10.382 2.013 0.004 3.877v28.971c0 9.481 7.713 17.192 17.187 17.192l21.836.132c9.474 0 17.187-7.712 17.187-17.193v-2.654l0.027.004V15.815l-10.382 2.012 0.028 25.322z' fill='%23001c34' fill-rule='evenodd' transform='matrix(1 0 0 1.01337 587.92 420.059)'/></g></g></svg>">
<style>
:root{--brand-primary:#2563eb;--brand-primary-dark:#1d4ed8;--brand-grey-light:#f3f4f6;--brand-gray:#d1d5db;--nubes-dark:#001C34}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;color:#1a1a1a;min-height:100vh;background:linear-gradient(135deg,#f0f4ff 0%,#fff 50%,#f0f4ff 100%)}
.logo-bar{display:flex;align-items:center;gap:12px;padding:20px 24px}
.logo{font-size:22px;font-weight:700;color:var(--nubes-dark);letter-spacing:-0.5px}
.logo-bar .badge{background:var(--brand-primary);color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;letter-spacing:.5px}
main{max-width:640px;margin:0 auto;padding:0 16px 40px}
.card{background:#fff;border:1px solid var(--brand-gray);border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.06);overflow:hidden;margin-bottom:20px}
.card-header{background:var(--brand-grey-light);padding:12px 16px;font-size:14px;font-weight:600;color:#374151}
.card-body{padding:16px}
h1{font-size:22px;font-weight:700;margin-bottom:6px}
.sub{color:#6b7280;font-size:14px;line-height:1.5;margin-bottom:20px}
.api-info{font-size:13px;color:#6b7280;line-height:1.7}
.api-info code{background:#eef2ff;color:var(--brand-primary);padding:2px 6px;border-radius:4px;font-size:12px;font-weight:500}
.form-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.file-label{flex:1;min-width:200px;display:block;border:2px dashed var(--brand-gray);border-radius:8px;padding:28px 16px;text-align:center;cursor:pointer;transition:border-color .2s;color:#6b7280;font-size:14px}
.file-label:hover{border-color:var(--brand-primary)}
.file-label.has-file{border-style:solid;border-color:var(--brand-primary);background:#eef2ff}
.file-label input{display:none}
.file-label .filename{color:#1a1a1a;font-weight:500;word-break:break-all}
.btn{background:var(--brand-primary);color:#fff;border:none;padding:10px 28px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;white-space:nowrap;transition:background .15s}
.btn:hover{background:var(--brand-primary-dark)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.result{margin-top:16px;padding:12px 16px;border-radius:8px;font-size:14px;display:none}
.result.show{display:block}
.result.ok{background:#ecfdf5;border:1px solid #a7f3d0;color:#065f46}
.result.err{background:#fef2f2;border:1px solid #fecaca;color:#991b1b}
.result a{color:var(--brand-primary);font-weight:600}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid #ccc;border-top-color:var(--brand-primary);border-radius:50%;animation:spin .8s linear infinite;margin-right:6px;vertical-align:middle}
.footer{text-align:center;padding:0 16px 40px;font-size:12px;color:#9ca3af}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<div class="logo-bar">
<svg viewBox="0 0 8222 1821" style="height:22px"><path fill="#001C34" d="M477 480l-414 0-64 276 179 0 1 1029 0 27 276 0 0-872c0-101 82-184 184-184l589 4c101 0 184 82 184 184l0 871 276 0 0-871c0-254-206-460-460-460l-589-4-162 1 0-1zM6665 1813l1181 0c212 0 376-175 376-387 0-212-173-385-385-385l-653 0c-60 0-109-49-109-109l0-66c0-60 49-109 109-109l696 0 64-276-760 0c-212 0-384 173-384 385l0 66c0 212 172 385 384 385l654 0c60 0 109 49 109 109s-49 116-109 116l-1119 0-54 271zM6201 484l-723 0c-216 0-392 175-392 391l0 486c0 248 202 450 450 450l990 0 62-268-1052 0c-96 0-175-86-175-182l-1-40 923 0 309-2 0-238 0-206c0-216-176-391-392-391zm116 560l-955 0 0-169c0-64 52-116 116-116l723 0c64 0 116 52 116 116l0 169zM4683 1194l0 168c0 101-81 179-182 179l-590 1c-93 0-170-70-182-160l0-458c12-90 89-160 182-160l589 3c101 0 183 83 183 184l0 30 0 213zm-1230-345l-1-795 277-54 0 526c56-24 117-38 182-38l589 4c253 0 459 206 459 458l0 31 0 213 0 168c0 253-206 459-459 459l-589-4c-253 0-459-206-459-459l0-171 0-241 1-98zM3041 1151l0 204c0 101-82 183-183 183l-582-3c-101 0-183-82-183-183l0-68 2 0 0-862-277 54 0 103 0 773c0 253 205 458 458 458l582 4c253 0 459-206 459-459l0-70 0 0 0-863-277 54 1 675z"/></svg>
<span class="badge">КОНВЕРТЕР</span>
<span class="badge" style="background:var(--brand-grey-light);color:var(--nubes-dark);">v__VERSION__</span>
</div>
<main>
<h1>Конвертация старых документов Word</h1>
<p class="sub">
Загрузите файл в формате <strong>.doc</strong> (Microsoft Word 972003),
<strong>.rtf</strong> или <strong>.odt</strong> — получите современный
<strong>.docx</strong>. Конвертация происходит на сервере через LibreOffice,
исходный файл никуда не сохраняется.
</p>
<div class="card">
<div class="card-header">📦 Конвертировать файл</div>
<div class="card-body">
<form id="convForm">
<div class="form-row">
<label class="file-label" id="fileLabel">
<span id="fileText">Выберите файл .doc, .rtf или .odt</span>
<input type="file" name="file" accept=".doc,.rtf,.odt" required id="fileInput">
</label>
<button type="submit" class="btn" id="submitBtn">Конвертировать</button>
</div>
</form>
<div class="result" id="result"></div>
</div>
</div>
<div class="card">
<div class="card-header">🔌 API для разработчиков</div>
<div class="card-body">
<div class="api-info">
<strong>Адрес:</strong> <code>https://liberta.containerk8s.dev.nubes.ru/convert</code><br>
<strong>Метод:</strong> <code>POST</code>, файл в <code>multipart/form-data</code>, поле <code>file</code>.<br>
<strong>Успех:</strong> в ответ придёт готовый <code>.docx</code> — сразу сохраняйте.<br>
<strong>Ошибка:</strong> <code>{"ok": false, "error": "..."}</code> с кодом 500.<br>
<strong>Проверка:</strong> <code>GET /health</code> → <code>{"ok": true}</code><br><br>
<strong>Пример curl:</strong><br>
<code style="word-break:break-all">curl -X POST -F "file=@документ.doc" https://liberta.containerk8s.dev.nubes.ru/convert -o результат.docx</code>
</div>
</div>
</div>
</main>
<footer class="footer">Nubes © 2026 · v__VERSION__</footer>
<script>
var f = document.getElementById('fileInput');
var t = document.getElementById('fileText');
var l = document.getElementById('fileLabel');
f.onchange = function() {
if (f.files.length) { t.className='filename'; t.textContent=f.files[0].name; l.classList.add('has-file'); }
else { t.className=''; t.textContent='Выберите файл .doc, .rtf или .odt'; l.classList.remove('has-file'); }
};
document.getElementById('convForm').onsubmit = async function(e) {
e.preventDefault();
var b = document.getElementById('submitBtn'), r = document.getElementById('result');
b.disabled = true; b.innerHTML = '<span class="spinner"></span>Идёт конвертация...';
r.className = 'result'; r.innerHTML = '';
try {
var fd = new FormData(); fd.append('file', f.files[0]);
var resp = await fetch('/convert', { method: 'POST', body: fd });
if (resp.ok && resp.headers.get('Content-Type').includes('application/vnd')) {
var blob = await resp.blob(), url = URL.createObjectURL(blob);
var fn = f.files[0].name.replace(/\\.[^.]+$/,'') + '.docx';
r.className = 'result ok show';
r.innerHTML = '✅ Готово! <a href=\"'+url+'\" download=\"'+fn+'\">Скачать '+fn+'</a>';
} else { var j = await resp.json();
r.className = 'result err show'; r.textContent = '❌ ' + (j.error || 'Неизвестная ошибка'); }
} catch(ex) { r.className = 'result err show'; r.textContent = '❌ Ошибка соединения: ' + ex.message; }
b.disabled = false; b.textContent = 'Конвертировать';
};
</script>
</body>
</html>"""
def _convert_file(f):
"""Convert one file to .docx, return (bytes, download_name) or raise."""
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmp:
f.save(tmp)
doc_path = tmp.name
tmpdir = tempfile.mkdtemp()
try:
subprocess.run(
["libreoffice", "--headless", "--convert-to", "docx", "--outdir", tmpdir, doc_path],
timeout=120, capture_output=True, check=True
)
docx_files = [x for x in os.listdir(tmpdir) if x.endswith(".docx")]
if not docx_files:
raise ValueError("conversion produced no .docx")
with open(os.path.join(tmpdir, docx_files[0]), "rb") as out:
return out.read(), os.path.splitext(f.filename)[0] + ".docx"
finally:
if os.path.exists(doc_path):
os.unlink(doc_path)
for x in os.listdir(tmpdir):
os.unlink(os.path.join(tmpdir, x))
os.rmdir(tmpdir)
@app.route("/", methods=["GET"])
def index():
return render_template_string(HTML.replace("__VERSION__", VERSION))
@app.route("/convert", methods=["POST"])
def convert():
if "file" not in request.files:
return {"ok": False, "error": "no file"}, 400
f = request.files["file"]
if not f.filename:
return {"ok": False, "error": "empty filename"}, 400
try:
data, name = _convert_file(f)
return send_file(
io.BytesIO(data),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
as_attachment=True,
download_name=name,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "conversion timeout"}, 500
except subprocess.CalledProcessError as e:
err = e.stderr.decode() if e.stderr else "unknown"
return {"ok": False, "error": f"libreoffice: {err}"}, 500
except ValueError as e:
return {"ok": False, "error": str(e)}, 500
@app.route("/health", methods=["GET"])
def health():
return {"ok": True, "version": VERSION}
if __name__ == "__main__":
import io
app.run(host="0.0.0.0", port=5000)