Initial commit: certgen — сервис генерации сертификатов

This commit is contained in:
apuc committed 2026-10-16 17:17:47 +03:00
commit 49bac43e16
23 files changed
+3046

No files matched your search

+374
View File
@@ -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
```
Откройте <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/<seq>` (доступна только вошедшим — получателю
или администратору). Плюс экспорт 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/<id> { "role"?, "is_active"?, "name"?, "password"? }
DELETE /api/users/<id>
```
### Создать документ (только админ)
```
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[/<slug>]` | админ | шаблоны |
| POST/DELETE | `/api/templates[...]` | админ | изменение шаблонов |
| GET | `/api/certificates?limit=&offset=` | вход | user — свои, admin — все |
| GET | `/api/certificates/<seq\|номер>` | вход | один документ (с проверкой владения) |
| DELETE | `/api/certificates/<seq\|номер>` | админ | удалить документ и файлы |
| POST | `/api/certificates/batch` | админ | пакетное создание |
| POST | `/api/preview` | админ | вернуть PNG без сохранения |
| POST | `/api/certificates/<seq>/share` | админ | включить/обновить публичную ссылку |
| DELETE | `/api/certificates/<seq>/share` | админ | отключить публичную ссылку |
| GET | `/c/<seq\|номер>` и `.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/<token>", ... }
# отключить:
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/<slug>/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": "<PNG/JPG в base64>",
"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": "<html>... {{fio}} ... {{number}} ...</html>",
"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/