diff --git a/README.md b/README.md new file mode 100644 index 0000000..02aad35 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# liberta — HTTP-сервис конвертации документов Word + +Сервис принимает устаревшие документы Microsoft Word (`.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`) + +## Содержание + +- [Состав репозитория](#состав-репозитория) +- [Как работает](#как-работает) +- [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 +``` + +Никаких других зависимостей, конфигов, БД или внешних сервисов нет. + +## Как работает + +1. Клиент отправляет `POST /convert` с файлом в поле `file` (`multipart/form-data`). +2. Сервер сохраняет входной файл во временный файл с суффиксом `.doc`. +3. Вызывается LibreOffice: + + ```text + libreoffice --headless --convert-to docx --outdir + ``` + + Таймаут вызова — **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`). + +## 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: "}` | +| `.docx` не создан | `500` | `{"ok": false, "error": "conversion produced no .docx"}` | + +Пример: + +```bash +curl -X POST -F "file=@документ.doc" \ + https://liberta.containerk8s.dev.nubes.ru/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`, что подтверждает соответствие +развёрнутого сервиса данному репозиторию.