Initial commit: certgen — сервис генерации сертификатов
This commit is contained in:
commit
49bac43e16
23 files changed
+3046
No files matched your search
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# данные сервиса (БД, сгенерированные сертификаты, фоны шаблонов)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# локальное окружение и секреты
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# редакторы/ОС
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""certgen — небольшой сервис генерации сертификатов, благодарственных писем и пр.
|
||||||
|
|
||||||
|
Каждому документу присваивается номер; документ доступен по ссылке /c/<номер>.
|
||||||
|
Чистый Python stdlib + Pillow (+ опционально Chromium для HTML-шаблонов).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -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()
|
||||||
+622
@@ -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()
|
||||||
@@ -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 <dr@jones.dk>
|
||||||
|
2020, Purism, SPC
|
||||||
|
2013, Vasudev Kamath <kamathvasudev@gmail.com>
|
||||||
|
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
|
||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
+115
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"),))
|
||||||
+507
@@ -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=>`<option value="${x.slug}">${esc(x.title)} (${x.slug})</option>`).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='<p class="muted">'+(ADMIN?'Документов пока нет.':'У вас пока нет сертификатов. Если ожидаете документ — проверьте, что администратор указал этот email как получателя.')+'</p>'; return; }
|
||||||
|
box.innerHTML = items.map(c=>`<div class="doc">
|
||||||
|
<img src="/c/${c.seq}.png" loading="lazy" alt="">
|
||||||
|
<div class="meta">
|
||||||
|
<div class="num">№ ${esc(c.number)}</div>
|
||||||
|
<div class="fio">${esc(c.fio||'')}</div>
|
||||||
|
<div class="muted" style="font-size:12px">${esc(c.title||'')}${c.recipient_email?(' · '+esc(c.recipient_email)):''}</div>
|
||||||
|
${c.public_url?`<div class="muted" style="font-size:12px;word-break:break-all">🔗 <a href="${c.public_url}" target="_blank">${c.public_url}</a> <a href="#" data-copy="${c.public_url}">копировать</a></div>`:''}
|
||||||
|
<div class="links"><a href="/c/${c.seq}" target="_blank">Открыть</a>
|
||||||
|
<a href="/c/${c.seq}.png" download>PNG</a>
|
||||||
|
<a href="/c/${c.seq}.pdf" download>PDF</a>
|
||||||
|
${ADMIN?`<a href="#" data-share="${c.seq}" data-on="${c.public_url?1:0}">${c.public_url?'Скрыть ссылку':'Сделать публичной'}</a>`:''}
|
||||||
|
${ADMIN?`<a href="#" data-del="${c.seq}" style="color:#ffb4b4">Удалить</a>`:''}</div>
|
||||||
|
</div></div>`).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=>`<tr>
|
||||||
|
<td>${esc(u.email)}</td>
|
||||||
|
<td>${esc(u.name||'')}</td>
|
||||||
|
<td><span class="pill ${u.role==='admin'?'admin':''}">${u.role}</span></td>
|
||||||
|
<td>${u.is_active?'да':'нет'}</td>
|
||||||
|
<td class="muted">${esc((u.created_at||'').slice(0,10))}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-ghost btn-sm" data-role="${u.id}" data-cur="${u.role}">${u.role==='admin'?'Снять админа':'Сделать админом'}</button>
|
||||||
|
<button class="btn-ghost btn-sm" data-active="${u.id}" data-cur="${u.is_active?1:0}">${u.is_active?'Отключить':'Включить'}</button>
|
||||||
|
<button class="btn-ghost btn-sm" data-pw="${u.id}">Сбросить пароль</button>
|
||||||
|
${u.id===ME.id?'':`<button class="btn-danger btn-sm" data-del="${u.id}">Удалить</button>`}
|
||||||
|
</td></tr>`).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'<!doctype html><html lang="ru"><head><meta charset="utf-8">'
|
||||||
|
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||||
|
f'<title>{html.escape(title)}</title><style>{STYLE}{extra_css}</style></head>')
|
||||||
|
|
||||||
|
|
||||||
|
def auth_page_html(mode: str, notice: str = "") -> str:
|
||||||
|
is_reg = mode == "register"
|
||||||
|
title = "Регистрация" if is_reg else "Вход"
|
||||||
|
extra = ('<label>Имя</label><input id="name" placeholder="Иван Иванов" autocomplete="name">'
|
||||||
|
if is_reg else "")
|
||||||
|
link = ('<div class="switch">Уже есть аккаунт? <a href="/login">Войти</a></div>'
|
||||||
|
if is_reg else
|
||||||
|
'<div class="switch">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div>')
|
||||||
|
notice_html = f'<div id="notice" class="notice">{html.escape(notice)}</div>' if notice else ""
|
||||||
|
return (
|
||||||
|
_head(f"certgen — {title}") +
|
||||||
|
f'<body><div class="authwrap"><div class="card">'
|
||||||
|
f'<h1>{title}</h1><p class="sub">Сертификаты и благодарственные письма</p>'
|
||||||
|
f'{notice_html}'
|
||||||
|
f'<form id="form" autocomplete="on">'
|
||||||
|
f'<label>Email</label><input id="email" type="email" placeholder="you@example.com" autocomplete="email" required>'
|
||||||
|
f'{extra}'
|
||||||
|
f'<label>Пароль</label><input id="password" type="password" placeholder="мин. 8 символов" autocomplete="current-password" required>'
|
||||||
|
f'<button class="btn-primary" type="submit">{title}</button>'
|
||||||
|
f'</form><div id="err" class="err"></div>{link}'
|
||||||
|
f'</div></div>'
|
||||||
|
f'<script>window.AUTH_MODE={json.dumps(mode)};{AUTH_JS}</script></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = ('<span class="pill admin">администратор</span>' if is_admin
|
||||||
|
else '<span class="pill">пользователь</span>')
|
||||||
|
users_link = '<a href="/users">Пользователи</a>' if is_admin else ""
|
||||||
|
header = (
|
||||||
|
'<header><span class="dot"></span><h1>certgen</h1>'
|
||||||
|
f'<span class="who">{html.escape(user["email"])} {role_pill}</span>'
|
||||||
|
f'<span class="spacer"></span>{users_link}'
|
||||||
|
'<a href="#" onclick="logout();return false">Выйти</a></header>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
create_card = f"""
|
||||||
|
<section class="card">
|
||||||
|
<h2>Новый документ</h2>
|
||||||
|
<label>Шаблон</label><select id="template"></select>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>ФИО получателя</label><input id="fio" placeholder="Иванов Иван Иванович"></div>
|
||||||
|
<div><label>Email получателя</label><input id="email" type="email" placeholder="user@example.com"></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="font-size:12px;margin:6px 0 0">Пользователь увидит документ после входа под этим email.</p>
|
||||||
|
<label>Заголовок (необязательно)</label><input id="title" placeholder="напр. СЕРТИФИКАТ">
|
||||||
|
<label>Дополнительный текст</label><textarea id="body" placeholder="за успешное прохождение курса «...»"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Дата</label><input id="date" placeholder="пусто — сегодня"></div>
|
||||||
|
<div><label>Префикс номера</label><input id="prefix" placeholder="напр. СЕРТ"></div>
|
||||||
|
</div>
|
||||||
|
<label>Особый номер 1–1000 (необязательно)</label>
|
||||||
|
<input id="number" placeholder="пусто — обычная нумерация с 1001">
|
||||||
|
<button class="btn-primary" onclick="create()">Создать документ</button>
|
||||||
|
<div class="actions"><button class="btn-ghost" onclick="preview()">Предпросмотр</button></div>
|
||||||
|
<div id="err" class="err"></div><div id="notice" class="notice"></div>
|
||||||
|
<div id="result" style="margin-top:16px;display:none">
|
||||||
|
<label>Ссылка <span class="pill" id="numbadge"></span></label>
|
||||||
|
<div class="linkbox"><input id="link" readonly>
|
||||||
|
<button class="btn-ghost" id="copy" onclick="copyLink()">Копировать</button></div>
|
||||||
|
<div class="actions">
|
||||||
|
<a id="dlpng" class="btn-ghost btn-sm" download>PNG</a>
|
||||||
|
<a id="dlpdf" class="btn-ghost btn-sm" download>PDF</a></div>
|
||||||
|
<div class="preview"><img id="img" alt=""></div>
|
||||||
|
</div>
|
||||||
|
</section>"""
|
||||||
|
docs_title = "Все документы"
|
||||||
|
else:
|
||||||
|
create_card = ""
|
||||||
|
docs_title = "Мои документы"
|
||||||
|
|
||||||
|
docs_card = f"""
|
||||||
|
<section class="card">
|
||||||
|
<h2>{docs_title}</h2>
|
||||||
|
<div class="cards" id="docs"></div>
|
||||||
|
</section>"""
|
||||||
|
|
||||||
|
account_card = """
|
||||||
|
<section class="card" style="grid-column:1/-1">
|
||||||
|
<h2>Аккаунт</h2>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Текущий пароль</label><input id="pw_cur" type="password"></div>
|
||||||
|
<div><label>Новый пароль</label><input id="pw_new" type="password" placeholder="мин. 8 символов"></div>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="btn-ghost" onclick="changePassword()">Сменить пароль</button></div>
|
||||||
|
<div id="pwmsg" class="notice"></div>
|
||||||
|
</section>"""
|
||||||
|
|
||||||
|
main_cls = "" if is_admin else ' class="single"'
|
||||||
|
return (
|
||||||
|
_head("certgen — кабинет") + '<body>' + header +
|
||||||
|
f'<main{main_cls}>{create_card}{docs_card}'
|
||||||
|
+ (account_card if is_admin else "") +
|
||||||
|
'</main>'
|
||||||
|
f'<script>window.CURRENT_USER={user_js};window.IS_ADMIN={str(is_admin).lower()};{DASH_JS}</script>'
|
||||||
|
'</body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
'<header><span class="dot"></span><h1>certgen</h1>'
|
||||||
|
'<span class="who">Пользователи</span><span class="spacer"></span>'
|
||||||
|
'<a href="/">← Кабинет</a> '
|
||||||
|
'<a href="#" onclick="logout();return false">Выйти</a></header>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_head("certgen — пользователи") + '<body>' + header +
|
||||||
|
'<main><section class="card" style="grid-column:1/-1">'
|
||||||
|
'<h2>Новый пользователь</h2>'
|
||||||
|
'<div class="row">'
|
||||||
|
'<div><label>Email</label><input id="u_email" type="email"></div>'
|
||||||
|
'<div><label>Имя</label><input id="u_name"></div></div>'
|
||||||
|
'<div class="row">'
|
||||||
|
'<div><label>Пароль</label><input id="u_pw" type="password" placeholder="мин. 8 символов"></div>'
|
||||||
|
'<div><label>Роль</label><select id="u_role"><option value="user">user</option>'
|
||||||
|
'<option value="admin">admin</option></select></div></div>'
|
||||||
|
'<button class="btn-primary" onclick="addUser()">Добавить</button>'
|
||||||
|
'<div id="msg" class="err"></div></section>'
|
||||||
|
'<section class="card" style="grid-column:1/-1"><h2>Список</h2>'
|
||||||
|
'<table><thead><tr><th>Email</th><th>Имя</th><th>Роль</th><th>Активен</th>'
|
||||||
|
'<th>Создан</th><th></th></tr></thead><tbody id="tbody"></tbody></table></section></main>'
|
||||||
|
f'<script>window.CURRENT_USER={user_js};{USERS_JS}</script></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}") +
|
||||||
|
'<body><div class="wrap">'
|
||||||
|
'<div class="head"><span class="dot"></span>'
|
||||||
|
f'<h1>Документ №{safe(cert["number"])}</h1>'
|
||||||
|
f'<span class="muted">{safe(cert.get("fio") or "")} · {safe(cert.get("date") or "")}</span>'
|
||||||
|
'<span style="margin-left:auto"></span>'
|
||||||
|
f'<a class="btn-ghost btn-sm" href="{base}/c/{cert["seq"]}.png" download>Скачать PNG</a> '
|
||||||
|
f'<a class="btn-ghost btn-sm" href="{base}/c/{cert["seq"]}.pdf" download>Скачать PDF</a>'
|
||||||
|
'</div>'
|
||||||
|
f'<div class="big"><img src="{base}/c/{cert["seq"]}.png" alt="document"></div>'
|
||||||
|
'</div></body></html>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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<meta property="og:image:width" content="{int(image_size[0])}">'
|
||||||
|
f'\n<meta property="og:image:height" content="{int(image_size[1])}">')
|
||||||
|
secure = ('\n<meta property="og:image:secure_url" content="%s">' % 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"""<!doctype html><html lang="ru"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{safe(page_title)}</title>
|
||||||
|
<meta name="description" content="{esc(description)}">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="author" content="certgen">
|
||||||
|
<meta name="theme-color" content="#c69e2d">
|
||||||
|
<link rel="canonical" href="{esc(url)}">
|
||||||
|
<link rel="icon" href="{favicon}">
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="certgen">
|
||||||
|
<meta property="og:locale" content="ru_RU">
|
||||||
|
<meta property="og:title" content="{esc(page_title)}">
|
||||||
|
<meta property="og:description" content="{esc(description)}">
|
||||||
|
<meta property="og:url" content="{esc(url)}">
|
||||||
|
<meta property="og:image" content="{esc(img)}">
|
||||||
|
<meta property="og:image:type" content="image/png">
|
||||||
|
<meta property="og:image:alt" content="{esc(image_alt)}">{dims}{secure}
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{esc(page_title)}">
|
||||||
|
<meta name="twitter:description" content="{esc(description)}">
|
||||||
|
<meta name="twitter:image" content="{esc(img)}">
|
||||||
|
<meta name="twitter:image:alt" content="{esc(image_alt)}">
|
||||||
|
<script type="application/ld+json">{json.dumps(jsonld, ensure_ascii=False)}</script>
|
||||||
|
<style>{STYLE}
|
||||||
|
.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}}
|
||||||
|
</style></head>"""
|
||||||
|
|
||||||
|
return (
|
||||||
|
head +
|
||||||
|
'<body><div class="wrap">'
|
||||||
|
'<div class="head"><span class="dot"></span>'
|
||||||
|
f'<h1>Документ №{safe(number)}</h1>'
|
||||||
|
f'<span class="muted">{safe(fio)} · {safe(date)}</span>'
|
||||||
|
'<span style="margin-left:auto"></span>'
|
||||||
|
f'<a class="btn-ghost btn-sm" href="{pdf}" download>Скачать PDF</a> '
|
||||||
|
f'<a class="btn-ghost btn-sm" href="{img}" download>Скачать PNG</a>'
|
||||||
|
'</div>'
|
||||||
|
f'<div class="big"><img src="{img}" alt="{esc(image_alt)}"></div>'
|
||||||
|
'</div></body></html>'
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ФИО;Текст;Дата;Префикс
|
||||||
|
Смирнов Олег Петрович;за победу в олимпиаде по физике;;
|
||||||
|
Волкова Мария Ивановна;за активное участие в проекте;;
|
||||||
|
Кузнецов Дмитрий Олегович;за высокие результаты в обучении;;
|
||||||
|
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
/* Размер страницы для PDF. Скриншот делается в размере page.width/height. */
|
||||||
|
@page { size: 297mm 210mm; margin: 0; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
/* 100vw/100vh — чтобы шаблон заполнял весь кадр скриншота. */
|
||||||
|
.cert {
|
||||||
|
width: 100vw; height: 100vh; box-sizing: border-box;
|
||||||
|
border: 14px solid #c69e2d; background: #fffdf7;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
text-align: center; font-family: Georgia, "DejaVu Serif", serif;
|
||||||
|
}
|
||||||
|
h1 { font-size: 5.4vh; letter-spacing: .08em; color: #1b2a4a; margin: 0; }
|
||||||
|
.sub { font-size: 2.4vh; color: #666; font-style: italic; margin-top: 2vh; }
|
||||||
|
.name { font-size: 6.5vh; font-weight: bold; color: #7a1f2b; margin: 4vh 0; }
|
||||||
|
.body { font-size: 2.8vh; color: #333; max-width: 70vw; }
|
||||||
|
.meta { font-size: 2.2vh; color: #555; margin-top: 5vh; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cert">
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
<div class="sub">настоящий сертификат подтверждает, что</div>
|
||||||
|
<div class="name">{{fio}}</div>
|
||||||
|
<div class="body">{{body}}</div>
|
||||||
|
<div class="meta">{{date}} · № {{number}}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=9.0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Точка входа: python run.py"""
|
||||||
|
from certgen.__main__ import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Reference in new issue
Block a user