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
+29
View File
@@ -0,0 +1,29 @@
# Пример конфигурации Caddy для публичного развёртывания WhatIDo.
#
# Порядок включения:
# 1. Скопируйте этот файл в ./Caddyfile (cp Caddyfile.example Caddyfile)
# 2. Замените yourdomain.example на реальный домен/WWW, указывающий на сервер
# 3. В docker-compose.yml расскомментируйте сервис caddy (и тома caddy_data/caddy_config)
# и переведите блок ports сервиса app в expose (см. комментарии в compose)
# 4. docker compose up -d --build
#
# Caddy автоматически получит Let's Encrypt сертификат на 80/443 портах.
# Запрос к app идёт по HTTPS на внутренний порт 3443 (самоподписанный серт
# приложения, поэтому tls_insecure_skip_verify).
kiberone.dns.army {
reverse_proxy app:3443 {
transport http {
tls_insecure_skip_verify
}
}
}
# Внутренний HTTP-листенер для Cloudflare Quick Tunnel (без редиректов).
:8080 {
reverse_proxy app:3443 {
transport http {
tls_insecure_skip_verify
}
}
}
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.20
RUN apk add --no-cache curl bash
WORKDIR /app
COPY update-dynv6.sh /app/update-dynv6.sh
RUN chmod +x /app/update-dynv6.sh
# Run once on startup, then every 5 minutes in a loop
CMD /app/update-dynv6.sh && while true; do sleep 300; /app/update-dynv6.sh; done
+249
View File
@@ -0,0 +1,249 @@
# Аудит безопасности и антиспама — WhatIDo
> Приложение предназначено для публичного развёртывания. Ниже — результаты аудита
> по уровню важности: 🔴 критично (исправить обязательно), 🟠 высокий приоритет,
> 🟡 средний приоритет, 🔵 замечания по антиспаму.
---
## 🔴 КРИТИЧНО (исправить обязательно перед публикацией)
### 1. Админ-токен в открытом виде + уязвимость по времени
**Файл:** `server.js:52-56`
```js
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
if (token !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Unauthorized' });
next();
}
```
- Токен сравнивается через `===` — **уязвим к атакам по времени** (timing attack).
- Нет хеширования (bcrypt/argon2) — при утечке `.env` или логов токен сразу компрометирован.
- Реальный пароль лежит в `.env` на диске.
**Рекомендация:** использовать `crypto.timingSafeEqual` и хранить bcrypt-хеш.
---
### 2. Файлы доступны анонимно по токену
**Файлы:** `server.js:877-885`, `server.js:49`
- `GET /api/files/:token` и статика `/uploads/*` отдают любой файл любому, кто знает токен.
- Токены криптостойкие (32 hex), но **фото детей и учебные проекты не должны быть публично доступны по угадываемому ключу**.
- Share-ссылки (`/api/share/:token`) также выдают все файлы записи.
**Рекомендация:** отдавать файлы только в контексте действующей share-ссылки (проверка принадлежности entry к ссылке) либо подписанные URL с TTL.
---
### 3. Публичные share-ссылки раскрывают ПИД (персональные данные)
**Файл:** `server.js:480-531`
- `/api/share/:token` возвращает: имена учеников, фото, описания, файлы проектов.
- Нет срока действия ссылки (`expires_at`).
- Юридический риск (152-ФЗ, GDPR) — данные детей в открытую.
**Рекомендация:** добавить `expires_at` в `share_links`, опцию анонимизации имён, требование пароля к ссылке.
---
## 🟠 ВЫСОКИЙ ПРИОРИТЕТ
### 4. Нет IP-based rate limit на публичный POST
**Файл:** `server.js:969`
- Только интервал по имени студента (`spam_interval_min`, дефолт 30 мин).
- Бот может менять имена — ограничение обходится.
- Нет лимита по IP — легко завалить сервер или перебрать `/api/files/:token`.
**Рекомендация:** добавить `express-rate-limit` по IP на `POST /api/entries`, `GET /api/share/:token`, `GET /api/files/:token`.
---
### 5. Нет honeypot / CAPTCHA
**Файл:** `public/index.html:305`
- Форма отправки полностью открыта для ботов.
- Скрытое поле-ловушка (honeypot) остановит 90% простых ботов.
**Рекомендация:** добавить `<input name="website" style="display:none" tabindex="-1" autocomplete="off">` и проверку на сервере.
---
### 6. Restore бэкапа загружает 300 МБ в память
**Файл:** `server.js:191-194`
```js
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
});
```
- `memoryStorage()` — под нагрузкой DoS (OOM killer).
- Архив не проверяется на содержимое до распаковки.
**Рекомендация:** использовать `diskStorage` во временную директорию, лимит 50 МБ.
---
### 7. CSP отключён
**Файл:** `server.js:47`
```js
app.use(helmet({ contentSecurityPolicy: false }));
```
- Весь фронтенд на inline-скриптах и атрибутных обработчиках — строгий CSP их заблокирует.
- Без CSP — риск XSS через инъекции в ошибки/настройки.
**Рекомендация:** рефакторинг фронтенда на внешние JS-файлы → включить CSP.
---
### 8. Сравнение токена без timing-safe
**Файл:** `server.js:54`
- `token !== ADMIN_PASSWORD` — уязвим к timing attack.
**Рекомендация:** `crypto.timingSafeEqual(Buffer.from(token), Buffer.from(ADMIN_PASSWORD))`.
---
### 9. Поле `files` принимает любые расширения
**Файл:** `server.js:83-93`
- `fileFilter` проверяет только `photo` (image MIME).
- Поле `files` принимает **что угодно** — `.html`, `.js`, `.svg` (SVG может содержать JS).
- Имена генерируются случайно, но при угадывании токена — вредоносный файл отдаётся как есть.
**Рекомендация:** добавить тот же блок-лист расширений для `files`, отдавать как `download` (не inline).
---
### 10. Нет лимита на размер JSON-body
**Файл:** `server.js:48`
```js
app.use(express.json());
```
- Нет `limit` — можно слать огромные JSON, забивать память.
**Рекомендация:** `express.json({ limit: '1mb' })`.
---
## 🟡 СРЕДНИЙ ПРИОРИТЕТ
| # | Проблема | Файл/Место |
|---|----------|------------|
| 11 | Share-ссылки никогда не истекают | `db/init.sql:32-41` — нет `expires_at` |
| 12 | Нет аудит-лога админ-действий | — |
| 13 | Стектрейсы утекают в non-production | `server.js:1155-1157` |
| 14 | Нет HSTS / secure cookies / принудительного HTTPS | `server.js:1169-1180` |
| 15 | Имена учеников в URL параметрах share | `public/share.html:105` |
| 16 | Отсутствует валидация `spam_interval_min` ≥ 1 | `server.js:1004` — можно поставить 0 и отключить антиспам |
---
## 🔵 АНТИСПАМ — ПРОБЕЛЫ
| Мера | Статус | Что нужно |
|------|--------|-----------|
| IP-based rate limit | ❌ | `rateLimit` по IP на `/api/entries` |
| Honeypot поле | ❌ | Скрытый input в форме + проверка сервером |
| CAPTCHA / Turnstile | ❌ | Опционально: Cloudflare Turnstile |
| Мин. интервал спама | ⚠️ Можно 0 | Валидация: минимум 1 минута |
| Квота суммарного объёма на IP/день | ❌ | Добавить (напр. 100 МБ/день) |
| Лимит файлов на запрос | ✅ 10 файлов | Оставить |
| Суммарный размер на запрос | ✅ 30 МБ | Оставить |
---
## ✅ УЖЕ ИСПРАВЛЕНО (подтверждено в текущем коде)
- ❌ Убран фолбэк-пароль `'admin'` — старт невозможен без `ADMIN_PASSWORD`
- ❌ CORS полностью удалён
- ❌ Порт БД 5432 не опубликован, креды из `.env`
- ❌ `escapeHtml` исправлен (`&`)
- ❌ `express-rate-limit` на API роутах
- ❌ Валидация restore-данных + безопасный `safeUnlink` (path traversal защита)
- ❌ `helmet` + security-заголовки (кроме CSP)
- ❌ Блок-лист расширений загрузки + лимит 30 МБ/запись
- ❌ Параметризованные запросы (нет SQL-инъекций)
- ❌ Токены файлов криптостойкие (16 байт hex)
- ❌ Файлы не перезаписываются (случайные имена)
- ❌ Multer-лимиты на размеры есть
---
## 📋 ПЛАН ДЕЙСТВИЙ (must-do перед публикацией)
### Фаза 1 — Критично (до публичного запуска)
1. **Хеш админ-токена** (bcrypt) + `crypto.timingSafeEqual` для сравнения
2. **Защита файлов** — отдача только в контексте валидной share-ссылки или подписанные URL
3. **IP rate limit** на `POST /api/entries` (10 req / 15 мин на IP)
4. **Honeypot** в публичной форме
5. **Backup restore на диск** (diskStorage, лимит 50 МБ)
6. **Мин. spam_interval_min = 1** (валидация в settings)
### Фаза 2 — Укрепление (высокий приоритет)
7. **CSP** — рефакторинг inline-скриптов → внешние файлы
8. **HSTS** через reverse-proxy (Caddy/nginx)
9. **Блок-лист расширений для `files`** + отдача как download
10. **JSON body limit** (`1mb`)
11. **`expires_at` для share_links** + опция анонимизации
12. **Аудит-лог** админ-действий (логин, CRUD, backup/restore)
### Фаза 3 — Приватность и соответствие
13. **Анонимизация share-ссылок** (опция скрыть имена)
14. **Политика хранения** — автоудаление старых записей
15. **Privacy notice** на публичной форме
---
## БЫСТРЫЕ ПОБЕДЫ (можно внедрить сегодня)
```javascript
// 1. Timing-safe админ-проверка (server.js:52-56)
const crypto = require('crypto');
function requireAdmin(req, res, next) {
const token = req.headers['x-admin-token'];
const expected = Buffer.from(ADMIN_PASSWORD);
const provided = Buffer.from(token || '');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// 2. IP rate limit на публичную запись (server.js:969)
const entryIpLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: req => req.ip,
message: { error: 'Too many submissions from this IP' }
});
app.post('/api/entries', entryIpLimiter, entryLimiter, ...);
// 3. Honeypot в форме (index.html) — скрытое поле
// <input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none">
// В хендлере: if (req.body.website) return res.status(400).json({ error: 'Spam detected' });
// 4. JSON body limit (server.js:48)
app.use(express.json({ limit: '1mb' }));
// 5. Блок-лист для project files (server.js:83-93)
const BLOCKED_EXT = /\.(?:html?|js|mjs|cjs|svg|xml|json|map|wasm|php\d?|phtml|asp|aspx|jsp|sh|bat|cmd|cgi|exe|dll|com|msi|scr|hta|vbs|py|r|rb|htaccess)$/i;
if (file.fieldname === 'files' && ext && BLOCKED_EXT.test(ext)) return cb(new Error('Not allowed extension'));
```
---
## Файлы для доработки (приоритет)
| Файл | Что править |
|------|-------------|
| `server.js:52-56` | timing-safe compare, bcrypt-хеш |
| `server.js:969` | IP rate limiter + honeypot проверка |
| `server.js:191-194` | diskStorage для backup restore |
| `server.js:83-93` | блок-лист для `files` |
| `server.js:48` | `express.json({ limit: '1mb' })` |
| `server.js:1004` | валидация `spam_interval_min >= 1` |
| `public/index.html` | honeypot input |
| `db/init.sql` | добавить `expires_at` в `share_links` |
| `docker-compose.yml` | раскомментировать Caddy для TLS |
---
*Аудит выполнен: 2026-09-07*
*Статус: готово к внедрению Фазы 1*
+5
View File
@@ -37,9 +37,14 @@ CREATE TABLE IF NOT EXISTS share_links (
student_name VARCHAR(150),
date_from DATE,
date_to DATE,
anonymize_names BOOLEAN DEFAULT false,
expires_at TIMESTAMPTZ,
access_password_hash VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
INSERT INTO groups (name) VALUES
('Beginner'),
('Intermediate'),
+17
View File
@@ -0,0 +1,17 @@
-- Migration: Add anonymize_names, expires_at, access_password_hash to share_links
-- Run this on existing database
-- 1. Add anonymize_names column (default false = show names)
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS anonymize_names BOOLEAN DEFAULT false;
-- 2. Add expires_at column (default 7 days from creation, nullable for backward compat)
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;
-- 3. Add access_password_hash column for bcrypt password protection
ALTER TABLE share_links ADD COLUMN IF NOT EXISTS access_password_hash VARCHAR(255);
-- 4. Update existing links to have expires_at = created_at + 7 days (optional, for existing links)
-- UPDATE share_links SET expires_at = created_at + interval '7 days' WHERE expires_at IS NULL;
-- 5. Add index for cleanup of expired links
CREATE INDEX IF NOT EXISTS idx_share_links_expires_at ON share_links(expires_at);
+2 -1
View File
@@ -11,6 +11,7 @@
"helmet": "^8.3.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.0",
"tar": "^7.4.3"
"tar": "^7.4.3",
"bcrypt": "^5.1.1"
}
}
+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(),
+98 -16
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,27 +103,91 @@ 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();
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;
@@ -133,6 +214,7 @@ function filesHTML(files){
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">
@@ -141,18 +223,18 @@ function filesHTML(files){
: `<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>
${!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)}
${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('');
} catch {
document.getElementById('title').textContent = 'Ссылка не найдена';
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
}
}
(async()=>{
await loadShare();
})();
</script>
</body>
+88 -9
View File
@@ -3,6 +3,7 @@ const { Pool, types } = require('pg');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const bcrypt = require('bcrypt');
const https = require('https');
const path = require('path');
@@ -448,25 +449,71 @@ app.get('/api/links', requireAdmin, async (_, res) => {
});
app.post('/api/links', requireAdmin, async (req, res) => {
const { name, group_id, student_name, date_from, date_to } = req.body;
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
const token = crypto.randomBytes(20).toString('hex');
let passwordHash = null;
if (access_password && access_password.trim()) {
passwordHash = await bcrypt.hash(access_password.trim(), 10);
}
let expiresAt = null;
if (expires_at) {
const parsed = new Date(expires_at);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: 'Неверный формат даты истечения' });
}
expiresAt = parsed.toISOString();
} else {
// Default 7 days from now
const defaultExp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
expiresAt = defaultExp.toISOString();
}
const { rows } = await pool.query(
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null]
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash]
);
res.status(201).json(normDates(rows[0]));
});
app.put('/api/links/:id', requireAdmin, async (req, res) => {
const { name, group_id, student_name, date_from, date_to } = req.body;
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
let passwordHash = undefined;
if (access_password !== undefined) {
if (access_password && access_password.trim()) {
passwordHash = await bcrypt.hash(access_password.trim(), 10);
} else {
passwordHash = null; // Clear password if empty string sent
}
}
let expiresAt = undefined;
if (expires_at !== undefined) {
if (expires_at) {
const parsed = new Date(expires_at);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: 'Неверный формат даты истечения' });
}
expiresAt = parsed.toISOString();
} else {
expiresAt = null; // Clear expiry if null sent
}
}
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6'];
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false];
let paramIdx = 7;
if (passwordHash !== undefined) {
fields.push(`access_password_hash = $${paramIdx++}`);
values.push(passwordHash);
}
if (expiresAt !== undefined) {
fields.push(`expires_at = $${paramIdx++}`);
values.push(expiresAt);
}
values.push(req.params.id);
const { rows } = await pool.query(
`UPDATE share_links SET
name = $1, group_id = $2, student_name = $3, date_from = $4, date_to = $5
WHERE id = $6 RETURNING *`,
[name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.params.id]
`UPDATE share_links SET ${fields.join(', ')} WHERE id = $${paramIdx} RETURNING *`,
values
);
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
res.json(normDates(rows[0]));
@@ -486,6 +533,24 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
normDates(rows[0]);
const l = rows[0];
// Check expiry
if (l.expires_at && new Date(l.expires_at) < new Date()) {
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
}
// Check password
if (l.access_password_hash) {
const providedPassword = req.headers['x-share-password'] || req.query.password;
if (!providedPassword) {
return res.status(401).json({ error: 'Требуется пароль', passwordRequired: true });
}
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
if (!valid) {
return res.status(401).json({ error: 'Неверный пароль' });
}
}
const conditions = [];
const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
@@ -508,6 +573,19 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); });
}
entries.forEach(r => { r.files = files[r.id] || []; });
// Anonymize names if enabled
if (l.anonymize_names) {
const nameMap = new Map();
let counter = 1;
entries.forEach(e => {
if (!nameMap.has(e.student_name)) {
nameMap.set(e.student_name, `Ученик ${counter++}`);
}
e.student_name = nameMap.get(e.student_name);
});
}
let photos = [];
if (l.group_id) {
const pRes = await pool.query(
@@ -525,6 +603,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
date_from: l.date_from,
date_to: l.date_to,
created_at: l.created_at,
anonymize_names: l.anonymize_names,
entries,
photos,
});
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# dynv6 DDNS update script
# Environment variables expected:
# DYNDNS_TOKEN - dynv6 token
# DYNDNS_HOSTNAME - domain name (e.g., kiberone.dns.army)
set -e
TOKEN="${DYNDNS_TOKEN}"
HOSTNAME="${DYNDNS_HOSTNAME}"
if [ -z "$TOKEN" ] || [ -z "$HOSTNAME" ]; then
echo "ERROR: DYNDNS_TOKEN and DYNDNS_HOSTNAME must be set"
exit 1
fi
echo "[$(date)] Updating dynv6 for $HOSTNAME..."
# Update IPv4
IPV4=$(curl -s -4 https://api.ipify.org 2>/dev/null || echo "")
if [ -n "$IPV4" ]; then
echo "[$(date)] Detected IPv4: $IPV4"
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv4=${IPV4}")
echo "[$(date)] IPv4 update response: $RESPONSE"
else
echo "[$(date)] No IPv4 detected"
fi
# Update IPv6
IPV6=$(curl -s -6 https://api6.ipify.org 2>/dev/null || echo "")
if [ -n "$IPV6" ]; then
echo "[$(date)] Detected IPv6: $IPV6"
RESPONSE=$(curl -s "https://dynv6.com/api/update?hostname=${HOSTNAME}&token=${TOKEN}&ipv6=${IPV6}")
echo "[$(date)] IPv6 update response: $RESPONSE"
else
echo "[$(date)] No IPv6 detected"
fi
echo "[$(date)] Update complete"