commit 49bac43e16f30a39b0dec561e7d1c949d0d6644d Author: apuc Date: Fri Oct 16 17:17:47 2026 +0300 Initial commit: certgen — сервис генерации сертификатов diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eea230c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# данные сервиса (БД, сгенерированные сертификаты, фоны шаблонов) +data/ + +# python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ + +# локальное окружение и секреты +.env +*.local + +# редакторы/ОС +.DS_Store +.idea/ +.vscode/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..28536b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +MIT License + +Copyright (c) 2026 apuc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This project bundles the Noto Sans Bold font (certgen/assets/fonts/NotoSans-Bold.ttf), +licensed under the SIL Open Font License 1.1. See +certgen/assets/fonts/LICENSE-NotoSans.txt for details. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb248a8 --- /dev/null +++ b/README.md @@ -0,0 +1,374 @@ +# certgen — сервис генерации сертификатов и благодарственных писем + +Небольшой самодостаточный сервис: вы передаёте **ФИО**, **шаблон** и **дополнительный +текст** — на выходе получаете документ **под номером** и **ссылку** на него +(`/c/<номер>`), а также готовые **PNG** и **PDF**. + +Стек: чистый Python (stdlib) + [Pillow]. Внешний веб-сервер не нужен. +Для HTML-шаблонов дополнительно используется headless Chromium/Chrome, если он есть в системе. + +## Быстрый старт + +```bash +pip install -r requirements.txt # нужен только Pillow +python run.py # http://127.0.0.1:8077 +``` + +Откройте — форма создания документа и список последних. + +Порт/адрес: + +```bash +python run.py --host 0.0.0.0 --port 8080 +# или через переменные окружения: +CERTGEN_HOST=0.0.0.0 CERTGEN_PORT=8080 python run.py +``` + +## Что умеет + +- **Встроенные шаблоны**: `classic` (сертификат, A4 альбомная) и `letter` + (благодарственное письмо, A4 книжная) — работают сразу, без файлов. +- **Свои шаблоны-картинки**: загружаете фон (PNG/JPG) и описываете расположение + текста в JSON — ФИО, заголовок, текст, дата, номер, любые свои поля. +- **HTML-шаблоны**: любой HTML/CSS с плейсхолдерами `{{fio}}`, `{{title}}`, + `{{body}}`, `{{date}}`, `{{number}}` и своими полями. PDF собирается Chromium. +- **Нумерация**: сквозная. Первые **1000** номеров зарезервированы под особые + случаи, обычная нумерация начинается с **1001** (настраивается). Необязательный + префикс: `СЕРТ-001001`. +- **Аккаунты и роли**: регистрация и вход по email+пароль, сессии в cookie. + Обычный пользователь видит только свои документы; администратор создаёт + документы и управляет пользователями. +- **Ссылка на документ**: `/c/` (доступна только вошедшим — получателю + или администратору). Плюс экспорт PNG/PDF. +- **Публичные ссылки**: админ может включить для документа публичную ссылку + `/s/<токен>` — она открывается без входа (для пересылки в письме, мессенджере). +- **Пакетная генерация**: JSON-эндпоинт `/api/certificates/batch` и CLI из CSV. +- **Хранение**: SQLite + файлы в `data/`. + +## Веб-интерфейс + +- `/register`, `/login` — регистрация и вход по email и паролю. +- `/` — кабинет. Администратор видит форму создания и **все** документы; обычный + пользователь видит только **свои** документы (карточки с превью и скачиванием). +- `/users` — управление пользователями (только админ): создание, смена роли, + включение/выключение, сброс пароля, удаление. + +Форма создания доступна только админу. При создании указывается **email +получателя** — этот пользователь увидит документ после входа под своим email. + +## Пользователи и роли + +- **Первый зарегистрированный пользователь становится администратором.** + Дальше регистрируются обычные пользователи (роль `user`). +- Пароли хранятся как PBKDF2-HMAC-SHA256 (200 000 итераций, соль на пользователя). +- Вход выдаёт сессию в cookie `certgen_session` (HttpOnly, SameSite=Lax, Secure за + HTTPS, срок 30 дней). +- **Права**: + + | Действие | user | admin | + |---|---|---| + | Регистрация/вход/смена своего пароля | ✅ | ✅ | + | Просмотр своих документов | ✅ | ✅ (все) | + | Создание/удаление документов, предпросмотр | ❌ | ✅ | + | Шаблоны, пакетное создание | ❌ | ✅ | + | Управление пользователями | ❌ | ✅ | + +- Ограничение попыток входа: не более 10 за 5 минут с одного IP. +- Регистрацию можно закрыть: `CERTGEN_REGISTRATION=closed` (первый админ + создаётся через админку или CLI). Проверить/создать админа вручную: + +```bash +CERTGEN_DATA=/path/to/data python3 - <<'PY' +from certgen import store, auth +store.init_db() +if not store.get_user_by_email("admin@example.com"): + store.create_user("admin@example.com", auth.hash_password("сильный-пароль"), "Админ", "admin") + print("admin создан") +PY +``` + +## HTTP API + +Базовый адрес по умолчанию — `http://127.0.0.1:8077`. +Все запросы, кроме `/health`, `/login`, `/register` и `POST /api/auth/*`, +требуют активной сессии (cookie `certgen_session`), а часть — прав администратора. + +### Аутентификация + +``` +POST /api/auth/register { "email": "...", "password": "...", "name": "..." } +POST /api/auth/login { "email": "...", "password": "..." } +POST /api/auth/logout +POST /api/auth/password { "current": "...", "password": "новый" } +GET /api/auth/me +``` + +Успешный вход/регистрация ставит cookie сессии. Первый пользователь получает +роль `admin`. + +### Пользователи (только админ) + +``` +GET /api/users +POST /api/users { "email", "password", "name", "role": "user|admin" } +PATCH /api/users/ { "role"?, "is_active"?, "name"?, "password"? } +DELETE /api/users/ +``` + +### Создать документ (только админ) + +``` +POST /api/certificates +Content-Type: application/json + +{ + "template": "classic", // slug шаблона + "fio": "Иванов Иван Иванович", // обязательно + "email": "user@example.com", // email получателя: он увидит документ у себя + "title": "СЕРТИФИКАТ", // необязательно (по умолчанию из шаблона) + "body": "за успешное прохождение курса «...»", + "date": "16 октября 2026", // необязательно, по умолчанию — сегодня + "prefix": "СЕРТ", // необязательно + "number": 7, // необязательно: особый номер из резерва 1..1000 + "fields": { "custom": "значение" } // необязательно, свои поля {custom} +} +``` + +Ответ (`201`): + +```json +{ + "seq": 1001, + "number": "СЕРТ-001001", + "template": "classic", + "fio": "Иванов Иван Иванович", + "recipient_email": "user@example.com", + "url": "https://cert.my-dpr.ru/c/1001", + "page": "https://cert.my-dpr.ru/c/1001", + "png": "https://cert.my-dpr.ru/c/1001.png", + "pdf": "https://cert.my-dpr.ru/c/1001.pdf" +} +``` + +### Остальные эндпоинты + +| Метод | Путь | Доступ | Описание | +|--------|------|--------|----------| +| GET | `/health` | все | проверка живости | +| GET | `/`, `/login`, `/register`, `/users` | — | веб-страницы | +| GET | `/api/templates[/]` | админ | шаблоны | +| POST/DELETE | `/api/templates[...]` | админ | изменение шаблонов | +| GET | `/api/certificates?limit=&offset=` | вход | user — свои, admin — все | +| GET | `/api/certificates/` | вход | один документ (с проверкой владения) | +| DELETE | `/api/certificates/` | админ | удалить документ и файлы | +| POST | `/api/certificates/batch` | админ | пакетное создание | +| POST | `/api/preview` | админ | вернуть PNG без сохранения | +| POST | `/api/certificates//share` | админ | включить/обновить публичную ссылку | +| DELETE | `/api/certificates//share` | админ | отключить публичную ссылку | +| GET | `/c/` и `.png` / `.pdf` | вход | доступ: получатель или админ | +| GET | `/s/<токен>` и `.png` / `.pdf` | **публично** | доступ по публичной ссылке | + +### Публичные ссылки + +Администратор включает публичную ссылку в кабинете (кнопка «Сделать публичной») +или через API: + +```bash +curl -X POST https://cert.my-dpr.ru/api/certificates/1001/share -b cookies.txt +# -> { "public_url": "https://cert.my-dpr.ru/s/", ... } + +# отключить: +curl -X DELETE https://cert.my-dpr.ru/api/certificates/1001/share -b cookies.txt +``` + +Публичная ссылка не требует входа и отдаёт страницу, PNG и PDF. Токен случайный +(≈43 символа); при включении заново выдается новый токен, старый перестаёт +работать. Чтобы закрыть доступ — отключите публичную ссылку. + +### Пакетное создание (только админ) + +``` +POST /api/certificates/batch +{ + "template": "letter", + "body": "за участие", + "names": ["Первый Участник", "Второй Участник"] +} +``` + +В `names` можно передавать строки (ФИО) или объекты с полями конкретного документа. + +## Нумерация + +- Обычные документы нумеруются последовательно, **начиная с 1001**: + `001001`, `001002`, … (ширину ведущих нулей задаёт `CERTGEN_PAD`, по умолчанию 6). +- Диапазон **1…1000 зарезервирован под особые случаи**. Чтобы выдать конкретный + номер оттуда, передайте `number` (в API/CLI или поле «Особый номер» в форме). + Повторное использование занятого номера вернёт ошибку. +- Номер отображается как `number` (с префиксом, если задан), а ссылка строится по + внутреннему `seq`; работает и открытие по самому номеру: `/c/<номер>`. +- Стартовый номер меняется переменной `CERTGEN_START` (например, `CERTGEN_START=1`, + чтобы вообще не резервировать диапазон). + +```bash +# особый случай под номером 7 +curl -X POST localhost:8077/api/certificates -H 'Content-Type: application/json' \ + -d '{"template":"classic","fio":"Иванов И.И.","number":7}' +``` + +## Шаблоны + +### Встроенные + +- `classic` — «СЕРТИФИКАТ», A4 альбомная (1754×1240). +- `letter` — «БЛАГОДАРСТВЕННОЕ ПИСЬМО», A4 книжная (1240×1754). + +### Шаблон по готовому дизайну (пример «Созидатели») + +Если у вас есть готовый макет (PNG/JPG), где заголовок, текст и подпись уже +нарисованы, а пустое место оставлено под ФИО — такой шаблон делается за минуту: +фон = картинка, поверх накладывается только имя. + +```bash +python examples/make_sozidateli_template.py "Сертификат Созидатели.png" +``` + +Скрипт увеличивает фон ×2, кладёт его в `data/templates/custom//background.png` +и регистрирует image-шаблон `sozidateli` (ФИО по центру, шрифт NotoSans-Bold). +Готовый макет и логику можно взять за образец для других дизайнов. + +> В проект вложен шрифт `certgen/assets/fonts/NotoSans-Bold.ttf` (лицензия OFL, +> см. `LICENSE-NotoSans.txt`) — чтобы рендер выглядел одинаково на любой машине, +> даже если системного Noto Sans нет. + +### Свой шаблон-картинка + +``` +POST /api/templates +{ + "slug": "dark", + "title": "Тёмный сертификат", + "kind": "image", + "background_b64": "", + "page": { "width": 1600, "height": 1131 }, + "defaults": { "title": "СЕРТИФИКАТ", "body": "подтверждение участия" }, + "layout": [ + { "key": "title", "x": 0.5, "y": 0.2, "size": 90, "font": "sans-bold", + "color": "#e0bd57", "align": "center", "uppercase": true, "letter_spacing": 8 }, + { "key": "fio", "x": 0.5, "y": 0.45, "size": 96, "font": "serif-bold", + "color": "#ffffff", "align": "center", "max_width": 0.85 }, + { "text": "выдан 2026", "x": 0.5, "y": 0.9, "size": 26, "color": "#888", + "align": "center" } + ] +} +``` + +Поля элемента `layout`: + +- `key` — имя подставляемого значения (`fio`, `title`, `body`, `date`, `number` + или ключ из `fields`); вместо `key` можно задать `text` — статичный текст. +- `x`, `y` — координаты: доля от 0 до 1 (от ширины/высоты фона) либо пиксели. +- `size` — размер шрифта в px относительно `page.height`. +- `font` — `serif`, `serif-bold`, `serif-italic`, `sans`, `sans-bold`, + `sans-italic`, `mono` или имя/путь к TTF. +- `color` — `#rrggbb`; `align` — `left` / `center` / `right`. +- `max_width` — доля ширины для переноса строк (необязательно). +- `uppercase`, `letter_spacing`, `line_spacing` — оформление (необязательно). + +В любом тексте (и в `text`, и в `body`) можно писать `{fio}`, `{{fio}}`, +`{custom}` — значения подставятся. + +### HTML-шаблон + +``` +POST /api/templates +{ + "slug": "htmlcert", + "title": "HTML сертификат", + "kind": "html", + "html": "... {{fio}} ... {{number}} ...", + "page": { "width": 1600, "height": 1131 }, + "defaults": { "title": "СЕРТИФИКАТ", "body": "подтверждает участие" } +} +``` + +Готовый пример — `examples/sample.html`. Задайте размер страницы через `@page` +(для PDF) и заполняйте кадр через `100vw/100vh` (для PNG-скриншота, размер которого +берётся из `page.width` × `page.height`). + +> HTML-шаблоны требуют Chromium/Chrome. Если его нет, они вернут понятную ошибку, +> а картинки-шаблоны продолжат работать. + +## CLI (пакетно, без сервера) + +```bash +# один документ +python -m certgen.gen --template classic \ + --fio "Иванов Иван Иванович" --body "за успешное прохождение курса" --outdir out + +# из CSV (см. examples/names.csv), с копированием файлов в out/ +python -m certgen.gen --template letter --csv examples/names.csv --outdir out + +# список через ';' +python -m certgen.gen --template classic --names "Иванов И.И.;Петров П.П." +``` + +Документы сохраняются в общую базу, поэтому нумерация общая с сервером, и позже +их можно открыть по ссылке `/c/<номер>`. CLI работает с локальной базой напрямую +(в обход веб-авторизации) — для этого нужен доступ к файлу `data/certs.db`. +Чтобы привязать документ к получателю, передайте `--email user@example.com`. + +## Данные + +``` +data/ + certs.db # SQLite: документы и шаблоны + output/000001.png # сгенерированные файлы + output/000001.pdf + templates/ # фоны шаблонов + tmp/ # временные файлы +``` + +Каталог данных можно вынести: `CERTGEN_DATA=/path/to/data`. + +## Развёртывание + +- Сервис рассчитан на запуск за reverse-proxy (nginx/caddy). Публичный адрес для + ссылок задайте явно: `CERTGEN_BASE_URL=https://certs.example.org`. +- Если проксируете, включите передачу заголовков `Host` и `X-Forwarded-Proto`. +- Для доступа извне: `python run.py --host 0.0.0.0` и, желательно, авторизация на прокси. +- Запуск как systemd-юнита: + +```ini +[Unit] +Description=certgen +After=network.target +[Service] +WorkingDirectory=/opt/certgen +ExecStart=/usr/bin/python3 run.py --host 127.0.0.1 --port 8077 +Environment=CERTGEN_BASE_URL=https://certs.example.org +Restart=on-failure +[Install] +WantedBy=multi-user.target +``` + +## Переменные окружения + +| Переменная | По умолчанию | Значение | +|-----------|--------------|----------| +| `CERTGEN_HOST` | `127.0.0.1` | адрес прослушивания | +| `CERTGEN_PORT` | `8077` | порт | +| `CERTGEN_DATA` | `./data` | каталог данных | +| `CERTGEN_BASE_URL` | — | публичный базовый URL для ссылок | +| `CERTGEN_PDF_DPI` | `150` | DPI при экспорте картинки в PDF | +| `CERTGEN_START` | `1001` | первый номер обычной нумерации (1..START-1 — резерв) | +| `CERTGEN_PAD` | `6` | ширина номера с ведущими нулями | +| `CERTGEN_REGISTRATION` | `open` | `open` — свободная регистрация; `closed` — только админ | +| `CERTGEN_ADMIN_EMAIL` | — | email, получающий роль админа при регистрации | +| `CERTGEN_SESSION_DAYS` | `30` | срок жизни сессии в днях | + +## Лицензия + +MIT (используйте и меняйте свободно). + +[Pillow]: https://python-pillow.org/ diff --git a/certgen/__init__.py b/certgen/__init__.py new file mode 100644 index 0000000..3cade65 --- /dev/null +++ b/certgen/__init__.py @@ -0,0 +1,7 @@ +"""certgen — небольшой сервис генерации сертификатов, благодарственных писем и пр. + +Каждому документу присваивается номер; документ доступен по ссылке /c/<номер>. +Чистый Python stdlib + Pillow (+ опционально Chromium для HTML-шаблонов). +""" + +__version__ = "0.1.0" diff --git a/certgen/__main__.py b/certgen/__main__.py new file mode 100644 index 0000000..2293ab3 --- /dev/null +++ b/certgen/__main__.py @@ -0,0 +1,19 @@ +"""Запуск: python -m certgen [--host H] [--port P]""" +from __future__ import annotations + +import argparse + +from . import config +from .app import run + + +def main() -> None: + parser = argparse.ArgumentParser(prog="certgen", description="Сервис генерации сертификатов") + parser.add_argument("--host", default=config.HOST, help="адрес (по умолчанию 127.0.0.1)") + parser.add_argument("--port", type=int, default=config.PORT, help="порт (по умолчанию 8077)") + args = parser.parse_args() + run(args.host, args.port) + + +if __name__ == "__main__": + main() diff --git a/certgen/app.py b/certgen/app.py new file mode 100644 index 0000000..cfccf47 --- /dev/null +++ b/certgen/app.py @@ -0,0 +1,622 @@ +"""HTTP-сервис certgen: авторизация, API и веб-интерфейс.""" +from __future__ import annotations + +import base64 +import json +import re +import tempfile +import threading +import time +import traceback +from collections import defaultdict, deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, quote, unquote, urlparse + +from . import __version__, auth, config, store, ui +from .render import render_certificate +from .service import build_values, cert_api, create_certificate + +SLUG_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") +CERT_FILE_RE = re.compile(r"^/c/([^/]+?)\.(png|pdf)$") +CERT_PAGE_RE = re.compile(r"^/c/([^/]+)$") + +COOKIE_NAME = "certgen_session" + +# Простейший ограничитель попыток входа: ip -> очередь меток времени. +_LOGIN_ATTEMPTS: dict[str, deque] = defaultdict(deque) +_LOGIN_LOCK = threading.Lock() +LOGIN_MAX = 10 +LOGIN_WINDOW = 300 # секунд + + +def _login_allowed(ip: str) -> bool: + now = time.time() + with _LOGIN_LOCK: + q = _LOGIN_ATTEMPTS[ip] + while q and now - q[0] > LOGIN_WINDOW: + q.popleft() + if len(q) >= LOGIN_MAX: + return False + q.append(now) + return True + + +def _base_url(handler: BaseHTTPRequestHandler) -> str: + if config.BASE_URL: + return config.BASE_URL + proto = handler.headers.get("X-Forwarded-Proto", "http") + host = handler.headers.get("Host") or f"{config.HOST}:{config.PORT}" + return f"{proto}://{host}" + + +def _public_cert(cert: dict) -> dict: + """Убрать из ответа локальные пути к файлам.""" + out = dict(cert) + out.pop("png_path", None) + out.pop("pdf_path", None) + return out + + +class Handler(BaseHTTPRequestHandler): + server_version = f"certgen/{__version__}" + + # ------------------------------------------------------------ низкий уровень + def _send(self, code: int, content_type: str, body: bytes, extra: dict | None = None): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _json(self, obj, code: int = 200, extra: dict | None = None): + self._send(code, "application/json; charset=utf-8", + json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8"), extra) + + def _error(self, code: int, message: str): + self._json({"error": message}, code) + + def _redirect(self, location: str, extra: dict | None = None): + self._send(302, "text/plain; charset=utf-8", b"", {"Location": location, **(extra or {})}) + + def _html(self, markup: str, code: int = 200, extra: dict | None = None): + self._send(code, "text/html; charset=utf-8", markup.encode("utf-8"), extra) + + def _read_body(self) -> dict: + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + raw = self.rfile.read(length) + try: + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def log_message(self, fmt, *args): + print(f"[certgen] {self.address_string()} - {fmt % args}", flush=True) + + # ------------------------------------------------------------------- сессии + def _cookies(self) -> dict: + out = {} + for part in (self.headers.get("Cookie") or "").split(";"): + if "=" in part: + k, v = part.strip().split("=", 1) + out[k] = v + return out + + def _current_user(self) -> dict | None: + token = self._cookies().get(COOKIE_NAME) + return store.get_session_user(token) if token else None + + def _is_https(self) -> bool: + if config.BASE_URL.startswith("https://"): + return True + return (self.headers.get("X-Forwarded-Proto", "").lower() == "https") + + def _session_cookie(self, token: str) -> str: + cookie = (f"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; " + f"Max-Age={config.SESSION_TTL_DAYS * 86400}") + if self._is_https(): + cookie += "; Secure" + return cookie + + def _clear_cookie(self) -> str: + cookie = f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + if self._is_https(): + cookie += "; Secure" + return cookie + + def _start_session(self, user: dict) -> str: + token = auth.new_token() + store.create_session(token, user["id"], config.SESSION_TTL_DAYS) + return self._session_cookie(token) + + # ------------------------------------------------------------------ доступ + def _require_api_user(self) -> dict | None: + user = self._current_user() + if not user: + self._error(401, "Требуется вход") + return None + return user + + def _require_api_admin(self) -> dict | None: + user = self._require_api_user() + if user and user["role"] != "admin": + self._error(403, "Доступно только администратору") + return None + return user + + def _can_access(self, user: dict, cert: dict) -> bool: + if user["role"] == "admin": + return True + recipient = (cert.get("recipient_email") or "").lower() + return bool(recipient) and recipient == user["email"].lower() + + def _cert_accessible(self, user: dict, ref: str) -> dict | None: + cert = store.get_certificate(ref) + if cert and self._can_access(user, cert): + return cert + return None + + # --------------------------------------------------------------------- GET + def do_GET(self): + try: + self._route_get() + except Exception as exc: # noqa: BLE001 + traceback.print_exc() + self._error(500, str(exc)) + + def _route_get(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) + base = _base_url(self) + user = self._current_user() + + if path == "/health": + return self._json({"ok": True, "version": __version__}) + + # --- публичные ссылки (без входа) + m = re.match(r"^/s/([^/.]+)\.(png|pdf)$", path) + if m: + cert = store.get_certificate_by_token(m.group(1)) + if not cert: + return self._error(404, "Ссылка недействительна") + return self._send_cert_file(cert, m.group(2)) + m = re.match(r"^/s/([^/]+)$", path) + if m: + cert = store.get_certificate_by_token(m.group(1)) + if not cert: + return self._error(404, "Ссылка недействительна") + size = None + png = cert.get("png_path") + if png and Path(png).exists(): + try: + from PIL import Image + with Image.open(png) as im: + size = im.size + except Exception: + size = None + return self._html(ui.public_cert_page_html(cert, base, size)) + + if path in ("/login", "/register"): + if user: + return self._redirect("/") + mode = "register" if path == "/register" else "login" + return self._html(ui.auth_page_html(mode)) + + if path == "/logout": + token = self._cookies().get(COOKIE_NAME) + if token: + store.delete_session(token) + return self._redirect("/login", {"Set-Cookie": self._clear_cookie()}) + + if path == "/": + if not user: + return self._redirect("/login") + return self._html(ui.dashboard_html(user, base)) + + if path == "/users": + if not user: + return self._redirect("/login") + if user["role"] != "admin": + return self._error(403, "Доступно только администратору") + return self._html(ui.users_page_html(user, base)) + + # --- API авторизации + if path == "/api/auth/me": + if not user: + return self._error(401, "Требуется вход") + return self._json(user) + + # --- шаблоны (нужны админу для формы создания) + if path == "/api/templates": + if not self._require_api_admin(): + return + return self._json(store.list_templates()) + m = re.match(r"^/api/templates/([^/]+)$", path) + if m: + if not self._require_api_admin(): + return + tpl = store.get_template(m.group(1)) + return self._json(tpl) if tpl else self._error(404, "Шаблон не найден") + + # --- пользователи (админ) + if path == "/api/users": + if not self._require_api_admin(): + return + return self._json(store.list_users()) + + # --- сертификаты + if path == "/api/certificates": + if not user: + return self._error(401, "Требуется вход") + q = parse_qs(parsed.query) + limit = min(int(q.get("limit", ["50"])[0]), 500) + offset = max(int(q.get("offset", ["0"])[0]), 0) + recipient = None if user["role"] == "admin" else user["email"] + items = store.list_certificates(limit, offset, recipient_email=recipient) + return self._json([_public_cert(cert_api(c, base)) for c in items]) + m = re.match(r"^/api/certificates/([^/]+)$", path) + if m: + if not user: + return self._error(401, "Требуется вход") + cert = self._cert_accessible(user, m.group(1)) + if not cert: + return self._error(404, "Документ не найден") + return self._json(_public_cert(cert_api(cert, base))) + + # --- файлы и страница документа + m = CERT_FILE_RE.match(path) + if m: + if not user: + return self._error(401, "Требуется вход") + return self._serve_file(user, m.group(1), m.group(2)) + m = CERT_PAGE_RE.match(path) + if m: + if not user: + return self._redirect(f"/login?next={quote(self.path)}") + cert = self._cert_accessible(user, m.group(1)) + if not cert: + return self._error(404, "Документ не найден") + return self._html(ui.cert_page_html(cert, base)) + + self._error(404, "Не найдено") + + def _serve_file(self, user: dict, ref: str, ext: str): + cert = self._cert_accessible(user, ref) + if not cert: + return self._error(404, "Документ не найден") + return self._send_cert_file(cert, ext) + + def _send_cert_file(self, cert: dict, ext: str): + path = cert.get("png_path" if ext == "png" else "pdf_path") + if not path or not Path(path).exists(): + return self._error(404, "Файл ещё не готов") + data = Path(path).read_bytes() + ctype = "image/png" if ext == "png" else "application/pdf" + ascii_name = f"document-{cert['seq']}.{ext}" + utf8_name = quote(f"{cert['number']}.{ext}") + disp = f'inline; filename="{ascii_name}"; filename*=UTF-8\'\'{utf8_name}' + self._send(200, ctype, data, {"Content-Disposition": disp}) + + # -------------------------------------------------------------------- POST + def do_POST(self): + try: + self._route_post() + except ValueError as exc: + self._error(400, str(exc)) + except Exception as exc: # noqa: BLE001 + traceback.print_exc() + self._error(500, str(exc)) + + def _route_post(self): + parsed = urlparse(self.path) + path = unquote(parsed.path) + base = _base_url(self) + + if path == "/api/auth/register": + return self._register() + if path == "/api/auth/login": + return self._login() + if path == "/api/auth/logout": + token = self._cookies().get(COOKIE_NAME) + if token: + store.delete_session(token) + return self._json({"ok": True}, extra={"Set-Cookie": self._clear_cookie()}) + if path == "/api/auth/password": + return self._change_password() + + user = self._current_user() + + # публичная ссылка: включение/обновление токена (только админ) + m = re.match(r"^/api/certificates/([^/]+)/share$", path) + if m: + if not user or user["role"] != "admin": + return self._error(403 if user else 401, + "Доступно только администратору" if user else "Требуется вход") + cert = store.set_public_token(m.group(1), auth.new_token()) + if not cert: + return self._error(404, "Документ не найден") + return self._json(_public_cert(cert_api(cert, base))) + + if path in ("/api/certificates", "/api/certificates/batch", "/api/preview", "/api/templates"): + if not user or user["role"] != "admin": + return self._error(403 if user else 401, + "Доступно только администратору" if user else "Требуется вход") + + if path == "/api/certificates": + body = self._read_body() + slug = (body.get("template") or "classic").strip() + template = store.get_template(slug) + if not template: + return self._error(400, f"Неизвестный шаблон: {slug}") + if not (body.get("fio") or "").strip(): + return self._error(400, "Поле 'fio' обязательно") + return self._json(_public_cert(create_certificate(template, body, base, created_by=user["id"])), 201) + + if path == "/api/certificates/batch": + body = self._read_body() + slug = (body.get("template") or "classic").strip() + template = store.get_template(slug) + if not template: + return self._error(400, f"Неизвестный шаблон: {slug}") + names = body.get("names") or [] + if not isinstance(names, list) or not names: + return self._error(400, "Поле 'names' должно быть непустым списком") + created = [] + for name in names: + item = dict(body) + item.pop("names", None) + if isinstance(name, dict): + item.update(name) + else: + item["fio"] = str(name) + created.append(_public_cert(create_certificate(template, item, base, created_by=user["id"]))) + return self._json(created, 201) + + if path == "/api/preview": + body = self._read_body() + slug = (body.get("template") or "classic").strip() + template = store.get_template(slug) + if not template: + return self._error(400, f"Неизвестный шаблон: {slug}") + values = build_values(template, body) + values["number"] = body.get("number") or "000000" + with tempfile.TemporaryDirectory(dir=config.TMP_DIR) as td: + tmp_png = Path(td) / "preview.png" + tmp_pdf = Path(td) / "preview.pdf" + render_certificate(template, values, tmp_png, tmp_pdf) + data = tmp_png.read_bytes() + return self._send(200, "image/png", data, {"Cache-Control": "no-store"}) + + if path == "/api/templates": + return self._create_template() + + if path == "/api/users": + return self._create_user() + + self._error(404, "Не найдено") + + # ------------------------------------------------------------ операции auth + def _register(self): + body = self._read_body() + email = (body.get("email") or "").strip() + password = body.get("password") or "" + name = (body.get("name") or "").strip() or None + + is_first = store.count_users() == 0 + if not is_first and config.REGISTRATION != "open": + return self._error(403, "Регистрация закрыта. Обратитесь к администратору.") + + err = auth.validate_registration(email, password) + if err: + return self._error(400, err) + if store.get_user_by_email(email): + return self._error(400, "Пользователь с таким email уже существует") + + role = "admin" if (is_first or email.lower() == config.ADMIN_EMAIL and config.ADMIN_EMAIL) else "user" + try: + user = store.create_user(email, auth.hash_password(password), name, role) + except ValueError as exc: + return self._error(400, str(exc)) + cookie = self._start_session(user) + return self._json(user, 201, {"Set-Cookie": cookie}) + + def _login(self): + ip = self.client_address[0] + if not _login_allowed(ip): + return self._error(429, "Слишком много попыток входа. Попробуйте позже.") + body = self._read_body() + email = (body.get("email") or "").strip() + password = body.get("password") or "" + user = store.get_user_by_email(email) + if not user or not user["is_active"] or not auth.verify_password( + password, store.get_password_hash(user["id"])): + return self._error(401, "Неверный email или пароль") + cookie = self._start_session(user) + return self._json(user, 200, {"Set-Cookie": cookie}) + + def _change_password(self): + user = self._require_api_user() + if not user: + return + body = self._read_body() + if not auth.verify_password(body.get("current") or "", store.get_password_hash(user["id"])): + return self._error(400, "Текущий пароль неверен") + new_password = body.get("password") or "" + if len(new_password) < auth.MIN_PASSWORD: + return self._error(400, f"Пароль должен быть не короче {auth.MIN_PASSWORD} символов") + store.update_user(user["id"], password_hash=auth.hash_password(new_password)) + return self._json({"ok": True}) + + # ---------------------------------------------------------- операции admin + def _create_template(self): + user = self._current_user() + if not user or user["role"] != "admin": + return self._error(403, "Доступно только администратору") + body = self._read_body() + slug = (body.get("slug") or "").strip() + if not SLUG_RE.match(slug): + return self._error(400, "Некорректный slug (a-z, 0-9, _, -)") + kind = body.get("kind", "image") + if kind == "html": + html = body.get("html") or "" + if not html.strip(): + return self._error(400, "Для html-шаблона нужен 'html'") + tpl = store.upsert_template( + slug=slug, title=body.get("title") or slug, kind="html", + html=html, page=body.get("page"), defaults=body.get("defaults")) + return self._json(tpl, 201) + bg_b64 = body.get("background_b64") or "" + if not bg_b64: + return self._error(400, "Для image-шаблона нужен 'background_b64'") + try: + raw = base64.b64decode(bg_b64.split(",")[-1]) + except Exception: + return self._error(400, "Некорректный base64 фон") + from PIL import Image + import io + bg_dir = config.TEMPLATES_DIR / "custom" / slug + bg_dir.mkdir(parents=True, exist_ok=True) + bg_path = bg_dir / "background.png" + img = Image.open(io.BytesIO(raw)).convert("RGB") + img.save(bg_path, "PNG") + rel = str(bg_path.relative_to(config.TEMPLATES_DIR)) + tpl = store.upsert_template( + slug=slug, title=body.get("title") or slug, kind="image", + background=rel, layout=body.get("layout") or [], + page=body.get("page") or {"width": img.width, "height": img.height}, + defaults=body.get("defaults")) + return self._json(tpl, 201) + + def _create_user(self): + body = self._read_body() + email = (body.get("email") or "").strip() + password = body.get("password") or "" + name = (body.get("name") or "").strip() or None + role = body.get("role") if body.get("role") in ("admin", "user") else "user" + err = auth.validate_registration(email, password) + if err: + return self._error(400, err) + if store.get_user_by_email(email): + return self._error(400, "Пользователь с таким email уже существует") + user = store.create_user(email, auth.hash_password(password), name, role) + return self._json(user, 201) + + # ------------------------------------------------------------------- PATCH + def do_PATCH(self): + try: + parsed = urlparse(self.path) + path = unquote(parsed.path) + user = self._current_user() + if not user or user["role"] != "admin": + return self._error(403 if user else 401, + "Доступно только администратору" if user else "Требуется вход") + m = re.match(r"^/api/users/(\d+)$", path) + if not m: + return self._error(404, "Не найдено") + uid = int(m.group(1)) + target = store.get_user(uid) + if not target: + return self._error(404, "Пользователь не найден") + body = self._read_body() + + new_role = body.get("role") + new_active = body.get("is_active") + password = body.get("password") + + # не даём остаться без администраторов + loses_admin = (target["role"] == "admin" and + ((new_role is not None and new_role != "admin") or new_active is False)) + if loses_admin and store.count_users(role="admin") <= 1: + return self._error(400, "Нельзя убрать последнего администратора") + if uid == user["id"] and new_active is False: + return self._error(400, "Нельзя отключить собственный аккаунт") + + if new_role is not None and new_role not in ("admin", "user"): + return self._error(400, "Недопустимая роль") + if password is not None and len(password) < auth.MIN_PASSWORD: + return self._error(400, f"Пароль должен быть не короче {auth.MIN_PASSWORD} символов") + + store.update_user( + uid, + role=new_role, + is_active=new_active, + name=body.get("name"), + password_hash=auth.hash_password(password) if password else None, + ) + if password: + store.delete_user_sessions(uid) + return self._json(store.get_user(uid)) + except Exception as exc: # noqa: BLE001 + traceback.print_exc() + self._error(500, str(exc)) + + # ------------------------------------------------------------------ DELETE + def do_DELETE(self): + try: + parsed = urlparse(self.path) + path = unquote(parsed.path) + user = self._current_user() + if not user: + return self._error(401, "Требуется вход") + is_admin = user["role"] == "admin" + + m = re.match(r"^/api/certificates/([^/]+)/share$", path) + if m: + if not is_admin: + return self._error(403, "Доступно только администратору") + cert = store.set_public_token(m.group(1), None) + if not cert: + return self._error(404, "Документ не найден") + return self._json({"public_token": None}) + m = re.match(r"^/api/certificates/([^/]+)$", path) + if m: + if not is_admin: + return self._error(403, "Доступно только администратору") + ok = store.delete_certificate(m.group(1)) + return self._json({"deleted": ok}, 200 if ok else 404) + m = re.match(r"^/api/templates/([^/]+)$", path) + if m: + if not is_admin: + return self._error(403, "Доступно только администратору") + ok = store.delete_template(m.group(1)) + return self._json({"deleted": ok}, 200 if ok else 404) + m = re.match(r"^/api/users/(\d+)$", path) + if m: + if not is_admin: + return self._error(403, "Доступно только администратору") + uid = int(m.group(1)) + if uid == user["id"]: + return self._error(400, "Нельзя удалить собственный аккаунт") + target = store.get_user(uid) + if target and target["role"] == "admin" and store.count_users(role="admin") <= 1: + return self._error(400, "Нельзя удалить последнего администратора") + ok = store.delete_user(uid) + return self._json({"deleted": ok}, 200 if ok else 404) + self._error(404, "Не найдено") + except Exception as exc: # noqa: BLE001 + traceback.print_exc() + self._error(500, str(exc)) + + +def run(host: str | None = None, port: int | None = None) -> None: + config.ensure_dirs() + store.init_db() + store.purge_expired_sessions() + host = host or config.HOST + port = port or config.PORT + httpd = ThreadingHTTPServer((host, port), Handler) + actual = httpd.server_address + users = store.count_users() + print(f"certgen: http://{host}:{actual[1]} (пользователей: {users})", flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\ncertgen: остановлено", flush=True) + finally: + httpd.server_close() diff --git a/certgen/assets/fonts/LICENSE-NotoSans.txt b/certgen/assets/fonts/LICENSE-NotoSans.txt new file mode 100644 index 0000000..e9dbd01 --- /dev/null +++ b/certgen/assets/fonts/LICENSE-NotoSans.txt @@ -0,0 +1,155 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Noto +Upstream-Contact: https://github.com/googlei18n/noto-fonts/issues + http://groups.google.com/group/noto-font +Source: https://github.com/googlei18n/noto-fonts + +Files: * +Copyright: + 2010,2012-2020, Google Inc. + 2015-2020, Google LLC. +License-Grant: + This Font Software is licensed under the SIL Open Font License, + Version 1.1. +License: OFL-1.1 + +Files: + debian/* +Copyright: + 2013-2020, Jonas Smedegaard + 2020, Purism, SPC + 2013, Vasudev Kamath +License-Grant: + This program is free software; + you can redistribute it and/or modify it + under the terms of the GNU General Public License + as published by the Free Software Foundation; + either version 3, or (at your option) any later version. +License: GPL-3+ + +Files: + debian/NotoMono/NotoMono-Regular.ttf +Copyright: + 2007, Google Inc. +License-Grant: + This Font Software is licensed under the SIL Open Font License, + Version 1.1. +License: OFL-1.1 + +License: OFL-1.1 + PREAMBLE + . + The goals of the Open Font License (OFL) are to stimulate + worldwide development of collaborative font projects, + to support the font creation efforts + of academic and linguistic communities, + and to provide a free and open framework + in which fonts may be shared and improved + in partnership with others. + . + The OFL allows the licensed fonts to be used, studied, + modified and redistributed freely + as long as they are not sold by themselves. + The fonts, including any derivative works, + can be bundled, embedded, + redistributed and/or sold with any software + provided that any reserved names are not used + by derivative works. + The fonts and derivatives, however, + cannot be released under any other type of license. + The requirement for fonts to remain under this license + does not apply to any document + created using the fonts or their derivatives. + . + DEFINITIONS + . + "Font Software" refers to the set of files + released by the Copyright Holder(s) under this license + and clearly marked as such. + This may include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such + after the copyright statement(s). + . + "Original Version" refers to the collection + of Font Software components + as distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative + made by adding to, deleting, or substituting -- + in part or in whole -- + any of the components of the Original Version, + by changing formats + or by porting the Font Software to a new environment. + . + "Author" refers to any designer, engineer, programmer, + technical writer or other person + who contributed to the Font Software. + . + PERMISSION & CONDITIONS + . + Permission is hereby granted, free of charge, + to any person obtaining a copy of the Font Software, + to use, study, copy, merge, embed, modify, redistribute, + and sell modified and unmodified copies of the Font Software, + subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, + may be sold by itself. + . + 2) Original or Modified Versions of the Font Software + may be bundled, redistributed and/or sold with any software, + provided that each copy contains the above copyright notice + and this license. + These can be included either as stand-alone text files, + human-readable headers or in the appropriate + machine-readable metadata fields within text or binary files + as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use + the Reserved Font Name(s) + unless explicit written permission is granted + by the corresponding Copyright Holder. + This restriction only applies to the primary font name + as presented to the users. + . + 4) The name(s) of the Copyright Holder(s) + or the Author(s) of the Font Software + shall not be used to promote, + endorse or advertise any Modified Version, + except to acknowledge the contribution(s) + of the Copyright Holder(s) and the Author(s) + or with their explicit written permission. + . + 5) The Font Software, + modified or unmodified, in part or in whole, + must be distributed entirely under this license, + and must not be distributed under any other license. + The requirement for fonts to remain under this license + does not apply to any document + created using the Font Software. + . + TERMINATION + . + This license becomes null and void + if any of the above conditions are not met. + . + DISCLAIMER + . + THE FONT SOFTWARE IS PROVIDED "AS IS", + WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO + ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. + IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, + INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF THE USE OR INABILITY TO USE + THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +License: GPL-3+ +License-Reference: /usr/share/common-licenses/GPL-3 diff --git a/certgen/assets/fonts/NotoSans-Bold.ttf b/certgen/assets/fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000..4d923cb Binary files /dev/null and b/certgen/assets/fonts/NotoSans-Bold.ttf differ diff --git a/certgen/auth.py b/certgen/auth.py new file mode 100644 index 0000000..c1f771c --- /dev/null +++ b/certgen/auth.py @@ -0,0 +1,49 @@ +"""Аутентификация: хеширование паролей, проверка, сессии, валидация.""" +from __future__ import annotations + +import hashlib +import hmac +import re +import secrets + +PBKDF2_ITERATIONS = 200_000 +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +MIN_PASSWORD = 8 + + +def hash_password(password: str, iterations: int = PBKDF2_ITERATIONS) -> str: + salt = secrets.token_bytes(16) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}" + + +def verify_password(password: str, stored: str | None) -> bool: + if not stored: + return False + try: + algo, iters, salt_hex, digest = stored.split("$") + if algo != "pbkdf2_sha256": + return False + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), + bytes.fromhex(salt_hex), int(iters)) + return hmac.compare_digest(dk.hex(), digest) + except (ValueError, AttributeError): + return False + + +def new_token() -> str: + return secrets.token_urlsafe(32) + + +def valid_email(email: str) -> bool: + email = (email or "").strip() + return bool(EMAIL_RE.match(email)) and len(email) <= 254 + + +def validate_registration(email: str, password: str) -> str | None: + """Вернуть текст ошибки или None, если всё хорошо.""" + if not valid_email(email): + return "Некорректный email" + if len(password or "") < MIN_PASSWORD: + return f"Пароль должен быть не короче {MIN_PASSWORD} символов" + return None diff --git a/certgen/builtin.py b/certgen/builtin.py new file mode 100644 index 0000000..69aca3a --- /dev/null +++ b/certgen/builtin.py @@ -0,0 +1,136 @@ +"""Встроенные шаблоны и генерация фонов.""" +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + +from .config import TEMPLATES_DIR + +GOLD = (198, 158, 45) +NAVY = (27, 42, 74) +MAROON = (122, 31, 43) +CREAM = (255, 253, 247) + +CLASSIC_PAGE = {"width": 1754, "height": 1240} # A4 landscape @ ~150dpi +LETTER_PAGE = {"width": 1240, "height": 1754} # A4 portrait @ ~150dpi + +# Меняйте при правке встроенных шаблонов — тогда они обновятся у пользователей. +BUILTIN_VERSION = "2" + + +def _diamond(d: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill) -> None: + d.polygon([(cx, cy - r), (cx + r, cy), (cx, cy + r), (cx - r, cy)], fill=fill) + + +def _frame(img: Image.Image, outer: int = 34) -> ImageDraw.ImageDraw: + W, H = img.size + d = ImageDraw.Draw(img) + d.rectangle([outer, outer, W - outer, H - outer], outline=GOLD, width=8) + d.rectangle([outer + 24, outer + 24, W - outer - 24, H - outer - 24], outline=NAVY, width=2) + d.rectangle([outer + 34, outer + 34, W - outer - 34, H - outer - 34], outline=GOLD, width=1) + inset = outer + 24 + for cx, cy in [(inset, inset), (W - inset, inset), (inset, H - inset), (W - inset, H - inset)]: + _diamond(d, cx, cy, 18, GOLD) + _diamond(d, cx, cy, 8, NAVY) + _diamond(d, cx, cy, 3, CREAM) + return d + + +def draw_classic_bg(path: Path) -> None: + W, H = CLASSIC_PAGE["width"], CLASSIC_PAGE["height"] + img = Image.new("RGB", (W, H), CREAM) + d = _frame(img) + # разделитель под заголовком + d.line([(W * 0.30, H * 0.245), (W * 0.70, H * 0.245)], fill=GOLD, width=3) + _diamond(d, W * 0.5, H * 0.245, 9, GOLD) + # линия для подписи + d.line([(W * 0.42, H * 0.925), (W * 0.58, H * 0.925)], fill=NAVY, width=2) + img.save(path, "PNG") + + +def draw_letter_bg(path: Path) -> None: + W, H = LETTER_PAGE["width"], LETTER_PAGE["height"] + img = Image.new("RGB", (W, H), CREAM) + d = _frame(img) + d.line([(W * 0.26, H * 0.205), (W * 0.74, H * 0.205)], fill=GOLD, width=3) + _diamond(d, W * 0.5, H * 0.205, 9, GOLD) + d.line([(W * 0.42, H * 0.925), (W * 0.58, H * 0.925)], fill=NAVY, width=2) + img.save(path, "PNG") + + +def ensure_assets() -> None: + """Создать фоны встроенных шаблонов, если их ещё нет.""" + builtin_dir = TEMPLATES_DIR / "builtin" + builtin_dir.mkdir(parents=True, exist_ok=True) + classic = builtin_dir / "classic.png" + letter = builtin_dir / "letter.png" + if not classic.exists(): + draw_classic_bg(classic) + if not letter.exists(): + draw_letter_bg(letter) + + +def builtin_templates() -> list[dict]: + return [ + { + "slug": "classic", + "title": "СЕРТИФИКАТ", + "kind": "image", + "background": "builtin/classic.png", + "page": CLASSIC_PAGE, + "defaults": { + "title": "СЕРТИФИКАТ", + "body": "за успешное прохождение программы", + }, + "layout": [ + {"key": "title", "x": 0.5, "y": 0.175, "size": 96, "font": "serif-bold", + "color": "#1B2A4A", "align": "center", "uppercase": True, + "letter_spacing": 10, "max_width": 0.9}, + {"text": "настоящий сертификат подтверждает, что", "x": 0.5, "y": 0.315, + "size": 34, "font": "serif-italic", "color": "#5a5a5a", "align": "center"}, + {"key": "fio", "x": 0.5, "y": 0.435, "size": 96, "font": "serif-bold", + "color": "#7A1F2B", "align": "center", "max_width": 0.82}, + {"key": "body", "x": 0.5, "y": 0.645, "size": 40, "font": "serif", + "color": "#333333", "align": "center", "max_width": 0.72, "line_spacing": 1.45}, + {"text": "Дата выдачи", "x": 0.26, "y": 0.795, "size": 22, "font": "sans", + "color": "#8a8a8a", "align": "center"}, + {"key": "date", "x": 0.26, "y": 0.845, "size": 32, "font": "serif-bold", + "color": "#1B2A4A", "align": "center"}, + {"text": "Номер документа", "x": 0.74, "y": 0.795, "size": 22, "font": "sans", + "color": "#8a8a8a", "align": "center"}, + {"key": "number", "x": 0.74, "y": 0.845, "size": 32, "font": "serif-bold", + "color": "#1B2A4A", "align": "center"}, + ], + }, + { + "slug": "letter", + "title": "БЛАГОДАРСТВЕННОЕ ПИСЬМО", + "kind": "image", + "background": "builtin/letter.png", + "page": LETTER_PAGE, + "defaults": { + "title": "БЛАГОДАРСТВЕННОЕ ПИСЬМО", + "body": "за вклад в развитие и активное участие", + }, + "layout": [ + {"key": "title", "x": 0.5, "y": 0.15, "size": 62, "font": "serif-bold", + "color": "#1B2A4A", "align": "center", "uppercase": True, + "letter_spacing": 6, "max_width": 0.86}, + {"text": "объявляется благодарность", "x": 0.5, "y": 0.255, "size": 32, + "font": "serif-italic", "color": "#5a5a5a", "align": "center"}, + {"key": "fio", "x": 0.5, "y": 0.36, "size": 70, "font": "serif-bold", + "color": "#7A1F2B", "align": "center", "max_width": 0.84}, + {"key": "body", "x": 0.5, "y": 0.50, "size": 34, "font": "serif", + "color": "#333333", "align": "center", "max_width": 0.74, "line_spacing": 1.5}, + {"text": "Дата", "x": 0.27, "y": 0.80, "size": 22, "font": "sans", + "color": "#8a8a8a", "align": "center"}, + {"key": "date", "x": 0.27, "y": 0.85, "size": 30, "font": "serif-bold", + "color": "#1B2A4A", "align": "center"}, + {"text": "Номер", "x": 0.73, "y": 0.80, "size": 22, "font": "sans", + "color": "#8a8a8a", "align": "center"}, + {"key": "number", "x": 0.73, "y": 0.85, "size": 30, "font": "serif-bold", + "color": "#1B2A4A", "align": "center"}, + ], + }, + ] diff --git a/certgen/config.py b/certgen/config.py new file mode 100644 index 0000000..7ff9895 --- /dev/null +++ b/certgen/config.py @@ -0,0 +1,41 @@ +"""Пути и настройки сервиса.""" +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Каталог данных можно переопределить переменной окружения CERTGEN_DATA. +DATA_DIR = Path(os.environ.get("CERTGEN_DATA", str(BASE_DIR / "data"))).expanduser() +DB_PATH = DATA_DIR / "certs.db" +OUTPUT_DIR = DATA_DIR / "output" # сгенерированные файлы (png/pdf) +TEMPLATES_DIR = DATA_DIR / "templates" # фоны и ассеты шаблонов +TMP_DIR = DATA_DIR / "tmp" # временные файлы + +# Публичный адрес сервиса (для абсолютных ссылок). Если не задан — берём из Host. +BASE_URL = os.environ.get("CERTGEN_BASE_URL", "").rstrip("/") + +HOST = os.environ.get("CERTGEN_HOST", "127.0.0.1") +PORT = int(os.environ.get("CERTGEN_PORT", "8077")) + +# Разрешение вывода (dpi для PDF-экспорта изображения). +PDF_DPI = int(os.environ.get("CERTGEN_PDF_DPI", "150")) + +# Нумерация: первые (START_SEQ - 1) номеров зарезервированы под особые случаи, +# обычная нумерация начинается с START_SEQ. +START_SEQ = int(os.environ.get("CERTGEN_START", "1001")) +# Ширина номера с ведущими нулями (6 -> 001001). +NUMBER_PAD = int(os.environ.get("CERTGEN_PAD", "6")) + +# Регистрация: open — любой может зарегистрироваться; closed — только админ создаёт. +REGISTRATION = os.environ.get("CERTGEN_REGISTRATION", "open").strip().lower() +# Email, который при регистрации получает роль администратора (необязательно). +ADMIN_EMAIL = os.environ.get("CERTGEN_ADMIN_EMAIL", "").strip().lower() +# Срок жизни сессии в днях. +SESSION_TTL_DAYS = int(os.environ.get("CERTGEN_SESSION_DAYS", "30")) + + +def ensure_dirs() -> None: + for d in (DATA_DIR, OUTPUT_DIR, TEMPLATES_DIR, TMP_DIR): + d.mkdir(parents=True, exist_ok=True) diff --git a/certgen/fonts.py b/certgen/fonts.py new file mode 100644 index 0000000..5a4dd3f --- /dev/null +++ b/certgen/fonts.py @@ -0,0 +1,86 @@ +"""Поиск системных шрифтов (в т.ч. с кириллицей).""" +from __future__ import annotations + +import subprocess +from functools import lru_cache +from pathlib import Path + +SEARCH_DIRS = [ + Path(__file__).resolve().parent / "assets" / "fonts", # шрифты, вложенные в проект + Path("/usr/share/fonts"), + Path("/usr/local/share/fonts"), + Path.home() / ".local/share/fonts", + Path("/Library/Fonts"), + Path("/System/Library/Fonts"), + Path("C:/Windows/Fonts"), +] + +ALIASES = { + "serif": "DejaVuSerif", + "serif-bold": "DejaVuSerif-Bold", + "serif-italic": "DejaVuSerif-Italic", + "serif-bolditalic": "DejaVuSerif-BoldItalic", + "sans": "DejaVuSans", + "sans-bold": "DejaVuSans-Bold", + "sans-italic": "DejaVuSans-Oblique", + "sans-bolditalic": "DejaVuSans-BoldOblique", + "mono": "DejaVuSansMono", +} + + +@lru_cache(maxsize=1) +def _index() -> dict[str, str]: + idx: dict[str, str] = {} + for d in SEARCH_DIRS: + if not d.exists(): + continue + for f in d.rglob("*"): + if f.suffix.lower() in (".ttf", ".otf", ".ttc"): + idx.setdefault(f.stem.lower(), str(f)) + idx.setdefault(f.name.lower(), str(f)) + return idx + + +def _fallback() -> str: + idx = _index() + for key in ("dejavusans", "dejavuserif", "liberationsans", "notosans", + "arial", "arialunicode"): + if key in idx: + return idx[key] + return next(iter(idx.values()), "") + + +@lru_cache(maxsize=256) +def find_font(name: str | None) -> str: + """Вернуть путь к файлу шрифта по имени или алиасу.""" + name = (name or "sans").strip() + p = Path(name) + if p.is_absolute() and p.exists(): + return str(p) + + key = ALIASES.get(name.lower(), name).lower() + idx = _index() + + if key in idx: + return idx[key] + if key + ".ttf" in idx: + return idx[key + ".ttf"] + + # частичное совпадение + matches = [v for k, v in idx.items() if key in k] + if matches: + return matches[0] + + # спросим fontconfig + try: + out = subprocess.run( + ["fc-match", "-f", "%{file}", name], + capture_output=True, text=True, timeout=10, + ) + path = out.stdout.strip() + if path and Path(path).exists(): + return path + except Exception: + pass + + return _fallback() diff --git a/certgen/gen.py b/certgen/gen.py new file mode 100644 index 0000000..c74b9a1 --- /dev/null +++ b/certgen/gen.py @@ -0,0 +1,115 @@ +"""CLI: пакетная генерация документов без веб-сервера. + +Примеры: + python -m certgen.gen --template classic --fio "Иванов Иван Иванович" \ + --body "за успешное прохождение курса" + + python -m certgen.gen --template letter --csv names.csv --outdir out + +В CSV первая строка может быть заголовком (fio,title,body,date,prefix); +допускаются разделители ',' и ';'. Если колонок нет — берётся первый столбец как ФИО. +""" +from __future__ import annotations + +import argparse +import csv +import json +import re +import shutil +from pathlib import Path + +from . import config, store +from .service import create_certificate + + +def _read_csv(path: Path) -> list[dict]: + text = path.read_text(encoding="utf-8-sig") + first = text.splitlines()[0] if text.strip() else "" + delim = ";" if first.count(";") > first.count(",") else "," + rows = list(csv.reader(text.splitlines(), delimiter=delim)) + rows = [r for r in rows if any(c.strip() for c in r)] + if not rows: + return [] + header = [c.strip().lower() for c in rows[0]] + known = {"fio", "фио", "title", "заголовок", "body", "текст", + "date", "дата", "prefix", "префикс", "number", "номер", "email", "почта"} + has_header = bool(known & set(header)) + records = [] + data = rows[1:] if has_header else rows + keys = header if has_header else ["fio"] + for row in data: + rec = {} + if has_header: + for i, key in enumerate(keys): + rec[key] = row[i] if i < len(row) else "" + else: + rec["fio"] = row[0] + # нормализуем русские заголовки + for ru, en in (("фио", "fio"), ("заголовок", "title"), ("текст", "body"), + ("дата", "date"), ("префикс", "prefix"), ("номер", "number"), + ("почта", "email")): + if ru in rec and en not in rec: + rec[en] = rec.pop(ru) + records.append(rec) + return records + + +def main() -> None: + p = argparse.ArgumentParser(prog="certgen.gen", description="Пакетная генерация документов") + p.add_argument("--template", default="classic", help="slug шаблона") + p.add_argument("--fio", help="ФИО для одного документа") + p.add_argument("--email", help="email получателя документа") + p.add_argument("--title", help="заголовок") + p.add_argument("--body", help="дополнительный текст") + p.add_argument("--date", help="дата") + p.add_argument("--prefix", help="префикс номера") + p.add_argument("--number", help="особый номер из резерва 1..(START-1)") + p.add_argument("--csv", type=Path, help="CSV со списком") + p.add_argument("--names", help="список ФИО через ';'") + p.add_argument("--outdir", type=Path, help="куда скопировать готовые файлы") + p.add_argument("--base", help="базовый URL для ссылок") + p.add_argument("--json", action="store_true", help="вывести результат как JSON") + args = p.parse_args() + + config.ensure_dirs() + store.init_db() + template = store.get_template(args.template) + if not template: + raise SystemExit(f"Неизвестный шаблон: {args.template}. " + f"Доступны: {[t['slug'] for t in store.list_templates()]}") + + records: list[dict] = [] + if args.csv: + records = _read_csv(args.csv) + elif args.names: + records = [{"fio": n.strip()} for n in args.names.split(";") if n.strip()] + elif args.fio: + records = [{"fio": args.fio}] + else: + raise SystemExit("Укажите --fio, --names или --csv") + + results = [] + for rec in records: + body = {"fio": rec.get("fio", "")} + for key, val in (("title", args.title), ("body", args.body), + ("date", args.date), ("prefix", args.prefix), + ("number", args.number), ("email", args.email)): + value = rec.get(key) or val + if value: + body[key] = value + cert = create_certificate(template, body, base=args.base) + if args.outdir: + args.outdir.mkdir(parents=True, exist_ok=True) + safe = re.sub(r"[^\w.-]+", "_", cert["number"]) or str(cert["seq"]) + shutil.copyfile(cert["png_path"], args.outdir / f"{safe}.png") + shutil.copyfile(cert["pdf_path"], args.outdir / f"{safe}.pdf") + results.append(cert) + if not args.json: + print(f"№ {cert['number']} {cert['fio']} -> {cert['url']}") + + if args.json: + print(json.dumps(results, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/certgen/render.py b/certgen/render.py new file mode 100644 index 0000000..e65820b --- /dev/null +++ b/certgen/render.py @@ -0,0 +1,238 @@ +"""Рендеринг документов: image-шаблоны (Pillow) и html-шаблоны (Chromium).""" +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from datetime import date +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from .config import PDF_DPI, TEMPLATES_DIR, TMP_DIR +from .fonts import find_font + +MONTHS_RU = [ + "января", "февраля", "марта", "апреля", "мая", "июня", + "июля", "августа", "сентября", "октября", "ноября", "декабря", +] + + +def russian_date(d: date | None = None) -> str: + d = d or date.today() + return f"{d.day} {MONTHS_RU[d.month - 1]} {d.year}" + + +def substitute(text: str | None, values: dict) -> str: + """Подставить значения в {ключ} или {{ключ}}.""" + if text is None: + return "" + out = str(text) + for k, v in values.items(): + s = "" if v is None else str(v) + out = out.replace("{{" + str(k) + "}}", s) + out = out.replace("{" + str(k) + "}", s) + return out + + +# -------------------------------------------------------------------------- +# Текстовый слой (Pillow) +# -------------------------------------------------------------------------- + +def _measure(font: ImageFont.FreeTypeFont, s: str, spacing: float = 0) -> float: + if not s: + return 0.0 + w = font.getlength(s) + if spacing: + w += spacing * (len(s) - 1) + return w + + +def _wrap(font: ImageFont.FreeTypeFont, text: str, max_px: float | None, + spacing: float = 0) -> list[str]: + if not text: + return [""] + lines: list[str] = [] + for paragraph in str(text).split("\n"): + if not paragraph.strip(): + lines.append("") + continue + if not max_px: + lines.append(paragraph) + continue + words = paragraph.split(" ") + cur = "" + for word in words: + trial = word if not cur else cur + " " + word + if _measure(font, trial, spacing) <= max_px or not cur: + cur = trial + else: + lines.append(cur) + cur = word + lines.append(cur) + return lines + + +def _draw_line(draw: ImageDraw.ImageDraw, text: str, font, x: float, y: float, + color: str, spacing: float) -> None: + if not spacing: + draw.text((x, y), text, font=font, fill=color) + return + cursor = x + for ch in text: + draw.text((cursor, y), ch, font=font, fill=color) + cursor += font.getlength(ch) + spacing + + +def _draw_block(draw: ImageDraw.ImageDraw, lines: list[str], font, x: float, y: float, + align: str, color: str, line_spacing: float, spacing: float, + max_px: float | None) -> None: + ascent, descent = font.getmetrics() + lh = (ascent + descent) * line_spacing + top = y - (lh * len(lines)) / 2.0 + for i, line in enumerate(lines): + width = _measure(font, line, spacing) + if align == "center": + lx = x - width / 2.0 + elif align == "right": + lx = x - width + else: + lx = x + _draw_line(draw, line, font, lx, top + i * lh, color, spacing) + + +def render_image_template(template: dict, values: dict, + out_png: Path, out_pdf: Path | None = None) -> None: + bg_path = TEMPLATES_DIR / template["background"] + if not bg_path.exists(): + raise FileNotFoundError(f"Фон шаблона не найден: {bg_path}") + + img = Image.open(bg_path).convert("RGB") + W, H = img.size + page = template.get("page") or {} + ref_h = float(page.get("height") or H) + scale = H / ref_h + draw = ImageDraw.Draw(img) + + for field in template.get("layout", []): + if "key" in field: + raw = values.get(field["key"], "") + else: + raw = field.get("text", "") + text = substitute(raw, values) + if field.get("uppercase"): + text = text.upper() + if not str(text).strip() and not field.get("keep_empty"): + continue + + size = max(1, int(round(float(field.get("size", 40)) * scale))) + font = ImageFont.truetype(find_font(field.get("font", "sans")), size) + + x = field.get("x", 0.5) + y = field.get("y", 0.5) + x = x * W if x <= 1 else x + y = y * H if y <= 1 else y + + maxw = field.get("max_width") + max_px = (maxw * W if (maxw and maxw <= 1) else maxw) + + spacing = float(field.get("letter_spacing", 0)) * scale + lines = _wrap(font, text, max_px, spacing) + _draw_block( + draw, lines, font, x, y, + field.get("align", "center"), + field.get("color", "#000000"), + float(field.get("line_spacing", 1.15)), + spacing, max_px, + ) + + out_png.parent.mkdir(parents=True, exist_ok=True) + img.save(out_png, "PNG") + if out_pdf is not None: + img.save(out_pdf, "PDF", resolution=PDF_DPI) + + +# -------------------------------------------------------------------------- +# HTML-шаблоны (headless Chromium) +# -------------------------------------------------------------------------- + +def _chromium_binary() -> str | None: + for name in ("chromium", "chromium-browser", "google-chrome", + "google-chrome-stable", "chrome"): + p = shutil.which(name) + if p: + return p + for p in ("/snap/bin/chromium", "/usr/bin/chromium", + "/usr/bin/google-chrome", "/usr/bin/chromium-browser"): + if Path(p).exists(): + return p + return None + + +def _chromium_workdir(binary: str) -> Path: + # Snap-версия Chromium пишет только в ~/snap/chromium/common. + if "/snap/" in binary: + d = Path.home() / "snap" / "chromium" / "common" / ".certgen-work" + else: + d = TMP_DIR / "chromium" + d.mkdir(parents=True, exist_ok=True) + return d + + +def render_html(html: str, out_pdf: Path, out_png: Path | None = None, + screenshot_size: tuple[int, int] = (1240, 1754)) -> None: + binary = _chromium_binary() + if not binary: + raise RuntimeError( + "Для HTML-шаблонов нужен Chromium/Chrome, но он не найден в системе." + ) + work = _chromium_workdir(binary) + run_dir = Path(tempfile.mkdtemp(prefix="run-", dir=work)) + try: + html_file = run_dir / "document.html" + html_file.write_text(html, encoding="utf-8") + profile = run_dir / "profile" + profile.mkdir(exist_ok=True) + + base = [binary, "--headless=new", "--no-sandbox", "--disable-gpu", + "--hide-scrollbars", "--no-pdf-header-footer", + f"--user-data-dir={profile}"] + + pdf_tmp = run_dir / "document.pdf" + subprocess.run(base + [f"--print-to-pdf={pdf_tmp}", html_file.as_uri()], + capture_output=True, timeout=120, check=False) + if not pdf_tmp.exists(): + raise RuntimeError("Chromium не смог собрать PDF из HTML.") + + out_pdf.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(pdf_tmp, out_pdf) + + if out_png is not None: + png_tmp = run_dir / "document.png" + w, h = screenshot_size + subprocess.run(base + [f"--window-size={int(w)},{int(h)}", + f"--screenshot={png_tmp}", html_file.as_uri()], + capture_output=True, timeout=120, check=False) + if png_tmp.exists(): + shutil.copyfile(png_tmp, out_png) + finally: + shutil.rmtree(run_dir, ignore_errors=True) + + +def render_html_template(template: dict, values: dict, out_png: Path, out_pdf: Path) -> None: + html = substitute(template.get("html", ""), values) + page = template.get("page") or {} + size = (int(page.get("width") or 1240), int(page.get("height") or 1754)) + render_html(html, out_pdf, out_png, screenshot_size=size) + + +# -------------------------------------------------------------------------- +# Единая точка входа +# -------------------------------------------------------------------------- + +def render_certificate(template: dict, values: dict, out_png: Path, out_pdf: Path) -> None: + kind = template.get("kind", "image") + if kind == "html": + render_html_template(template, values, out_png, out_pdf) + else: + render_image_template(template, values, out_png, out_pdf) diff --git a/certgen/service.py b/certgen/service.py new file mode 100644 index 0000000..081167e --- /dev/null +++ b/certgen/service.py @@ -0,0 +1,70 @@ +"""Ядро сервиса: построение значений и создание документа.""" +from __future__ import annotations + +from . import config, store +from .render import render_certificate, russian_date + + +def base_url() -> str: + if config.BASE_URL: + return config.BASE_URL + return f"http://{config.HOST}:{config.PORT}" + + +def build_values(template: dict, body: dict) -> dict: + defaults = template.get("defaults") or {} + title = (body.get("title") or defaults.get("title") or template.get("title") or "").strip() + text = body.get("body") + if text is None: + text = defaults.get("body", "") + fields = body.get("fields") or {} + values = { + "title": title, + "fio": (body.get("fio") or "").strip(), + "body": text or "", + "date": (body.get("date") or "").strip() or russian_date(), + "fields": fields, + } + # произвольные поля из fields доступны как {ключ} + for k, v in fields.items(): + values.setdefault(str(k), v) + return values + + +def cert_api(cert: dict, base: str) -> dict: + token = cert.get("public_token") + return { + **cert, + "url": f"{base}/c/{cert['seq']}", + "page": f"{base}/c/{cert['seq']}", + "png": f"{base}/c/{cert['seq']}.png", + "pdf": f"{base}/c/{cert['seq']}.pdf", + "public_url": f"{base}/s/{token}" if token else None, + "public_png": f"{base}/s/{token}.png" if token else None, + "public_pdf": f"{base}/s/{token}.pdf" if token else None, + } + + +def create_certificate(template: dict, body: dict, base: str | None = None, + created_by: int | None = None) -> dict: + """Создать документ: зарезервировать номер, отрендерить, сохранить пути.""" + base = base or base_url() + values = build_values(template, body) + cert = store.reserve_certificate( + template=template["slug"], values=values, prefix=body.get("prefix"), + number=body.get("number"), + recipient_email=body.get("email") or body.get("recipient_email"), + created_by=created_by, + ) + seq = cert["seq"] + values["number"] = cert["number"] + + out_png = config.OUTPUT_DIR / f"{seq:06d}.png" + out_pdf = config.OUTPUT_DIR / f"{seq:06d}.pdf" + try: + render_certificate(template, values, out_png, out_pdf) + except Exception: + store.delete_certificate(seq) + raise + store.update_certificate_files(seq, str(out_png), str(out_pdf)) + return cert_api(store.get_certificate(seq), base) diff --git a/certgen/store.py b/certgen/store.py new file mode 100644 index 0000000..0e1ba1b --- /dev/null +++ b/certgen/store.py @@ -0,0 +1,479 @@ +"""Хранилище (SQLite): сертификаты и шаблоны.""" +from __future__ import annotations + +import json +import sqlite3 +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from . import builtin, config +from .config import DB_PATH, TEMPLATES_DIR, ensure_dirs + +_LOCK = threading.Lock() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS certificates ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + number TEXT NOT NULL, + template TEXT NOT NULL, + fio TEXT, + title TEXT, + body TEXT, + date_text TEXT, + fields TEXT, + png_path TEXT, + pdf_path TEXT, + recipient_email TEXT, + created_by INTEGER, + public_token TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_cert_number ON certificates(number); + +CREATE TABLE IF NOT EXISTS templates ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL, + kind TEXT NOT NULL, + background TEXT, + layout TEXT, + html TEXT, + page TEXT, + defaults TEXT, + builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + name TEXT, + role TEXT NOT NULL DEFAULT 'user', + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +def _migrate(conn: sqlite3.Connection) -> None: + """Добавить недостающие колонки в старые базы.""" + cols = {r["name"] for r in conn.execute("PRAGMA table_info(certificates)").fetchall()} + for name, ddl in (("recipient_email", "TEXT"), ("created_by", "INTEGER"), + ("public_token", "TEXT")): + if name not in cols: + conn.execute(f"ALTER TABLE certificates ADD COLUMN {name} {ddl}") + conn.execute("CREATE INDEX IF NOT EXISTS idx_cert_recipient ON certificates(recipient_email)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_cert_token ON certificates(public_token)") + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def init_db() -> None: + ensure_dirs() + with _LOCK, _connect() as conn: + conn.executescript(SCHEMA) + _migrate(conn) + builtin.ensure_assets() + ensure_builtin_templates() + + +# ---------------------------------------------------------------- templates + +def _row_to_template(row: sqlite3.Row) -> dict: + return { + "slug": row["slug"], + "title": row["title"], + "kind": row["kind"], + "background": row["background"], + "layout": json.loads(row["layout"]) if row["layout"] else [], + "html": row["html"], + "page": json.loads(row["page"]) if row["page"] else None, + "defaults": json.loads(row["defaults"]) if row["defaults"] else {}, + "builtin": bool(row["builtin"]), + "created_at": row["created_at"], + } + + +def ensure_builtin_templates() -> None: + with _LOCK, _connect() as conn: + row = conn.execute("SELECT value FROM meta WHERE key='builtin_version'").fetchone() + current = row["value"] if row else None + if current == builtin.BUILTIN_VERSION: + return + for tpl in builtin.builtin_templates(): + upsert_template( + slug=tpl["slug"], + title=tpl["title"], + kind=tpl["kind"], + background=tpl.get("background"), + layout=tpl.get("layout"), + html=tpl.get("html"), + page=tpl.get("page"), + defaults=tpl.get("defaults"), + is_builtin=True, + ) + with _LOCK, _connect() as conn: + conn.execute( + "INSERT INTO meta(key,value) VALUES('builtin_version',?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (builtin.BUILTIN_VERSION,), + ) + + +def upsert_template(*, slug: str, title: str, kind: str, background=None, + layout=None, html=None, page=None, defaults=None, + is_builtin: bool = False) -> dict: + with _LOCK, _connect() as conn: + conn.execute( + """INSERT INTO templates + (slug, title, kind, background, layout, html, page, defaults, builtin, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(slug) DO UPDATE SET + title=excluded.title, kind=excluded.kind, background=excluded.background, + layout=excluded.layout, html=excluded.html, page=excluded.page, + defaults=excluded.defaults, builtin=excluded.builtin""", + ( + slug, title, kind, background, + json.dumps(layout, ensure_ascii=False) if layout is not None else None, + html, + json.dumps(page, ensure_ascii=False) if page is not None else None, + json.dumps(defaults or {}, ensure_ascii=False), + 1 if is_builtin else 0, _now(), + ), + ) + return get_template(slug) + + +def get_template(slug: str) -> dict | None: + with _LOCK, _connect() as conn: + row = conn.execute("SELECT * FROM templates WHERE slug=?", (slug,)).fetchone() + return _row_to_template(row) if row else None + + +def list_templates() -> list[dict]: + with _LOCK, _connect() as conn: + rows = conn.execute("SELECT * FROM templates ORDER BY builtin DESC, slug").fetchall() + out = [] + for row in rows: + t = _row_to_template(row) + t.pop("html", None) # не тянем тяжёлое поле в список + t.pop("layout", None) + out.append(t) + return out + + +def delete_template(slug: str) -> bool: + with _LOCK, _connect() as conn: + cur = conn.execute("DELETE FROM templates WHERE slug=? AND builtin=0", (slug,)) + return cur.rowcount > 0 + + +# ------------------------------------------------------------- certificates + +def _format_number(seq: int, prefix: str | None) -> str: + base = f"{seq:0{config.NUMBER_PAD}d}" + prefix = (prefix or "").strip() + return f"{prefix}-{base}" if prefix else base + + +def reserve_certificate(*, template: str, values: dict, prefix: str | None = None, + number: int | str | None = None, + recipient_email: str | None = None, + created_by: int | None = None) -> dict: + """Зарезервировать номер: вставить строку и вернуть её (файлы пока пустые). + + По умолчанию номер берётся из общей нумерации (начиная с config.START_SEQ). + Если передан `number`, используется явный номер из зарезервированного + диапазона 1 .. START_SEQ-1 (для особых случаев). + """ + with _LOCK, _connect() as conn: + conn.execute("BEGIN IMMEDIATE") + if number is not None and str(number).strip(): + try: + seq = int(str(number).strip()) + except (TypeError, ValueError): + raise ValueError("Номер должен быть целым числом") from None + if seq < 1: + raise ValueError("Номер должен быть положительным") + if seq >= config.START_SEQ: + raise ValueError( + f"Явный номер допускается только из резерва 1..{config.START_SEQ - 1}" + ) + if conn.execute("SELECT 1 FROM certificates WHERE seq=?", (seq,)).fetchone(): + raise ValueError(f"Номер {seq} уже занят") + else: + row = conn.execute("SELECT MAX(seq) AS m FROM certificates").fetchone() + # обычная нумерация никогда не заходит в зарезервированный диапазон + seq = max(int(row["m"] or 0), config.START_SEQ - 1) + 1 + number = _format_number(seq, prefix) + conn.execute( + """INSERT INTO certificates + (seq, number, template, fio, title, body, date_text, fields, + png_path, pdf_path, recipient_email, created_by, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + seq, number, template, + values.get("fio"), values.get("title"), values.get("body"), + values.get("date"), + json.dumps(values.get("fields") or {}, ensure_ascii=False), + None, None, + (recipient_email or "").strip().lower() or None, + created_by, _now(), + ), + ) + return get_certificate(seq) + + +def update_certificate_files(seq: int, png_path: str, pdf_path: str) -> None: + with _LOCK, _connect() as conn: + conn.execute( + "UPDATE certificates SET png_path=?, pdf_path=? WHERE seq=?", + (png_path, pdf_path, seq), + ) + + +def _row_to_cert(row: sqlite3.Row) -> dict: + return { + "seq": row["seq"], + "number": row["number"], + "template": row["template"], + "fio": row["fio"], + "title": row["title"], + "body": row["body"], + "date": row["date_text"], + "fields": json.loads(row["fields"]) if row["fields"] else {}, + "png_path": row["png_path"], + "pdf_path": row["pdf_path"], + "recipient_email": row["recipient_email"], + "created_by": row["created_by"], + "public_token": row["public_token"], + "created_at": row["created_at"], + } + + +def get_certificate(ref: int | str) -> dict | None: + with _LOCK, _connect() as conn: + if isinstance(ref, int) or str(ref).isdigit(): + row = conn.execute("SELECT * FROM certificates WHERE seq=?", (int(ref),)).fetchone() + else: + row = conn.execute("SELECT * FROM certificates WHERE number=?", (str(ref),)).fetchone() + return _row_to_cert(row) if row else None + + +def list_certificates(limit: int = 50, offset: int = 0, + recipient_email: str | None = None) -> list[dict]: + """Если recipient_email задан — вернуть только документы этого получателя.""" + with _LOCK, _connect() as conn: + if recipient_email: + rows = conn.execute( + "SELECT * FROM certificates WHERE lower(recipient_email)=lower(?) " + "ORDER BY seq DESC LIMIT ? OFFSET ?", + (recipient_email.strip(), limit, offset), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM certificates ORDER BY seq DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + return [_row_to_cert(r) for r in rows] + + +def count_certificates(recipient_email: str | None = None) -> int: + with _LOCK, _connect() as conn: + if recipient_email: + row = conn.execute( + "SELECT COUNT(*) AS n FROM certificates WHERE lower(recipient_email)=lower(?)", + (recipient_email.strip(),), + ).fetchone() + else: + row = conn.execute("SELECT COUNT(*) AS n FROM certificates").fetchone() + return int(row["n"]) + + +def set_public_token(ref: int | str, token: str | None) -> dict | None: + cert = get_certificate(ref) + if not cert: + return None + with _LOCK, _connect() as conn: + conn.execute("UPDATE certificates SET public_token=? WHERE seq=?", (token, cert["seq"])) + return get_certificate(cert["seq"]) + + +def get_certificate_by_token(token: str) -> dict | None: + if not token: + return None + with _LOCK, _connect() as conn: + row = conn.execute("SELECT * FROM certificates WHERE public_token=?", (token,)).fetchone() + return _row_to_cert(row) if row else None + + +def delete_certificate(ref: int | str) -> bool: + cert = get_certificate(ref) + if not cert: + return False + with _LOCK, _connect() as conn: + conn.execute("DELETE FROM certificates WHERE seq=?", (cert["seq"],)) + for key in ("png_path", "pdf_path"): + p = cert.get(key) + if p: + Path(p).unlink(missing_ok=True) + return True + + +# -------------------------------------------------------------------- users + +def _row_to_user(row: sqlite3.Row) -> dict: + return { + "id": row["id"], + "email": row["email"], + "name": row["name"], + "role": row["role"], + "is_active": bool(row["is_active"]), + "created_at": row["created_at"], + } + + +def create_user(email: str, password_hash: str, name: str | None = None, + role: str = "user") -> dict: + email = (email or "").strip().lower() + with _LOCK, _connect() as conn: + try: + cur = conn.execute( + "INSERT INTO users (email, password_hash, name, role, is_active, created_at) " + "VALUES (?,?,?,?,1,?)", + (email, password_hash, name, role, _now()), + ) + except sqlite3.IntegrityError: + raise ValueError("Пользователь с таким email уже существует") from None + uid = cur.lastrowid + return get_user(uid) + + +def get_user(user_id: int) -> dict | None: + with _LOCK, _connect() as conn: + row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() + return _row_to_user(row) if row else None + + +def get_user_by_email(email: str) -> dict | None: + with _LOCK, _connect() as conn: + row = conn.execute("SELECT * FROM users WHERE lower(email)=lower(?)", + ((email or "").strip(),)).fetchone() + return _row_to_user(row) if row else None + + +def get_password_hash(user_id: int) -> str | None: + with _LOCK, _connect() as conn: + row = conn.execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone() + return row["password_hash"] if row else None + + +def list_users() -> list[dict]: + with _LOCK, _connect() as conn: + rows = conn.execute( + "SELECT * FROM users ORDER BY (role='admin') DESC, email" + ).fetchall() + return [_row_to_user(r) for r in rows] + + +def count_users(role: str | None = None) -> int: + with _LOCK, _connect() as conn: + if role: + row = conn.execute("SELECT COUNT(*) AS n FROM users WHERE role=?", (role,)).fetchone() + else: + row = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone() + return int(row["n"]) + + +def update_user(user_id: int, *, role: str | None = None, is_active: bool | None = None, + name: str | None = None, password_hash: str | None = None) -> bool: + sets, params = [], [] + if role is not None: + sets.append("role=?"); params.append(role) + if is_active is not None: + sets.append("is_active=?"); params.append(1 if is_active else 0) + if name is not None: + sets.append("name=?"); params.append(name) + if password_hash is not None: + sets.append("password_hash=?"); params.append(password_hash) + if not sets: + return False + params.append(user_id) + with _LOCK, _connect() as conn: + cur = conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params) + return cur.rowcount > 0 + + +def delete_user(user_id: int) -> bool: + with _LOCK, _connect() as conn: + conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + cur = conn.execute("DELETE FROM users WHERE id=?", (user_id,)) + return cur.rowcount > 0 + + +# ----------------------------------------------------------------- sessions + +def create_session(token: str, user_id: int, ttl_days: int = 30) -> None: + now = datetime.now(timezone.utc) + expires = now + timedelta(days=ttl_days) + with _LOCK, _connect() as conn: + conn.execute( + "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)", + (token, user_id, now.isoformat(timespec="seconds"), + expires.isoformat(timespec="seconds")), + ) + + +def get_session_user(token: str) -> dict | None: + if not token: + return None + with _LOCK, _connect() as conn: + row = conn.execute("SELECT * FROM sessions WHERE token=?", (token,)).fetchone() + if not row: + return None + if row["expires_at"] <= datetime.now(timezone.utc).isoformat(timespec="seconds"): + conn.execute("DELETE FROM sessions WHERE token=?", (token,)) + return None + urow = conn.execute("SELECT * FROM users WHERE id=?", (row["user_id"],)).fetchone() + if not urow or not urow["is_active"]: + return None + return _row_to_user(urow) + + +def delete_session(token: str) -> None: + with _LOCK, _connect() as conn: + conn.execute("DELETE FROM sessions WHERE token=?", (token,)) + + +def delete_user_sessions(user_id: int) -> None: + with _LOCK, _connect() as conn: + conn.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + + +def purge_expired_sessions() -> None: + with _LOCK, _connect() as conn: + conn.execute("DELETE FROM sessions WHERE expires_at <= ?", + (datetime.now(timezone.utc).isoformat(timespec="seconds"),)) diff --git a/certgen/ui.py b/certgen/ui.py new file mode 100644 index 0000000..90496fa --- /dev/null +++ b/certgen/ui.py @@ -0,0 +1,507 @@ +"""HTML-интерфейс сервиса: вход, регистрация, кабинет, админка.""" +from __future__ import annotations + +import html +import json + +STYLE = """ +:root{--bg:#0f1116;--card:#171a22;--line:#262b36;--fg:#e8ebf2;--mut:#9aa3b2; +--acc:#c69e2d;--acc2:#e0bd57;--ok:#3fb950;--err:#f85149} +*{box-sizing:border-box} +body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,Arial,sans-serif; +background:var(--bg);color:var(--fg)} +header{padding:18px 28px;border-bottom:1px solid var(--line); +display:flex;align-items:center;gap:14px;background:linear-gradient(180deg,#141822,#0f1116)} +header h1{font-size:19px;margin:0;font-weight:700} +header .dot{width:10px;height:10px;border-radius:50%;background:var(--acc)} +header .spacer{margin-left:auto} +header .who{color:var(--mut);font-size:13px} +header a{color:var(--acc2);text-decoration:none;font-size:13px} +main{max-width:1180px;margin:0 auto;padding:26px;display:grid; +grid-template-columns:minmax(340px,440px) 1fr;gap:24px;align-items:start} +main.single{grid-template-columns:1fr;max-width:920px} +@media(max-width:920px){main{grid-template-columns:1fr}} +.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px} +.card h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em; +color:var(--mut);margin:0 0 16px;font-weight:600} +label{display:block;font-size:13px;color:var(--mut);margin:12px 0 5px} +input,select,textarea{width:100%;background:#0d1017;border:1px solid var(--line); +color:var(--fg);border-radius:9px;padding:10px 12px;font:inherit;outline:none} +input:focus,select:focus,textarea:focus{border-color:var(--acc)} +textarea{resize:vertical;min-height:70px} +.row{display:grid;grid-template-columns:1fr 1fr;gap:12px} +button{cursor:pointer;border:none;border-radius:10px;padding:11px 16px;font:inherit; +font-weight:600;transition:.15s} +.btn-primary{background:var(--acc);color:#1a1500;width:100%;margin-top:16px} +.btn-primary:hover{background:var(--acc2)} +.btn-ghost{background:#20252f;color:var(--fg);border:1px solid var(--line); +text-decoration:none;display:inline-block} +.btn-ghost:hover{background:#262c38} +.btn-sm{padding:6px 10px;font-size:13px;border-radius:8px} +.btn-danger{background:#3a1d20;color:#ffb4b4;border:1px solid #5a2a2e} +.actions{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} +.linkbox{display:flex;gap:8px;margin:10px 0} +.linkbox input{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px} +.preview{border-radius:12px;overflow:hidden;border:1px solid var(--line);background:#fff;margin-top:14px} +.preview img{width:100%;display:block} +table{width:100%;border-collapse:collapse;font-size:14px} +th,td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line);white-space:nowrap} +th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.06em} +td a{color:var(--acc2);text-decoration:none} +.mono{font-family:ui-monospace,Menlo,Consolas,monospace} +.muted{color:var(--mut)} +.err{color:var(--err);font-size:13px;margin-top:10px} +.notice{color:var(--ok);font-size:13px;margin-top:10px} +.pill{display:inline-block;background:#20252f;border:1px solid var(--line); +border-radius:20px;padding:2px 10px;font-size:12px;color:var(--mut)} +.pill.admin{color:#ffd76a;border-color:#5a4a1e;background:#2a2412} +.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} +.doc{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0d1017} +.doc img{width:100%;display:block;background:#fff} +.doc .meta{padding:10px 12px} +.doc .num{font-family:ui-monospace,Menlo,Consolas,monospace;font-weight:700} +.doc .fio{font-size:14px;margin:2px 0} +.doc .links{display:flex;gap:10px;margin-top:8px;font-size:13px} +.authwrap{max-width:420px;margin:8vh auto;padding:0 20px} +.authwrap h1{font-size:22px;margin:0 0 6px} +.authwrap .sub{color:var(--mut);margin:0 0 18px;font-size:14px} +.switch{color:var(--mut);font-size:14px;margin-top:14px} +.switch a{color:var(--acc2);text-decoration:none} +""" + +AUTH_JS = r""" +const $ = id => document.getElementById(id); +const mode = window.AUTH_MODE; +function setMsg(id, text){ const el = $(id); if (el) el.textContent = text; } +async function submit(e){ + e.preventDefault(); + setMsg('err',''); setMsg('notice',''); + const body = {email:$('email').value.trim(), password:$('password').value}; + if(mode==='register'){ body.name = ($('name')?$('name').value.trim():''); } + let r; + try { + r = await fetch('/api/auth/'+mode,{method:'POST', + headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); + } catch(err){ setMsg('err','Сервер недоступен. Попробуйте позже.'); return; } + if(r.ok){ location.href='/'; return; } + let msg = 'Ошибка'; + try { msg = (await r.json()).error || msg; } catch(e){} + setMsg('err', msg); +} +window.addEventListener('DOMContentLoaded',()=>{ $('form').addEventListener('submit',submit); }); +""" + +DASH_JS = r""" +const $ = id => document.getElementById(id); +const U = window.CURRENT_USER, ADMIN = window.IS_ADMIN; +async function api(path, opts){ + const r = await fetch(path, opts); + if(!r.ok){ let m='Ошибка'; try{ m=(await r.json()).error||m; }catch(e){} throw new Error(m); } + const ct = r.headers.get('content-type')||''; + return ct.includes('json') ? r.json() : r.blob(); +} +function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } + +async function loadTemplates(){ + const sel=$('template'); if(!sel) return; + const t = await api('/api/templates'); + sel.innerHTML = t.map(x=>``).join(''); +} +function payload(){ + const p={template:$('template').value, fio:$('fio').value.trim(), + title:$('title').value.trim(), body:$('body').value.trim(), + date:$('date').value.trim(), prefix:$('prefix').value.trim(), + email:$('email').value.trim()}; + const n=$('number').value.trim(); if(n) p.number=n; + return p; +} +async function preview(){ + $('err').textContent=''; + try{ + const blob = await api('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); + $('img').src = URL.createObjectURL(blob); + $('result').classList.add('show'); + }catch(e){ $('err').textContent=e.message; } +} +async function create(){ + $('err').textContent=''; $('notice').textContent=''; + try{ + const c = await api('/api/certificates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); + $('link').value=c.url; $('img').src=c.png+'?t='+Date.now(); + $('dlpng').href=c.png; $('dlpdf').href=c.pdf; $('numbadge').textContent='№ '+c.number; + $('result').classList.add('show'); loadDocs(); + }catch(e){ $('err').textContent=e.message; } +} +function copyLink(){ const el=$('link'); el.select(); navigator.clipboard.writeText(el.value); + $('copy').textContent='Скопировано'; setTimeout(()=>$('copy').textContent='Копировать',1200); } + +async function loadDocs(){ + let items; try{ items = await api('/api/certificates?limit=200'); }catch(e){ return; } + const box=$('docs'); + if(!items.length){ box.innerHTML='

