add share-link password/expiry, journal touch-ups, ddns helper and audit docs
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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*
|
||||||
@@ -37,9 +37,14 @@ CREATE TABLE IF NOT EXISTS share_links (
|
|||||||
student_name VARCHAR(150),
|
student_name VARCHAR(150),
|
||||||
date_from DATE,
|
date_from DATE,
|
||||||
date_to DATE,
|
date_to DATE,
|
||||||
|
anonymize_names BOOLEAN DEFAULT false,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
access_password_hash VARCHAR(255),
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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
|
INSERT INTO groups (name) VALUES
|
||||||
('Beginner'),
|
('Beginner'),
|
||||||
('Intermediate'),
|
('Intermediate'),
|
||||||
|
|||||||
@@ -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
@@ -11,6 +11,7 @@
|
|||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"tar": "^7.4.3"
|
"tar": "^7.4.3",
|
||||||
|
"bcrypt": "^5.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -75,6 +75,20 @@
|
|||||||
<label>Ученик (необязательно)</label>
|
<label>Ученик (необязательно)</label>
|
||||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||||
</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">
|
<div class="actions">
|
||||||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
||||||
<button class="save" onclick="createLink()">Создать</button>
|
<button class="save" onclick="createLink()">Создать</button>
|
||||||
@@ -402,6 +416,9 @@ async function openLinkCreate() {
|
|||||||
const dfl = df.split('-').reverse().join('.');
|
const dfl = df.split('-').reverse().join('.');
|
||||||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||||||
document.getElementById('linkStudent').value = currentStudent;
|
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('linkCreateView').style.display = '';
|
||||||
document.getElementById('linkResultView').style.display = 'none';
|
document.getElementById('linkResultView').style.display = 'none';
|
||||||
document.getElementById('linkModal').classList.add('open');
|
document.getElementById('linkModal').classList.add('open');
|
||||||
@@ -417,11 +434,16 @@ async function createLink() {
|
|||||||
const student_name = document.getElementById('linkStudent').value;
|
const student_name = document.getElementById('linkStudent').value;
|
||||||
const date_from = document.getElementById('dateFrom').value;
|
const date_from = document.getElementById('dateFrom').value;
|
||||||
const date_to = document.getElementById('dateTo').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 (!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`, {
|
const res = await fetch(`${API}/api/links`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: hdrJson(),
|
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; }
|
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||||||
const link = await res.json();
|
const link = await res.json();
|
||||||
|
|||||||
+29
-1
@@ -41,6 +41,20 @@
|
|||||||
<input type="date" id="linkDateTo">
|
<input type="date" id="linkDateTo">
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="actions">
|
||||||
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
||||||
<button class="save" onclick="saveLink()">Сохранить</button>
|
<button class="save" onclick="saveLink()">Сохранить</button>
|
||||||
@@ -71,6 +85,9 @@ async function loadLinks() {
|
|||||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_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.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')}
|
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,6 +109,9 @@ async function openLinkModal() {
|
|||||||
document.getElementById('linkName').value = '';
|
document.getElementById('linkName').value = '';
|
||||||
document.getElementById('linkDateFrom').value = '';
|
document.getElementById('linkDateFrom').value = '';
|
||||||
document.getElementById('linkDateTo').value = '';
|
document.getElementById('linkDateTo').value = '';
|
||||||
|
document.getElementById('linkExpiresDays').value = '7';
|
||||||
|
document.getElementById('linkAnonymizeNames').checked = false;
|
||||||
|
document.getElementById('linkPassword').value = '';
|
||||||
if (!groups.length) {
|
if (!groups.length) {
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
groups = await res.json();
|
groups = await res.json();
|
||||||
@@ -116,6 +136,9 @@ async function openLinkEdit(id) {
|
|||||||
document.getElementById('linkName').value = l.name;
|
document.getElementById('linkName').value = l.name;
|
||||||
document.getElementById('linkDateFrom').value = l.date_from || '';
|
document.getElementById('linkDateFrom').value = l.date_from || '';
|
||||||
document.getElementById('linkDateTo').value = l.date_to || '';
|
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) {
|
if (!groups.length) {
|
||||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||||
groups = await res.json();
|
groups = await res.json();
|
||||||
@@ -145,8 +168,13 @@ async function saveLink() {
|
|||||||
const student_name = document.getElementById('linkStudent').value;
|
const student_name = document.getElementById('linkStudent').value;
|
||||||
const date_from = document.getElementById('linkDateFrom').value;
|
const date_from = document.getElementById('linkDateFrom').value;
|
||||||
const date_to = document.getElementById('linkDateTo').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; }
|
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`, {
|
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||||
method: editingId ? 'PUT' : 'POST',
|
method: editingId ? 'PUT' : 'POST',
|
||||||
headers: hdrJson(),
|
headers: hdrJson(),
|
||||||
|
|||||||
+98
-16
@@ -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: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: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}}
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -70,8 +78,17 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
</main>
|
</main>
|
||||||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
|
<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>
|
<script>
|
||||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
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 fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
||||||
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
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||'')}
|
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:'📁'};
|
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
||||||
return icons[t]||icons.other;
|
return icons[t]||icons.other;
|
||||||
}
|
}
|
||||||
function filesHTML(files){
|
function filesHTML(files, password){
|
||||||
if(!files||!files.length)return '';
|
if(!files||!files.length)return '';
|
||||||
|
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||||
return `<div class="files-block">
|
return `<div class="files-block">
|
||||||
<div class="files-label">Работы резидента</div>
|
<div class="files-label">Работы резидента</div>
|
||||||
<div class="files">${files.map(f=>{
|
<div class="files">${files.map(f=>{
|
||||||
const t = fileType(f.name);
|
const t = fileType(f.name);
|
||||||
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
||||||
?`href="#" onclick="showImg('/api/files/${f.token}');return false;"`
|
?`href="#" onclick="showImg('/api/files/${f.token}${pw}');return false;"`
|
||||||
:`href="/api/files/${f.token}" download`}>
|
:`href="/api/files/${f.token}${pw}" download`}>
|
||||||
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
}).join('')}</div>
|
}).join('')}</div>
|
||||||
</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() || '';
|
const token = location.pathname.split('/').filter(Boolean).pop() || '';
|
||||||
try {
|
currentToken = token;
|
||||||
const res = await fetch('/api/share/' + token);
|
currentPassword = null;
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const data = await res.json();
|
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 || 'Записи занятий';
|
const heading = data.group_name || data.name || 'Записи занятий';
|
||||||
document.title = heading;
|
document.title = heading;
|
||||||
document.getElementById('title').textContent = heading;
|
document.getElementById('title').textContent = heading;
|
||||||
@@ -133,6 +214,7 @@ function filesHTML(files){
|
|||||||
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isAnonymized = data.anonymize_names;
|
||||||
grid.innerHTML = data.entries.map(e => `
|
grid.innerHTML = data.entries.map(e => `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="photo">
|
<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 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>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="name">${esc(e.student_name)}</div>
|
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
||||||
<div>${e.group_name ? `<span class="group">${esc(e.group_name)}</span>` : ''}</div>
|
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
||||||
<div class="desc">${esc(e.description)}</div>
|
<div class="desc">${esc(e.description)}</div>
|
||||||
${filesHTML(e.files)}
|
${filesHTML(e.files, password)}
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} catch {
|
}
|
||||||
document.getElementById('title').textContent = 'Ссылка не найдена';
|
|
||||||
document.getElementById('grid').innerHTML = '<div class="empty"><div class="big">🤔</div>Эта ссылка не существует или была удалена</div>';
|
(async()=>{
|
||||||
}
|
await loadShare();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { Pool, types } = require('pg');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -448,25 +449,71 @@ app.get('/api/links', requireAdmin, async (_, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/links', requireAdmin, async (req, 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: 'Название обязательно' });
|
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||||
const token = crypto.randomBytes(20).toString('hex');
|
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(
|
const { rows } = await pool.query(
|
||||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to)
|
`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) RETURNING *`,
|
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]
|
[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]));
|
res.status(201).json(normDates(rows[0]));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/links/:id', requireAdmin, async (req, res) => {
|
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: 'Название обязательно' });
|
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(
|
const { rows } = await pool.query(
|
||||||
`UPDATE share_links SET
|
`UPDATE share_links SET ${fields.join(', ')} WHERE id = $${paramIdx} RETURNING *`,
|
||||||
name = $1, group_id = $2, student_name = $3, date_from = $4, date_to = $5
|
values
|
||||||
WHERE id = $6 RETURNING *`,
|
|
||||||
[name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.params.id]
|
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||||
res.json(normDates(rows[0]));
|
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: 'Ссылка не найдена' });
|
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
||||||
normDates(rows[0]);
|
normDates(rows[0]);
|
||||||
const l = 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 conditions = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
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 }); });
|
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] || []; });
|
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 = [];
|
let photos = [];
|
||||||
if (l.group_id) {
|
if (l.group_id) {
|
||||||
const pRes = await pool.query(
|
const pRes = await pool.query(
|
||||||
@@ -525,6 +603,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
|||||||
date_from: l.date_from,
|
date_from: l.date_from,
|
||||||
date_to: l.date_to,
|
date_to: l.date_to,
|
||||||
created_at: l.created_at,
|
created_at: l.created_at,
|
||||||
|
anonymize_names: l.anonymize_names,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user