Files
contracts/convert-service/app.py
T

191 lines
13 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, base64
from flask import Flask, request, send_file, render_template_string
app = Flask(__name__)
VERSION = "1.0.0"
# Логотип как data URI (как в Сверке — через img, не inline SVG)
_LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd" viewBox="0 0 8221.93 1821.07"><defs><style>.fil0{fill:#001C34}</style></defs><g id="Слой_x0020_1"><path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/><path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/><path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/><path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/><path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/></g></svg>"""
LOGO_DATA_URI = "data:image/svg+xml;base64," + base64.b64encode(_LOGO_SVG.encode()).decode()
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">
<img src="/static/logo.svg" alt="Nubes" style="height:28px">
<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}
@app.route("/static/logo.svg")
def logo():
from flask import Response
return Response(_LOGO_SVG, mimetype="image/svg+xml")
if __name__ == "__main__":
import io
app.run(host="0.0.0.0", port=5000)