'+(ADMIN?'Документов пока нет.':'У вас пока нет сертификатов. Если ожидаете документ — проверьте, что администратор указал этот email как получателя.')+'

'; return; } + box.innerHTML = items.map(c=>`
+ +
+
№ ${esc(c.number)}
+
${esc(c.fio||'')}
+
${esc(c.title||'')}${c.recipient_email?(' · '+esc(c.recipient_email)):''}
+ ${c.public_url?``:''} + +
`).join(''); + box.querySelectorAll('[data-del]').forEach(a=>a.addEventListener('click',async ev=>{ + ev.preventDefault(); + if(!confirm('Удалить документ №'+a.dataset.del+'?')) return; + try{ await api('/api/certificates/'+a.dataset.del,{method:'DELETE'}); loadDocs(); }catch(e){ alert(e.message); } + })); + box.querySelectorAll('[data-share]').forEach(a=>a.addEventListener('click',async ev=>{ + ev.preventDefault(); + const seq=a.dataset.share, isOn=a.dataset.on==='1'; + try{ + if(isOn){ await api('/api/certificates/'+seq+'/share',{method:'DELETE'}); } + else{ + const r = await api('/api/certificates/'+seq+'/share',{method:'POST'}); + try{ await navigator.clipboard.writeText(r.public_url); }catch(e){} + alert('Публичная ссылка создана и скопирована:\n'+r.public_url); + } + loadDocs(); + }catch(e){ alert(e.message); } + })); + box.querySelectorAll('[data-copy]').forEach(el=>el.addEventListener('click',ev=>{ + ev.preventDefault(); + navigator.clipboard.writeText(el.dataset.copy).catch(()=>{}); + el.textContent='скопировано'; setTimeout(()=>el.textContent='копировать',1200); + })); +} + +async function changePassword(){ + $('pwmsg').textContent=''; + const cur=$('pw_cur').value, nw=$('pw_new').value; + try{ + await api('/api/auth/password',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({current:cur, password:nw})}); + $('pw_cur').value=''; $('pw_new').value=''; $('pwmsg').textContent='Пароль изменён'; + }catch(e){ $('pwmsg').textContent=e.message; } +} +async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; } + +window.addEventListener('DOMContentLoaded',()=>{ + loadDocs(); + if(ADMIN && $('template')) loadTemplates(); +}); +""" + +USERS_JS = r""" +const $ = id => document.getElementById(id); +const ME = window.CURRENT_USER; +async function api(path, opts){ + const r = await fetch(path, opts); + if(!r.ok){ let m='Ошибка'; try{ m=(await r.json()).error||m; }catch(e){} throw new Error(m); } + return r.headers.get('content-type','').includes('json') ? r.json() : r.blob(); +} +function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } + +async function load(){ + const users = await api('/api/users'); + $('tbody').innerHTML = users.map(u=>` + ${esc(u.email)} + ${esc(u.name||'')} + ${u.role} + ${u.is_active?'да':'нет'} + ${esc((u.created_at||'').slice(0,10))} + + + + + ${u.id===ME.id?'':``} + `).join(''); + bind(); +} +function bind(){ + document.querySelectorAll('[data-del]').forEach(b=>b.onclick=async()=>{ + if(!confirm('Удалить пользователя?')) return; + try{ await api('/api/users/'+b.dataset.del,{method:'DELETE'}); load(); }catch(e){ alert(e.message); } + }); + document.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ + const role = b.dataset.cur==='admin'?'user':'admin'; + try{ await api('/api/users/'+b.dataset.role,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({role})}); load(); }catch(e){ alert(e.message); } + }); + document.querySelectorAll('[data-active]').forEach(b=>b.onclick=async()=>{ + const is_active = b.dataset.cur!=='1'; + try{ await api('/api/users/'+b.dataset.active,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active})}); load(); }catch(e){ alert(e.message); } + }); + document.querySelectorAll('[data-pw]').forEach(b=>b.onclick=async()=>{ + const pw = prompt('Новый пароль (мин. 8 символов):'); if(!pw) return; + try{ await api('/api/users/'+b.dataset.pw,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})}); alert('Пароль обновлён'); }catch(e){ alert(e.message); } + }); +} +async function addUser(){ + $('msg').textContent=''; + const body={email:$('u_email').value.trim(), name:$('u_name').value.trim(), + password:$('u_pw').value, role:$('u_role').value}; + try{ await api('/api/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + $('u_email').value=''; $('u_name').value=''; $('u_pw').value=''; load(); + }catch(e){ $('msg').textContent=e.message; } +} +async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; } +window.addEventListener('DOMContentLoaded',load); +""" + + +def _head(title: str, extra_css: str = "") -> str: + return (f'' + f'' + f'{html.escape(title)}') + + +def auth_page_html(mode: str, notice: str = "") -> str: + is_reg = mode == "register" + title = "Регистрация" if is_reg else "Вход" + extra = ('' + if is_reg else "") + link = ('
Уже есть аккаунт? Войти
' + if is_reg else + '
Нет аккаунта? Зарегистрироваться
') + notice_html = f'
{html.escape(notice)}
' if notice else "" + return ( + _head(f"certgen — {title}") + + f'
' + f'

{title}

Сертификаты и благодарственные письма

' + f'{notice_html}' + f'
' + f'' + f'{extra}' + f'' + f'' + f'
{link}' + f'
' + f'' + ) + + +def dashboard_html(user: dict, base: str) -> str: + is_admin = user["role"] == "admin" + user_js = json.dumps({"id": user["id"], "email": user["email"], + "name": user.get("name"), "role": user["role"]}, + ensure_ascii=False) + role_pill = ('администратор' if is_admin + else 'пользователь') + users_link = 'Пользователи' if is_admin else "" + header = ( + '

certgen

' + f'{html.escape(user["email"])} {role_pill}' + f'{users_link}' + 'Выйти
' + ) + + if is_admin: + create_card = f""" +
+

Новый документ

+ +
+
+
+
+

Пользователь увидит документ после входа под этим email.

+ + +
+
+
+
+ + + +
+
+ +
""" + docs_title = "Все документы" + else: + create_card = "" + docs_title = "Мои документы" + + docs_card = f""" +
+

{docs_title}

+
+
""" + + account_card = """ +
+

Аккаунт

+
+
+
+
+
+
+
""" + + main_cls = "" if is_admin else ' class="single"' + return ( + _head("certgen — кабинет") + '' + header + + f'{create_card}{docs_card}' + + (account_card if is_admin else "") + + '' + f'' + '' + ) + + +def users_page_html(user: dict, base: str) -> str: + user_js = json.dumps({"id": user["id"], "email": user["email"], "role": user["role"]}, + ensure_ascii=False) + header = ( + '

certgen

' + 'Пользователи' + '← Кабинет ' + 'Выйти
' + ) + return ( + _head("certgen — пользователи") + '' + header + + '
' + '

Новый пользователь

' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '

Список

' + '' + '
EmailИмяРольАктивенСоздан
' + f'' + ) + + +def cert_page_html(cert: dict, base: str) -> str: + safe = html.escape + return ( + _head(f"Документ №{cert['number']}", + ".wrap{max-width:1000px;margin:0 auto;padding:28px}" + ".head{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}" + ".head h1{font-size:18px;margin:0}" + ".big{background:#fff;border-radius:14px;overflow:hidden;border:1px solid var(--line)}" + ".big img{width:100%;display:block}") + + '
' + '
' + f'

Документ №{safe(cert["number"])}

' + f'{safe(cert.get("fio") or "")} · {safe(cert.get("date") or "")}' + '' + f'Скачать PNG ' + f'Скачать PDF' + '
' + f'
document
' + '
' + ) + + +def public_cert_page_html(cert: dict, base: str, image_size: tuple[int, int] | None = None) -> str: + safe = html.escape + esc = lambda s: html.escape(str(s or ""), quote=True) # noqa: E731 + token = cert.get("public_token") or "" + url = f"{base}/s/{token}" + img = f"{base}/s/{token}.png" + pdf = f"{base}/s/{token}.pdf" + + number = cert.get("number") or "" + doc_title = cert.get("title") or "Документ" + fio = cert.get("fio") or "" + date = cert.get("date") or "" + + page_title = f"{doc_title} № {number}" + (f" — {fio}" if fio else "") + desc_parts = [f"{doc_title} № {number}"] + if fio: + desc_parts.append(f"получатель: {fio}") + if date: + desc_parts.append(f"дата: {date}") + description = ". ".join(desc_parts) + ". Электронный документ certgen." + image_alt = f"{doc_title} № {number}" + (f", {fio}" if fio else "") + + dims = "" + if image_size: + dims = (f'\n' + f'\n') + secure = ('\n' % esc(img)) if url.startswith("https://") else "" + + favicon = ("data:image/svg+xml," + "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E" + "%3Crect width='32' height='32' rx='7' fill='%230f1116'/%3E" + "%3Ccircle cx='16' cy='16' r='7' fill='%23c69e2d'/%3E%3C/svg%3E") + + jsonld = { + "@context": "https://schema.org", + "@type": "EducationalOccupationalCredential", + "name": page_title, + "description": description, + "image": img, + "url": url, + "dateCreated": cert.get("date"), + "credentialCategory": "certificate", + "creator": {"@type": "Organization", "name": "certgen"}, + } + + head = f""" + + +{safe(page_title)} + + + + + + + + + + + + + + + +{dims}{secure} + + + + + + + +""" + + return ( + head + + '
' + '
' + f'

Документ №{safe(number)}

' + f'{safe(fio)} · {safe(date)}' + '' + f'Скачать PDF ' + f'Скачать PNG' + '
' + f'
{esc(image_alt)}
' + '
' + ) diff --git a/examples/make_sozidateli_template.py b/examples/make_sozidateli_template.py new file mode 100644 index 0000000..5740597 --- /dev/null +++ b/examples/make_sozidateli_template.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Создать/обновить шаблон «Созидатели» из исходной картинки-примера. + +Использование: + python examples/make_sozidateli_template.py "Сертификат Созидатели.png" + +Скрипт: + * увеличивает фон x2 (для более чёткой печати), + * сохраняет его в data/templates/custom/sozidateli/background.png, + * регистрирует image-шаблон `sozidateli` с полем ФИО в нужной позиции. +В исходной картинке заголовок, «Подтверждает, что», основной текст и подпись +уже нарисованы — поэтому поверх накладывается только ФИО. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PIL import Image # noqa: E402 + +from certgen import config, store # noqa: E402 + +SLUG = "sozidateli" +SCALE = 2 +LAYOUT = [ + {"key": "fio", "x": 0.5, "y": 0.402, "size": 66, "font": "NotoSans-Bold", + "color": "#ffffff", "align": "center", "max_width": 0.88, "line_spacing": 1.15}, +] + + +def main() -> None: + src = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("Сертификат Созидатели.png") + if not src.exists(): + raise SystemExit(f"Не найден файл: {src}") + + store.init_db() + img = Image.open(src).convert("RGB") + bg = img.resize((img.width * SCALE, img.height * SCALE), Image.LANCZOS) + bg_dir = config.TEMPLATES_DIR / "custom" / SLUG + bg_dir.mkdir(parents=True, exist_ok=True) + bg_path = bg_dir / "background.png" + bg.save(bg_path, "PNG") + + tpl = store.upsert_template( + slug=SLUG, + title="Сертификат «Созидатели»", + kind="image", + background=str(bg_path.relative_to(config.TEMPLATES_DIR)), + layout=LAYOUT, + page={"width": bg.width, "height": bg.height}, + defaults={"title": "СЕРТИФИКАТ", "body": ""}, + ) + print(f"Шаблон '{SLUG}' готов: {bg.width}x{bg.height}, фон {bg_path}") + + +if __name__ == "__main__": + main() diff --git a/examples/names.csv b/examples/names.csv new file mode 100644 index 0000000..ef04829 --- /dev/null +++ b/examples/names.csv @@ -0,0 +1,4 @@ +ФИО;Текст;Дата;Префикс +Смирнов Олег Петрович;за победу в олимпиаде по физике;; +Волкова Мария Ивановна;за активное участие в проекте;; +Кузнецов Дмитрий Олегович;за высокие результаты в обучении;; diff --git a/examples/sample.html b/examples/sample.html new file mode 100644 index 0000000..272d373 --- /dev/null +++ b/examples/sample.html @@ -0,0 +1,32 @@ + + + + + + + +
+

{{title}}

+
настоящий сертификат подтверждает, что
+
{{fio}}
+
{{body}}
+
{{date}} · № {{number}}
+
+ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..58df511 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Pillow>=9.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..4ddfac3 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Точка входа: python run.py""" +from certgen.__main__ import main + +if __name__ == "__main__": + main() diff --git a/Сертификат Созидатели.png b/Сертификат Созидатели.png new file mode 100644 index 0000000..06d8d19 Binary files /dev/null and b/Сертификат Созидатели.png differ