- Add honeypot field to public submission form + server-side check - Serve shared files only in context of a valid share link (/api/share/:shareToken/files/:fileToken) - Switch backup restore upload to diskStorage (50MB) with temp-dir cleanup - Limit JSON body to 1mb - Document fixed audit items
242 lines
13 KiB
HTML
242 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<meta name="theme-color" content="#F7C90E">
|
|
<title>Записи занятий</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
:root{--yellow:#F7C90E;--dark:#211E22;--gray:#9C9C9C;--page:#f6f6f6;--card-bg:#fff;--radius:30px}
|
|
body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background:var(--page);color:var(--dark);min-height:100dvh}
|
|
.hero{background:var(--yellow);padding:44px 20px 52px;text-align:center;position:relative;overflow:hidden}
|
|
.hero h1{font-size:clamp(1.6rem,4.5vw,2.9rem);font-weight:800;line-height:1.05;letter-spacing:-.02em;color:var(--dark);max-width:900px;margin:0 auto}
|
|
.hero .chips{margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap}
|
|
.hero .chip{background:#fff;color:var(--dark);border-radius:999px;padding:8px 18px;font-size:.88rem;font-weight:600;box-shadow:5px 5px 10px rgba(216,173,0,.25)}
|
|
.wrap{max-width:1200px;margin:0 auto;padding:48px 28px 70px}
|
|
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:28px}
|
|
.card{background:var(--card-bg);border-radius:var(--radius);padding:10px 10px 14px;box-shadow:0 1px 3px rgba(20,30,40,.06);transition:transform .18s ease,box-shadow .18s ease;display:flex;flex-direction:column}
|
|
.card:hover{transform:translateY(-4px);box-shadow:0 14px 30px rgba(20,30,40,.14)}
|
|
.card .photo{border-radius:24px;overflow:hidden;background:#E7E7E7}
|
|
.card .photo img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;cursor:zoom-in}
|
|
.card .no-photo{aspect-ratio:1/1;background:#E7E7E7;display:flex;align-items:center;justify-content:center;color:var(--gray)}
|
|
.card .no-photo svg{width:52%;height:52%}
|
|
.card .no-photo svg *{fill:currentColor}
|
|
.card .body{padding:14px 6px 4px;display:flex;flex-direction:column;gap:6px;flex:1}
|
|
.card .name{font-size:1.02rem;font-weight:700;color:var(--dark);letter-spacing:-.01em}
|
|
.card .group{display:inline-block;background:var(--yellow);color:var(--dark);font-size:.72rem;font-weight:700;padding:4px 11px;border-radius:999px;align-self:flex-start}
|
|
.card .desc{font-size:.9rem;line-height:1.45;color:#4a4f5a;white-space:pre-wrap;word-break:break-word}
|
|
.files-block{margin-top:10px}
|
|
.files-label{font-size:.7rem;font-weight:800;color:var(--gray);text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}
|
|
.card .files{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
.card .files a{display:flex;align-items:center;justify-content:center;width:44px;height:44px;background:#F6F6F6;border:1px solid #EBEBEB;color:#3a3f4a;text-decoration:none;border-radius:12px;transition:border .15s,color .15s,background .15s}
|
|
.card .files a:hover{border-color:var(--yellow);color:var(--dark);background:#fff}
|
|
.card .files .ficon{font-size:1.25rem;line-height:1;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;flex-shrink:0}
|
|
.card .files a.ftype-image .ficon{background:#EAF4E8}
|
|
.card .files a.ftype-music .ficon{background:#EAEEF6}
|
|
.card .files a.ftype-document .ficon{background:#F6F0E0}
|
|
.card .files a.ftype-presentation .ficon{background:#F6E4E8}
|
|
.card .files a.ftype-other .ficon{background:#EFEFEF}
|
|
.card .foot{margin-top:auto;padding:8px 6px 2px;font-size:.78rem;color:var(--gray)}
|
|
.empty{grid-column:1/-1;background:#fff;border-radius:var(--radius);padding:48px 24px;text-align:center;color:var(--gray);box-shadow:0 1px 3px rgba(20,30,40,.06)}
|
|
.empty .big{font-size:2rem;margin-bottom:10px}
|
|
.loading{grid-column:1/-1;text-align:center;color:var(--gray);padding:40px}
|
|
.lightbox{position:fixed;inset:0;background:rgba(12,14,20,.92);display:none;align-items:center;justify-content:center;z-index:100;cursor:zoom-out;padding:24px}
|
|
.lightbox.open{display:flex}
|
|
.lightbox img{max-width:100%;max-height:100%;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.5)}
|
|
#photosBlock{display:none;margin-bottom:44px}
|
|
.photos-label{font-size:.78rem;font-weight:800;color:var(--gray);text-transform:uppercase;letter-spacing:.05em;margin-bottom:14px}
|
|
.photos-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
|
.gphoto{position:relative;border-radius:18px;overflow:hidden;background:#fff;display:block;box-shadow:0 1px 3px rgba(20,30,40,.06);text-decoration:none}
|
|
.gphoto img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;cursor:zoom-in;transition:transform .18s ease}
|
|
.gphoto:hover img{transform:scale(1.03)}
|
|
.gcap{position:absolute;left:0;right:0;bottom:0;padding:22px 12px 9px;font-size:.74rem;font-weight:600;color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
@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>
|
|
<section class="hero">
|
|
<h1 id="title">Загрузка...</h1>
|
|
<div class="chips" id="chips"></div>
|
|
</section>
|
|
<main class="wrap">
|
|
<div id="photosBlock">
|
|
<div class="photos-label">Фото группы</div>
|
|
<div class="photos-grid" id="photosGrid"></div>
|
|
</div>
|
|
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
|
</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 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||'')}
|
|
function fileType(name){
|
|
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
|
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
|
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
|
|
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
|
|
return 'other';
|
|
}
|
|
function fileIcon(t){
|
|
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
|
return icons[t]||icons.other;
|
|
}
|
|
function filesHTML(files, password, shareToken){
|
|
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);
|
|
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
|
|
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
|
?`href="#" onclick="showImg('${url}');return false;"`
|
|
:`href="${url}" download`}>
|
|
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
|
</a>`;
|
|
}).join('')}</div>
|
|
</div>`;
|
|
}
|
|
|
|
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() || '';
|
|
currentToken = token;
|
|
currentPassword = null;
|
|
|
|
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;
|
|
};
|
|
|
|
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, currentToken)}
|
|
</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>
|
|
</html> |