Add gitignore, proxy-aware base URL and concise README
build-and-push / build (push) Canceled after 0s
build-and-push / build (push) Canceled after 0s
This commit is contained in:
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Служебные файлы Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
# Подробная архитектура и внутренние заметки — только локально, не публикуются
|
||||
ARCHITECTURE.md
|
||||
@@ -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`).
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user