Add gitignore, proxy-aware base URL and concise README
build-and-push / build (push) Canceled after 0s

This commit is contained in:
Repinoid
2026-10-23 19:14:52 +03:00
parent a1dc34a90c
commit f4a390b5fe
4 changed files with 97 additions and 194 deletions
+51
View File
@@ -0,0 +1,51 @@
# Сборка контейнерного образа liberta и публикация в Gitea Container Registry.
#
# Требования к репозиторию (Settings → Actions → Secrets):
# REGISTRY_USERNAME — пользователь Gitea с правом записи пакетов (write:package)
# REGISTRY_TOKEN — personal access token этого пользователя со scope write:package
#
# Образ публикуется как gitea.services.ngcloud.ru/forcloud/liberta:
# - <VERSION из app.py> — основной тег (latest не используется)
# - <commit sha> — воспроизводимая версия
name: build-and-push
on:
push:
branches: [master]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: gitea.services.ngcloud.ru
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Read VERSION from app.py
id: version
run: |
VERSION=$(grep -oP 'VERSION\s*=\s*"\K[^"]+' app.py | head -1)
test -n "$VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
gitea.services.ngcloud.ru/forcloud/liberta:${{ steps.version.outputs.version }}
gitea.services.ngcloud.ru/forcloud/liberta:${{ github.sha }}
+7
View File
@@ -0,0 +1,7 @@
# Служебные файлы Python
__pycache__/
*.pyc
.venv/
# Подробная архитектура и внутренние заметки — только локально, не публикуются
ARCHITECTURE.md
+19 -190
View File
@@ -1,206 +1,35 @@
# liberta — HTTP-сервис конвертации документов Word
# liberta — конвертер документов Word
Сервис принимает устаревшие документы Microsoft Word (`.doc`, а также `.rtf` и `.odt`)
и возвращает современный `.docx`. Конвертация выполняется **LibreOffice в headless-режиме**
внутри контейнера; исходный файл на диске не сохраняется.
HTTP-сервис: принимает `.doc`, `.rtf`, `.odt` и возвращает `.docx`.
Конвертация — LibreOffice headless в контейнере; входные файлы не сохраняются.
- **Публичный адрес (текущий стенд):** `https://liberta.containerk8s.dev.nubes.ru/`
- **Имя образа:** `gitea.services.ngcloud.ru/nail/contractor-convert:latest`
- **Репозиторий:** `https://gitea.services.ngcloud.ru/forcloud/liberta.git`
- **Версия приложения:** `1.0.0` (константа `VERSION` в `app.py`)
Стек: Python 3.12, Flask, LibreOffice.
## Содержание
## Запуск
- [Состав репозитория](#состав-репозитория)
- [Как работает](#как-работает)
- [API](#api)
- [Запуск локально](#запуск-локально)
- [Сборка и запуск в Docker](#сборка-и-запуск-в-docker)
- [Развёртывание на платформе (Штурвал)](#развёртывание-на-платформе-штурвал)
- [Использование из Obfuscator / DrHider](#использование-из-obfuscator--drhider)
- [Ограничения и известные особенности](#ограничения-и-известные-особенности)
- [Проверка работоспособности](#проверка-работоспособности)
## Состав репозитория
```text
.
├── app.py Flask-приложение: UI, /convert, /health, /static/logo.svg
├── Dockerfile python:3.12 + libreoffice, EXPOSE 5000, CMD python3 app.py
└── requirements.txt flask
```bash
docker build -t liberta:1.0.2 .
docker run --rm -p 5000:5000 liberta:1.0.2
curl -s http://localhost:5000/health
# {"ok":true,"version":"1.0.2"}
```
Никаких других зависимостей, конфигов, БД или внешних сервисов нет.
## Как работает
1. Клиент отправляет `POST /convert` с файлом в поле `file` (`multipart/form-data`).
2. Сервер сохраняет входной файл во временный файл с суффиксом `.doc`.
3. Вызывается LibreOffice:
```text
libreoffice --headless --convert-to docx --outdir <tmpdir> <input>
```
Таймаут вызова — **120 секунд**. `check=True`: ненулевой код выхода считается ошибкой.
4. Из временного каталога берётся первый `.docx`; если его нет — ошибка `conversion produced no .docx`.
5. Файл отдаётся клиенту как вложение (`Content-Disposition: attachment`) с MIME
`application/vnd.openxmlformats-officedocument.wordprocessingml.document`.
6. В `finally` удаляются входной файл, временный каталог и всё его содержимое —
**документы не сохраняются на сервере**.
Дополнительно на `GET /` отдаётся встроенная HTML-страница с формой конвертации
(тот же `/convert` вызывается из браузера через `fetch`).
Без Docker: `pip install -r requirements.txt && python3 app.py`
(нужен LibreOffice в `PATH`).
## API
### `POST /convert`
Конвертирует один файл в `.docx`.
| Параметр | Значение |
| --- | --- |
| Метод | `POST` |
| Content-Type | `multipart/form-data` |
| Поле файла | `file` |
| Поддерживаемые входы | `.doc`, `.rtf`, `.odt` (фактически — всё, что умеет LibreOffice) |
| Успех | `200`, тело — бинарный `.docx`, `Content-Disposition: attachment` |
| Ошибка | `400`/`500`, JSON `{"ok": false, "error": "..."}` |
Коды ошибок:
| Ситуация | Статус | Тело |
| Метод | Путь | Назначение |
| --- | --- | --- |
| Нет поля `file` | `400` | `{"ok": false, "error": "no file"}` |
| Пустое имя файла | `400` | `{"ok": false, "error": "empty filename"}` |
| Превышен таймаут LibreOffice (120 с) | `500` | `{"ok": false, "error": "conversion timeout"}` |
| LibreOffice завершился с ошибкой | `500` | `{"ok": false, "error": "libreoffice: <stderr>"}` |
| `.docx` не создан | `500` | `{"ok": false, "error": "conversion produced no .docx"}` |
| `POST` | `/convert` | `multipart/form-data`, поле `file` → бинарный `.docx` |
| `GET` | `/health` | `{"ok": true, "version": "..."}` |
| `GET` | `/` | HTML-форма конвертации |
Пример:
Ошибки — `400`/`500` с JSON `{"ok": false, "error": "..."}`.
```bash
curl -X POST -F "file=@документ.doc" \
https://liberta.containerk8s.dev.nubes.ru/convert \
-o результат.docx
http://localhost:5000/convert -o результат.docx
```
### `GET /health`
Проверка живости и версии:
```json
{"ok": true, "version": "1.0.0"}
```
Используется платформой и внешними клиентами для liveness/readiness-проверок.
### `GET /`
HTML-интерфейс: логотип, форма загрузки `.doc/.rtf/.odt`, кнопка «Конвертировать»,
краткая справка по API и пример `curl`.
### `GET /static/logo.svg`
Отдаёт встроенный SVG-логотип (data URI в коде страницы, отдельный файл не требуется).
## Запуск локально
Требования:
- Python 3.12+ и Flask (`pip install -r requirements.txt`);
- установленный **LibreOffice** в `PATH` (без него конвертация завершится ошибкой).
```bash
cd liberta
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 app.py
```
Приложение слушает `0.0.0.0:5000`.
## Сборка и запуск в Docker
`Dockerfile` ставит LibreOffice и Flask; других слоёв нет.
```bash
docker build -t contractor-convert:latest .
docker run --rm -p 5000:5000 contractor-convert:latest
```
Проверка:
```bash
curl -s http://localhost:5000/health
# {"ok":true,"version":"1.0.0"}
```
## Развёртывание на платформе (Штурвал)
Текущий стенд: имя экземпляра `liberta`, реалм `iot-naeel`, домен `liberta`,
образ `gitea.services.ngcloud.ru/nail/contractor-convert:latest`.
Параметры, которые нужно учесть при переносе на другой стенд:
1. **Порт жёстко задан `5000`** (`app.run(host="0.0.0.0", port=5000)`).
Переменная окружения `PORT` приложением **не читается** — стенд должен
использовать/пробросить порт `5000` либо потребуется правка кода.
2. **Запускать только как скрипт** — `python3 app.py` (так задано в `CMD`).
`io` импортируется внутри блока `if __name__ == "__main__"`, а `/convert`
использует `io.BytesIO`. При запуске через `gunicorn`/`flask run` (импорт модуля)
`/convert` упадёт с `NameError`.
3. **Ресурсы.** LibreOffice требователен к CPU и памяти: в текущем стенде
`CPU=500m`, `memory=1024 MiB`. Меньшие лимиты или параллельные запросы
приводят к таймаутам/падениям. Запросы к сервису следует выполнять
последовательно (Obfuscator так и делает).
4. **`/tmp`** должен быть доступен для записи и иметь достаточный размер
(входной `.doc`, временный каталог, `.docx`).
5. **Сеть/DNS/TLS** — домен, сертификат и ingress настраиваются в стенде,
в образе их нет.
6. **Тег `latest` мутабелен** — для воспроизводимости фиксируйте digest
или версионированный тег.
7. **Healthcheck** в `Dockerfile` отсутствует, но есть `GET /health` —
probe настраивается на стенде.
## Использование из Obfuscator / DrHider
`drhider/extractor.py::doc_to_markdown()` отправляет `.doc` на этот сервис:
- URL берётся из переменной окружения `CONVERT_SERVICE_URL`
(дефолт в коде — `https://liberta.containerk8s.dev.nubes.ru/convert`);
- запрос: `POST`, `multipart/form-data`, поле `file`, имя файла `input.doc`;
- ожидается ответ `200` с телом `.docx`; далее документ обрабатывается штатным
`docx_to_markdown()`;
- вызовы выполняются **последовательно**: сервис не рассчитан на конкуренцию.
⚠️ Дефолт `CONVERT_SERVICE_URL` указывает на dev-домен
(`containerk8s.dev.nubes.ru`). В продакшене переменную нужно задать явно,
иначе прод обратится к dev-сервису.
## Ограничения и известные особенности
- **Нет аутентификации и rate-limit** — любой, кто знает URL, может конвертировать
файлы и нагружать CPU. Для публичного использования нужен внешний контроль
(ingress-auth / сетевые ограничения).
- **Нет CORS-заголовков** — вызовы из браузера с другого origin заблокируются
(Obfuscator вызывает сервис с бэкенда, CORS не нужен).
- **Один файл за запрос** — пакетная конвертация не поддерживается.
- **Нет кэширования** — каждый запрос запускает новый процесс LibreOffice.
- **Временные файлы** создаются в системном `/tmp`; при аварийном завершении
процесса теоретически могут остаться (штатный путь удаляет их в `finally`).
- **Версия в UI/health** берётся из константы `VERSION` в `app.py` — при изменении
кода поднимайте её.
## Проверка работоспособности
Проверено 2026-10-23 на `https://liberta.containerk8s.dev.nubes.ru`:
| Запрос | Ответ |
| --- | --- |
| `GET /` | `200`, HTML «Конвертер документов — Nubes» |
| `OPTIONS /convert` | `200`, `Allow: POST, OPTIONS` |
| `GET /health` | `200`, `{"ok":true,"version":"1.0.0"}` |
Версия `1.0.0` совпадает с константой в `app.py`, что подтверждает соответствие
развёрнутого сервиса данному репозиторию.
Один файл за запрос; таймаут — 120 с; порт фиксирован (`5000`).
+20 -4
View File
@@ -2,9 +2,14 @@
"""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
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
VERSION = "1.0.0"
# За ingress TLS терминируется на прокси, поэтому Flask видит http.
# ProxyFix восстанавливает схему и хост из X-Forwarded-Proto/X-Forwarded-Host,
# чтобы ссылки на странице были https и с внешним доменом.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=0, x_proto=1, x_host=1)
VERSION = "1.0.2"
# Логотип как 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>"""
@@ -84,13 +89,13 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px}
<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>__BASE_URL__/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>
<code style="word-break:break-all">curl -X POST -F "file=@документ.doc" __BASE_URL__/convert -o результат.docx</code>
</div>
</div>
</div>
@@ -151,7 +156,18 @@ def _convert_file(f):
@app.route("/", methods=["GET"])
def index():
return render_template_string(HTML.replace("__VERSION__", VERSION))
# Базовый URL берётся из текущего запроса, чтобы страница показывала адрес
# того стенда, на котором открыта (prod/dev/localhost), без хардкода домена.
# ProxyFix уже учёл X-Forwarded-*, но если прокси схему не передал, для любого
# внешнего хоста принудительно ставим https — TLS терминируется на ingress.
# PUBLIC_BASE_URL переопределяет адрес целиком (например, при нестандартном прокси).
scheme, host = request.scheme, request.host
if scheme == "http" and not host.startswith(("localhost", "127.0.0.1", "[::1]")):
scheme = "https"
base_url = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") or f"{scheme}://{host}"
return render_template_string(
HTML.replace("__VERSION__", VERSION).replace("__BASE_URL__", base_url)
)
@app.route("/convert", methods=["POST"])
def convert():