add share-link password/expiry, journal touch-ups, ddns helper and audit docs
This commit is contained in:
+23
-1
@@ -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
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user