From f4a390b5fe4b70454169aa3c094c7303fa5af216 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 23 Oct 2026 19:14:52 +0300 Subject: [PATCH] Add gitignore, proxy-aware base URL and concise README --- .gitea/workflows/build.yaml | 51 +++++++++ .gitignore | 7 ++ README.md | 209 ++++-------------------------------- app.py | 24 ++++- 4 files changed, 97 insertions(+), 194 deletions(-) create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..8533fbf --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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: +# - — основной тег (latest не используется) +# - — воспроизводимая версия +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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..673c1aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Служебные файлы Python +__pycache__/ +*.pyc +.venv/ + +# Подробная архитектура и внутренние заметки — только локально, не публикуются +ARCHITECTURE.md diff --git a/README.md b/README.md index 02aad35..b4c2187 100644 --- a/README.md +++ b/README.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 - ``` - - Таймаут вызова — **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: "}` | -| `.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`). diff --git a/app.py b/app.py index 6c0df5f..73f8321 100644 --- a/app.py +++ b/app.py @@ -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 = """""" @@ -84,13 +89,13 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px}
🔌 API для разработчиков
- Адрес: https://liberta.containerk8s.dev.nubes.ru/convert
+ Адрес: __BASE_URL__/convert
Метод: POST, файл в multipart/form-data, поле file.
Успех: в ответ придёт готовый .docx — сразу сохраняйте.
Ошибка: {"ok": false, "error": "..."} с кодом 500.
Проверка: GET /health → {"ok": true}

Пример curl:
- curl -X POST -F "file=@документ.doc" https://liberta.containerk8s.dev.nubes.ru/convert -o результат.docx + curl -X POST -F "file=@документ.doc" __BASE_URL__/convert -o результат.docx
@@ -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():