Initial commit: certgen — сервис генерации сертификатов
This commit is contained in:
commit
49bac43e16
23 files changed
+3046
No files matched your search
+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()
|
||||
Reference in new issue
Block a user