Files
contracts/convert-service/app.py
T

180 lines
9.3 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__)
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" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M16 2C7.7 2 2 8.5 2 16c0 6 3.4 10 8 10h12c5.5 0 8-4.5 8-10C30 8.5 24.3 2 16 2zm0 4c3.8 0 7.5 3 8 7l-1.6.3c-.5-3.2-3-5.3-6.4-5.3-3.5 0-6 2-6.4 5.3L8 13c.5-4 4.2-7 8-7z' fill='%23001c34' fill-rule='evenodd'/></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">
<span class="logo">nubes</span>
<span class="badge">КОНВЕРТЕР</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</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)
@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}
if __name__ == "__main__":
import io
app.run(host="0.0.0.0", port=5000)