add share-link password/expiry, journal touch-ups, ddns helper and audit docs

This commit is contained in:
dev
2026-09-08 10:54:24 +03:00
parent dd5a2ea288
commit 57f2ea4f41
11 changed files with 626 additions and 63 deletions
+133 -51
View File
@@ -54,6 +54,14 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
@media(max-width:1000px){.grid{grid-template-columns:repeat(3,1fr)}.photos-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:760px){.grid{grid-template-columns:repeat(2,1fr)}.photos-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:480px){.grid{grid-template-columns:1fr}.photos-grid{grid-template-columns:1fr}}
.password-modal{position:fixed;inset:0;background:rgba(12,14,20,.85);display:none;align-items:center;justify-content:center;z-index:200;padding:24px}
.password-modal.open{display:flex}
.password-box{background:var(--card-bg);border-radius:16px;padding:24px;max-width:360px;width:100%;text-align:center}
.password-box h3{margin-bottom:8px}
.password-box p{color:var(--gray);font-size:.9rem;margin-bottom:16px}
.password-box input{width:100%;padding:14px 16px;border:1px solid #EBEBEB;border-radius:12px;font-size:1rem;margin-bottom:16px}
.password-box button{width:100%;padding:14px;background:var(--yellow);color:var(--dark);border:none;border-radius:12px;font-size:1rem;font-weight:700;cursor:pointer}
.password-box button:hover{opacity:.9}
</style>
</head>
<body>
@@ -70,8 +78,17 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
</main>
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
<div class="password-modal" id="passwordModal">
<div class="password-box">
<h3>🔒 Ссылка защищена паролем</h3>
<p>Введите пароль для доступа к записям</p>
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
<button onclick="submitPassword()">Войти</button>
</div>
</div>
<script>
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
@@ -86,73 +103,138 @@ function fileIcon(t){
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
return icons[t]||icons.other;
}
function filesHTML(files){
function filesHTML(files, password){
if(!files||!files.length)return '';
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
return `<div class="files-block">
<div class="files-label">Работы резидента</div>
<div class="files">${files.map(f=>{
const t = fileType(f.name);
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
?`href="#" onclick="showImg('/api/files/${f.token}');return false;"`
:`href="/api/files/${f.token}" download`}>
?`href="#" onclick="showImg('/api/files/${f.token}${pw}');return false;"`
:`href="/api/files/${f.token}${pw}" download`}>
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
</a>`;
}).join('')}</div>
</div>`;
}
(async()=>{
let currentToken = '';
let currentPassword = null;
async function fetchShareData(token, password) {
const headers = {};
if (password) headers['x-share-password'] = password;
const res = await fetch('/api/share/' + token, { headers });
const data = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, data };
}
async function loadShare() {
const token = location.pathname.split('/').filter(Boolean).pop() || '';
try {
const res = await fetch('/api/share/' + token);
if (!res.ok) throw new Error();
const data = await res.json();
const heading = data.group_name || data.name || 'Записи занятий';
document.title = heading;
document.getElementById('title').textContent = heading;
const chips = [];
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
currentToken = token;
currentPassword = null;
const pb = document.getElementById('photosBlock');
if (data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
pb.style.display = 'block';
} else {
pb.style.display = 'none';
const tryLoad = async (pwd) => {
const result = await fetchShareData(token, pwd);
if (result.ok) {
renderShare(result.data, pwd);
return true;
}
if (result.status === 401 && result.data.passwordRequired) {
showPasswordModal();
return false;
}
if (result.status === 410) {
showError('Срок действия ссылки истёк');
return true;
}
showError('Ссылка не найдена или была удалена');
return true;
};
const grid = document.getElementById('grid');
if (!data.entries.length) {
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
return;
}
grid.innerHTML = data.entries.map(e => `
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${e.photo_path}" onclick="showImg('${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
<div class="body">
<div class="name">${esc(e.student_name)}</div>
<div>${e.group_name ? `<span class="group">${esc(e.group_name)}</span>` : ''}</div>
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files)}
</div>
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
</div>
`).join('');
} catch {
document.getElementById('title').textContent = 'Ссылка не найдена';
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
await tryLoad(null);
}
function showPasswordModal() {
document.getElementById('passwordInput').value = '';
document.getElementById('passwordModal').classList.add('open');
document.getElementById('passwordInput').focus();
}
function hidePasswordModal() {
document.getElementById('passwordModal').classList.remove('open');
}
async function submitPassword() {
const pwd = document.getElementById('passwordInput').value;
if (!pwd) return;
const result = await fetchShareData(currentToken, pwd);
if (result.ok) {
hidePasswordModal();
currentPassword = pwd;
renderShare(result.data, pwd);
} else {
alert('Неверный пароль');
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
}
}
function showError(msg) {
document.getElementById('title').textContent = msg;
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
document.getElementById('photosBlock').style.display = 'none';
}
function renderShare(data, password) {
const heading = data.group_name || data.name || 'Записи занятий';
document.title = heading;
document.getElementById('title').textContent = heading;
const chips = [];
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
const pb = document.getElementById('photosBlock');
if (data.photos && data.photos.length) {
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
</a>
`).join('');
pb.style.display = 'block';
} else {
pb.style.display = 'none';
}
const grid = document.getElementById('grid');
if (!data.entries.length) {
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
return;
}
const isAnonymized = data.anonymize_names;
grid.innerHTML = data.entries.map(e => `
<div class="card">
<div class="photo">
${e.photo_path
? `<img src="${e.photo_path}" onclick="showImg('${e.photo_path}')" alt="">`
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
</div>
<div class="body">
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
<div class="desc">${esc(e.description)}</div>
${filesHTML(e.files, password)}
</div>
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
</div>
`).join('');
}
(async()=>{
await loadShare();
})();
</script>
</body>