diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..498bbe9
--- /dev/null
+++ b/Caddyfile
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/Dockerfile.ddns b/Dockerfile.ddns
new file mode 100644
index 0000000..6c2d1a9
--- /dev/null
+++ b/Dockerfile.ddns
@@ -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
\ No newline at end of file
diff --git a/SECURITY_AUDIT_RU.md b/SECURITY_AUDIT_RU.md
new file mode 100644
index 0000000..325f1ad
--- /dev/null
+++ b/SECURITY_AUDIT_RU.md
@@ -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% простых ботов.
+
+**Рекомендация:** добавить `` и проверку на сервере.
+
+---
+
+### 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) — скрытое поле
+//
+// В хендлере: 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*
\ No newline at end of file
diff --git a/db/init.sql b/db/init.sql
index 5364117..292e494 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -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'),
diff --git a/db/migration_share_links.sql b/db/migration_share_links.sql
new file mode 100644
index 0000000..7cf8028
--- /dev/null
+++ b/db/migration_share_links.sql
@@ -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);
\ No newline at end of file
diff --git a/package.json b/package.json
index a4c28e4..6ae7cde 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/public/journal.html b/public/journal.html
index c85f74d..5d549e8 100644
--- a/public/journal.html
+++ b/public/journal.html
@@ -75,6 +75,20 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -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();
diff --git a/public/links.html b/public/links.html
index 866d46d..5c2cef6 100644
--- a/public/links.html
+++ b/public/links.html
@@ -41,6 +41,20 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -71,6 +85,9 @@ async function loadLinks() {
${l.group_name ? `Группа: ${esc(l.group_name)}` : ''}
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: ${esc(l.student_name)}` : ''}
${l.date_from || l.date_to ? ` · период: ${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'} — ${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}` : ' · весь период'}
+ ${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
+ ${l.anonymize_names ? ` · анонимизировано` : ''}
+ ${l.access_password_hash ? ` · 🔒 пароль` : ''}
· создана ${new Date(l.created_at).toLocaleString('ru')}
@@ -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(),
diff --git a/public/share.html b/public/share.html
index 4b055b9..ac59148 100644
--- a/public/share.html
+++ b/public/share.html
@@ -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}
@@ -70,8 +78,17 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
+
+
+
🔒 Ссылка защищена паролем
+
Введите пароль для доступа к записям
+
+
+
+
+
diff --git a/server.js b/server.js
index 4e666a4..abfa1cd 100644
--- a/server.js
+++ b/server.js
@@ -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,
});
diff --git a/update-dynv6.sh b/update-dynv6.sh
new file mode 100644
index 0000000..5ca4f40
--- /dev/null
+++ b/update-dynv6.sh
@@ -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"
\ No newline at end of file