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