- 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
347 lines
16 KiB
HTML
347 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Что мы узнали на занятии</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
:root{--bg:#f5f5f5;--card:#fff;--border:#e5e5e5;--accent:#2563eb;--text:#171717;--muted:#737373;--radius:12px}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100dvh;padding:24px 16px}
|
|
h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text-align:center}
|
|
.sub{color:var(--muted);font-size:.85rem;margin-bottom:32px;text-align:center}
|
|
|
|
.form{width:100%;max-width:900px;margin:0 auto;display:grid;grid-template-columns:320px 1fr;gap:32px;align-items:start}
|
|
@media(max-width:760px){.form{grid-template-columns:1fr;max-width:480px}}
|
|
|
|
.photo-col{display:flex;flex-direction:column;gap:16px}
|
|
.photo-card{border-radius:var(--radius);padding:24px;display:flex;flex-direction:column;gap:16px;align-items:center}
|
|
.photo-btns{display:flex;flex-direction:column;gap:12px;width:100%}
|
|
.photo-btns button{width:100%;border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
|
|
.photo-btns button:hover{border-color:var(--accent)}
|
|
.photo-btns .cam{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
.photo-btns .cam:hover{opacity:.85}
|
|
.photo-btns .files-btn p{font-size:.8rem;font-weight:500;margin:0}
|
|
.files-btn{width:100%;border-radius:var(--radius);padding:12px 16px;font-size:.9rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--text);transition:border .15s,background .15s}
|
|
.files-btn:hover{border-color:var(--accent)}
|
|
.files-card{width:100%;border:1px dashed var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)}
|
|
.files-card .files-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
.files-card label{font-size:.8rem;font-weight:600;color:var(--text);text-transform:none;letter-spacing:0}
|
|
.files-card .files-clear{background:none;border:none;color:#ef4444;font-size:.8rem;cursor:pointer;padding:0;display:none}
|
|
.files-card .files-clear.show{display:block}
|
|
.files-list{display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
|
|
.files-list:empty{display:none}
|
|
.files-list .file-row{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:.8rem}
|
|
.files-list .file-row .f-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
|
.files-list .file-row .f-size{color:var(--muted);flex-shrink:0}
|
|
.files-list .file-row button{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}
|
|
.files-list .file-row button:hover{color:#ef4444}
|
|
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
|
.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)}
|
|
.preview.has-photo{border-style:solid}
|
|
.remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
|
.remove-photo.show{display:block}
|
|
|
|
.fields-col{display:flex;flex-direction:column;gap:20px}
|
|
.field{display:flex;flex-direction:column;gap:6px}
|
|
label{font-size:.8rem;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
input,select,textarea{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;outline:none;transition:border .15s}
|
|
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
|
|
.fixed-name{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:var(--radius);padding:14px 16px;font-size:.95rem;font-weight:600;text-align:center}
|
|
textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
|
|
|
|
.btn{width:100%;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);padding:16px;font-size:1rem;font-weight:600;cursor:pointer;transition:opacity .15s;margin-top:8px}
|
|
.btn:hover{opacity:.85}
|
|
.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
|
|
.footer{max-width:900px;margin:48px auto 0;padding-top:24px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:16px;font-size:.85rem;color:var(--muted)}
|
|
.footer .f-left{flex:1;text-align:left}
|
|
.footer .f-right{flex:1;text-align:right}
|
|
@media(max-width:760px){.footer{flex-direction:column;text-align:center}.footer .f-left,.footer .f-right{text-align:center}}
|
|
|
|
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#16a34a;color:#fff;padding:12px 24px;border-radius:var(--radius);font-size:.9rem;opacity:0;transition:opacity .3s;pointer-events:none;z-index:200}
|
|
.toast.show{opacity:1}
|
|
.toast.error{background:#dc2626}
|
|
|
|
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px}
|
|
nav a{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
|
|
nav a:hover{color:var(--text)}
|
|
|
|
.modal{position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:100;display:none}
|
|
.modal.open{display:flex}
|
|
.modal-box{background:var(--card);border-radius:16px;padding:16px;max-width:400px;width:90%;display:flex;flex-direction:column;gap:12px}
|
|
.modal-box video{width:100%;border-radius:var(--radius);background:#000;transform:scaleX(-1)}
|
|
.modal-actions{display:flex;gap:8px}
|
|
.modal-actions button{flex:1;padding:12px;border:none;border-radius:var(--radius);font-size:.9rem;font-weight:600;cursor:pointer}
|
|
.modal-actions .capture{background:var(--accent);color:#fff}
|
|
.modal-actions .cancel{background:var(--border);color:var(--text)}
|
|
.modal-actions button:hover{opacity:.85}
|
|
#camCanvas{display:none}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav>
|
|
<a href="admin.html">Админка</a>
|
|
</nav>
|
|
<h1 id="title">Что мы узнали на занятии</h1>
|
|
<p class="sub">Отметь, что ты делал(а) на занятии</p>
|
|
|
|
<form class="form" id="form">
|
|
<div class="photo-col">
|
|
<div class="photo-card">
|
|
<div class="preview-wrap">
|
|
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
|
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
|
|
</div>
|
|
<div class="photo-btns">
|
|
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
|
</div>
|
|
</div>
|
|
<div class="files-card">
|
|
<div class="files-head">
|
|
<label>Файлы проекта</label>
|
|
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
|
</div>
|
|
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
|
|
<input type="file" id="filesInput" multiple hidden>
|
|
<div class="files-list" id="filesList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fields-col">
|
|
<div class="field" id="nameField">
|
|
<label>Фамилия и имя</label>
|
|
<input type="text" id="nameInput" list="studentList" placeholder="Например, Иванов Иван" required>
|
|
</div>
|
|
<datalist id="studentList"></datalist>
|
|
|
|
<div class="field">
|
|
<label>Группа</label>
|
|
<select id="groupInput" required>
|
|
<option value="" disabled selected>Выбери группу</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Что сделал</label>
|
|
<textarea id="descInput" placeholder="Опиши, что проходил, что делал на занятии..." required></textarea>
|
|
</div>
|
|
|
|
<input type="text" name="website" id="hpWebsite" tabindex="-1" autocomplete="off" style="display:none" value="">
|
|
|
|
<button class="btn" type="submit" id="submitBtn">Отправить</button>
|
|
</div>
|
|
</form>
|
|
|
|
<footer class="footer">
|
|
<div class="f-left" id="footerLeft"></div>
|
|
<div class="f-right" id="footerRight"></div>
|
|
</footer>
|
|
|
|
<div class="modal" id="camModal">
|
|
<div class="modal-box">
|
|
<video id="camVideo" autoplay playsinline></video>
|
|
<canvas id="camCanvas"></canvas>
|
|
<div class="modal-actions">
|
|
<button class="cancel" onclick="closeCam()">Отмена</button>
|
|
<button class="capture" onclick="capture()">Снять</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast">Готово!</div>
|
|
|
|
<script>
|
|
const API = location.origin;
|
|
const form = document.getElementById('form');
|
|
const preview = document.getElementById('preview');
|
|
const placeholderSrc = preview.src;
|
|
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
|
const groupInput = document.getElementById('groupInput');
|
|
const toast = document.getElementById('toast');
|
|
const camVideo = document.getElementById('camVideo');
|
|
const camCanvas = document.getElementById('camCanvas');
|
|
const camModal = document.getElementById('camModal');
|
|
let capturedBlob = null;
|
|
let stream = null;
|
|
const descInput = document.getElementById('descInput');
|
|
const filesInput = document.getElementById('filesInput');
|
|
const filesList = document.getElementById('filesList');
|
|
const filesClearBtn = document.getElementById('filesClearBtn');
|
|
let selectedFiles = [];
|
|
|
|
function humanSize(b) {
|
|
if (b < 1024) return b + ' Б';
|
|
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
|
return (b / 1048576).toFixed(1) + ' МБ';
|
|
}
|
|
|
|
function renderFiles() {
|
|
filesList.innerHTML = '';
|
|
selectedFiles.forEach((f, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'file-row';
|
|
const name = document.createElement('span');
|
|
name.className = 'f-name';
|
|
name.textContent = f.name;
|
|
name.title = f.name;
|
|
const size = document.createElement('span');
|
|
size.className = 'f-size';
|
|
size.textContent = humanSize(f.size);
|
|
const del = document.createElement('button');
|
|
del.type = 'button';
|
|
del.textContent = '✕';
|
|
del.onclick = () => removeFile(i);
|
|
row.append(name, size, del);
|
|
filesList.appendChild(row);
|
|
});
|
|
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
|
}
|
|
|
|
function removeFile(i) {
|
|
selectedFiles.splice(i, 1);
|
|
renderFiles();
|
|
}
|
|
|
|
function clearFiles() {
|
|
selectedFiles = [];
|
|
filesInput.value = '';
|
|
renderFiles();
|
|
}
|
|
|
|
filesInput.addEventListener('change', () => {
|
|
const keep = 10 - selectedFiles.length;
|
|
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
|
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
|
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
|
filesInput.value = '';
|
|
renderFiles();
|
|
});
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
let studentName = '';
|
|
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
if (params.get('nameInput')) {
|
|
studentName = params.get('nameInput');
|
|
document.getElementById('nameInput').value = studentName;
|
|
const field = document.getElementById('nameField');
|
|
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
|
}
|
|
|
|
(async () => {
|
|
const res = await fetch(`${API}/api/students`);
|
|
const students = await res.json();
|
|
const dl = document.getElementById('studentList');
|
|
students.forEach(s => {
|
|
const o = document.createElement('option');
|
|
o.value = s.name;
|
|
dl.appendChild(o);
|
|
});
|
|
const me = studentName ? students.find(s => s.name === studentName) : null;
|
|
if (me && me.group_id) {
|
|
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
|
groupInput.value = String(me.group_id);
|
|
} else {
|
|
const groupsRes = await fetch(`${API}/api/groups/active`);
|
|
const groups = await groupsRes.json();
|
|
groups.forEach(g => {
|
|
const opt = document.createElement('option');
|
|
opt.value = g.id;
|
|
opt.textContent = g.name;
|
|
groupInput.appendChild(opt);
|
|
});
|
|
if (!groups.length) {
|
|
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
|
}
|
|
}
|
|
try {
|
|
const fRes = await fetch(`${API}/api/public-settings`);
|
|
const f = await fRes.json();
|
|
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
|
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
|
} catch (e) { /* ignore */ }
|
|
})();
|
|
|
|
function showPreview(src) {
|
|
preview.src = src;
|
|
preview.classList.add('has-photo');
|
|
removePhotoBtn.classList.add('show');
|
|
}
|
|
|
|
function removePhoto() {
|
|
preview.src = placeholderSrc;
|
|
preview.classList.remove('has-photo');
|
|
removePhotoBtn.classList.remove('show');
|
|
capturedBlob = null;
|
|
}
|
|
|
|
async function openCam() {
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
|
camVideo.srcObject = stream;
|
|
camModal.classList.add('open');
|
|
} catch (e) {
|
|
alert('Не удалось открыть камеру: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function closeCam() {
|
|
camModal.classList.remove('open');
|
|
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
}
|
|
|
|
function capture() {
|
|
camCanvas.width = camVideo.videoWidth;
|
|
camCanvas.height = camVideo.videoHeight;
|
|
const ctx = camCanvas.getContext('2d');
|
|
ctx.translate(camCanvas.width, 0);
|
|
ctx.scale(-1, 1);
|
|
ctx.drawImage(camVideo, 0, 0);
|
|
camCanvas.toBlob(blob => {
|
|
capturedBlob = blob;
|
|
showPreview(URL.createObjectURL(blob));
|
|
closeCam();
|
|
}, 'image/jpeg', 0.85);
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const btn = document.getElementById('submitBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Отправка...';
|
|
|
|
const fd = new FormData();
|
|
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
|
fd.append('group_id', groupInput.value);
|
|
fd.append('description', document.getElementById('descInput').value);
|
|
fd.append('website', document.getElementById('hpWebsite').value);
|
|
|
|
try {
|
|
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
|
if (res.status === 429) {
|
|
const e = await res.json().catch(() => ({}));
|
|
toast.textContent = e.error || 'Уже ответили: подождите';
|
|
toast.classList.add('show', 'error');
|
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(await res.text());
|
|
form.reset();
|
|
removePhoto();
|
|
clearFiles();
|
|
toast.textContent = 'Готово!';
|
|
toast.classList.remove('error');
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
} catch (err) {
|
|
alert('Ошибка: ' + err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Отправить';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |