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

+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>'
)