feat: анти-спам блокировка повторной отправки на главной (cooldown по spam_interval_min)

- index.html/index.js: экран подтверждения отправки с обратным отсчётом
- локальный запрет повторной отправки через localStorage (по настройке spam_interval_min)
- server.js: отдаём spam_interval_min в public-settings (default 30)
- Dockerfile: timeout для apk add, чтобы сборка не зависала на недоступном зеркале
- docker-compose.yml: отключаем cloudflared сервис (закомментирован)
This commit is contained in:
dev
2026-09-13 12:27:35 +03:00
parent 0b763e5738
commit 2c8beedc4b
5 changed files with 93 additions and 23 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ RUN set -e; \
try_repo() { \ try_repo() { \
printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \ printf "%s/%s/main\n%s/%s/community\n" "$1" "$V" "$1" "$V" > /etc/apk/repositories; \
echo "Trying apk mirror: $1"; \ echo "Trying apk mirror: $1"; \
apk add --no-cache openssl; \ timeout 45 apk add --no-cache --timeout 20 openssl; \
}; \ }; \
if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \ if ! try_repo "https://dl-cdn.alpinelinux.org/alpine" && \
! try_repo "https://mirrors.edge.kernel.org/alpine" && \ ! try_repo "https://mirrors.edge.kernel.org/alpine" && \
+16 -16
View File
@@ -72,22 +72,22 @@ services:
# если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и # если в wg/wg0.conf лежит конфиг — контейнер сначала поднимает VPN и
# только потом запускает туннель (исход Cloudflare через VPN). Без конфига # только потом запускает туннель (исход Cloudflare через VPN). Без конфига
# туннель стартует сразу, как в базовой схеме. # туннель стартует сразу, как в базовой схеме.
cloudflared: # cloudflared:
build: # build:
context: . # context: .
dockerfile: Dockerfile.cloudflared # dockerfile: Dockerfile.cloudflared
restart: unless-stopped # restart: unless-stopped
cap_add: # cap_add:
- NET_ADMIN # - NET_ADMIN
privileged: true # privileged: true
volumes: # volumes:
- ./wg:/etc/wireguard:ro # - ./wg:/etc/wireguard:ro
environment: # environment:
TZ: Europe/Moscow # TZ: Europe/Moscow
CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003} # CLOUDFLARE_TUNNEL_URL: ${CLOUDFLARE_TUNNEL_URL:-http://app:3003}
WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60} # WG_HANDSHAKE_TIMEOUT: ${WG_HANDSHAKE_TIMEOUT:-60}
depends_on: # depends_on:
- app # - app
+13
View File
@@ -63,6 +63,12 @@ textarea{resize:vertical;min-height:160px;font-family:inherit;line-height:1.5}
.toast.show{opacity:1} .toast.show{opacity:1}
.toast.error{background:#dc2626} .toast.error{background:#dc2626}
.sent-panel{width:100%;max-width:900px;margin:32px auto 0;background:var(--card);border:2px solid var(--accent);border-radius:var(--radius);padding:40px 32px;display:none;flex-direction:column;gap:20px;align-items:center;text-align:center;box-shadow:0 8px 24px rgba(37,99,235,.18)}
.sent-check{width:88px;height:88px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:2.6rem}
.sent-panel h2{font-size:1.6rem;font-weight:700;letter-spacing:-.02em;color:var(--text)}
.sent-panel p{font-size:1.05rem;color:var(--muted);max-width:520px}
.sent-timer{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px 20px;font-size:1rem;font-weight:600;color:var(--text)}
nav{position:fixed;top:16px;right:16px;display:flex;gap:8px} 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{color:var(--muted);text-decoration:none;font-size:.8rem;padding:6px 12px;border-radius:8px;transition:color .15s}
nav a:hover{color:var(--text)} nav a:hover{color:var(--text)}
@@ -132,6 +138,13 @@ nav a:hover{color:var(--text)}
</div> </div>
</form> </form>
<div class="sent-panel" id="sentPanel">
<div class="sent-check">✓</div>
<h2 id="sentTitle">Запись отправлена!</h2>
<p id="sentText">Спасибо! Твоя запись уже передана преподавателю.</p>
<div class="sent-timer" id="sentTimer"></div>
</div>
<footer class="footer"> <footer class="footer">
<div class="f-left" id="footerLeft"></div> <div class="f-left" id="footerLeft"></div>
<div class="f-right" id="footerRight"></div> <div class="f-right" id="footerRight"></div>
+60 -4
View File
@@ -15,6 +15,61 @@ const filesInput = document.getElementById('filesInput');
const filesList = document.getElementById('filesList'); const filesList = document.getElementById('filesList');
const filesClearBtn = document.getElementById('filesClearBtn'); const filesClearBtn = document.getElementById('filesClearBtn');
let selectedFiles = []; let selectedFiles = [];
const sentPanel = document.getElementById('sentPanel');
const sentTitle = document.getElementById('sentTitle');
const sentText = document.getElementById('sentText');
const sentTimer = document.getElementById('sentTimer');
const SENT_KEY = 'whatido_entry_sent_at';
let entryCooldownMin = 30;
let sentTimerId = null;
function unlockForm() {
clearInterval(sentTimerId);
sentTimerId = null;
localStorage.removeItem(SENT_KEY);
sentPanel.style.display = 'none';
form.style.display = 'grid';
toast.textContent = 'Можно отправить новую запись';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
}
function tickSentTimer(unlockTime) {
const remain = Math.max(0, unlockTime - Date.now());
if (remain <= 0) {
sentTimer.textContent = '';
unlockForm();
return;
}
const m = Math.floor(remain / 60000);
const s = Math.floor((remain % 60000) / 1000);
sentTimer.textContent = 'Новую запись можно отправить через ' + m + ' мин ' + s + ' сек';
}
function showSent(title, text, unlockTime) {
form.style.display = 'none';
sentPanel.style.display = 'flex';
sentTitle.textContent = title;
sentText.textContent = text;
tickSentTimer(unlockTime);
clearInterval(sentTimerId);
sentTimerId = setInterval(() => tickSentTimer(unlockTime), 1000);
}
function cooldownMs() { return entryCooldownMin * 60 * 1000; }
function checkPreviousSend() {
const raw = localStorage.getItem(SENT_KEY);
if (!raw) return;
const sentAt = parseInt(raw, 10);
if (!Number.isFinite(sentAt)) { localStorage.removeItem(SENT_KEY); return; }
const unlockTime = sentAt + cooldownMs();
if (Date.now() < unlockTime) {
showSent('Запись уже отправлена', 'Ты уже отправил(а) запись сегодня. Дождись окончания отсчёта, чтобы отправить новую.', unlockTime);
} else {
localStorage.removeItem(SENT_KEY);
}
}
function humanSize(b) { function humanSize(b) {
if (b < 1024) return b + ' Б'; if (b < 1024) return b + ' Б';
@@ -99,7 +154,10 @@ if (params.get('nameInput')) {
const f = await fRes.json(); const f = await fRes.json();
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left; if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right; if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
const c = parseInt(f.spam_interval_min, 10);
if (Number.isFinite(c) && c >= 1) entryCooldownMin = c;
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
checkPreviousSend();
})(); })();
function showPreview(src) { function showPreview(src) {
@@ -177,10 +235,8 @@ form.addEventListener('submit', async (e) => {
form.reset(); form.reset();
removePhoto(); removePhoto();
clearFiles(); clearFiles();
toast.textContent = 'Готово!'; localStorage.setItem(SENT_KEY, String(Date.now()));
toast.classList.remove('error'); showSent('Запись отправлена!', 'Спасибо! Твоя запись уже передана преподавателю.', Date.now() + cooldownMs());
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2500);
} catch (err) { } catch (err) {
alert('Ошибка: ' + err.message); alert('Ошибка: ' + err.message);
} finally { } finally {
+3 -2
View File
@@ -872,9 +872,10 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text']; const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min'];
const defaults = { spam_interval_min: '30' };
const result = {}; const result = {};
for (const k of keys) result[k] = await getSetting(k, ''); for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
return result; return result;
}); });
res.json(out); res.json(out);