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

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

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""certgen — небольшой сервис генерации сертификатов, благодарственных писем и пр.
Каждому документу присваивается номер; документ доступен по ссылке /c/<номер>.
Чистый Python stdlib + Pillow (+ опционально Chromium для HTML-шаблонов).
"""
__version__ = "0.1.0"
+19
View File
@@ -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
View File
@@ -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()
+155
View File
@@ -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.
+49
View File
@@ -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
+136
View File
@@ -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"},
],
},
]
+41
View File
@@ -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)
+86
View File
@@ -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
View File
@@ -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()
+238
View File
@@ -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)
+70
View File
@@ -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)
+479
View File
@@ -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
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>'
)