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
+23 -1
View File
@@ -75,6 +75,20 @@
<label>Ученик (необязательно)</label>
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
</div>
<div>
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
</div>
<div>
<label>
<input type="checkbox" id="linkAnonymizeNames">
Показывать только инициалы учеников (анонимизировать)
</label>
</div>
<div>
<label>Пароль для доступа (необязательно)</label>
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
</div>
<div class="actions">
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
<button class="save" onclick="createLink()">Создать</button>
@@ -402,6 +416,9 @@ async function openLinkCreate() {
const dfl = df.split('-').reverse().join('.');
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
document.getElementById('linkStudent').value = currentStudent;
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkAnonymizeNames').checked = false;
document.getElementById('linkPassword').value = '';
document.getElementById('linkCreateView').style.display = '';
document.getElementById('linkResultView').style.display = 'none';
document.getElementById('linkModal').classList.add('open');
@@ -417,11 +434,16 @@ async function createLink() {
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('dateFrom').value;
const date_to = document.getElementById('dateTo').value;
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
const access_password = document.getElementById('linkPassword').value;
if (!name) { alert('Введите название'); return; }
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const res = await fetch(`${API}/api/links`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null })
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null })
});
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
const link = await res.json();
+29 -1
View File
@@ -41,6 +41,20 @@
<input type="date" id="linkDateTo">
</div>
</div>
<div>
<label>Истекает через (дни)</label>
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
</div>
<div>
<label>
<input type="checkbox" id="linkAnonymizeNames">
Показывать только инициалы учеников (анонимизировать)
</label>
</div>
<div>
<label>Пароль для доступа (необязательно)</label>
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
</div>
<div class="actions">
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
<button class="save" onclick="saveLink()">Сохранить</button>
@@ -71,6 +85,9 @@ async function loadLinks() {
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
${l.anonymize_names ? ` · <span style="color:#f59e0b">анонимизировано</span>` : ''}
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
</div>
</div>
@@ -92,6 +109,9 @@ async function openLinkModal() {
document.getElementById('linkName').value = '';
document.getElementById('linkDateFrom').value = '';
document.getElementById('linkDateTo').value = '';
document.getElementById('linkExpiresDays').value = '7';
document.getElementById('linkAnonymizeNames').checked = false;
document.getElementById('linkPassword').value = '';
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
@@ -116,6 +136,9 @@ async function openLinkEdit(id) {
document.getElementById('linkName').value = l.name;
document.getElementById('linkDateFrom').value = l.date_from || '';
document.getElementById('linkDateTo').value = l.date_to || '';
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
document.getElementById('linkPassword').value = ''; // Never show existing password
if (!groups.length) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
groups = await res.json();
@@ -145,8 +168,13 @@ async function saveLink() {
const student_name = document.getElementById('linkStudent').value;
const date_from = document.getElementById('linkDateFrom').value;
const date_to = document.getElementById('linkDateTo').value;
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
const access_password = document.getElementById('linkPassword').value;
if (!name) { alert('Введите название'); return; }
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null });
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
const body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null });
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
method: editingId ? 'PUT' : 'POST',
headers: hdrJson(),
+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>