feat: env-driven upload limits + request timeout, inline video playback, range requests

- Add UPLOAD_FILE_LIMIT_MB/UPLOAD_TOTAL_LIMIT_MB env (defaults 50/200), compute request timeout from total limit or UPLOAD_REQUEST_TIMEOUT_MS
- Expose upload limits via /api/public-settings and sync in frontend (remove hardcoded 50MB assumption)
- Add byte-range support in storage (getRange/streamRangeTo) and serve Content-Range/Accept-Ranges for S3/local
- Implement inline playable video delivery for browser formats (mp4/m4v/webm/ogv) with ?play=1, range requests, proper 206/416
- Add video modal in journal UI with player and download fallback
- Update docs (AGENTS.md/PRD.md/README.md), styles for video modal, add instructions/TODO.md and screenshots
- Extend MIME types for media
This commit is contained in:
dev
2026-10-03 12:00:48 +03:00
parent 104bdc4f49
commit 70b0c7ae9b
71 changed files with 307 additions and 6 deletions
+3 -1
View File
@@ -33,6 +33,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 3. File Uploads ### 3. File Uploads
- **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore) - **Multer configs**: `upload` (images only), `adminUpload` (wider allowed ext), `uploadBackup` (restore)
- **Видео в интерфейсе**: `mp4`/`m4v`/`webm`/`ogv` играются в модалке `#videoModal` в `journal.html` (`data-video` в `filesHTML`); остальные видео (`mov`, `mkv`, `avi`, …) остаются обычными ссылками на скачивание. Отдача — `GET /api/files/:token?play=1` **inline** с `Accept-Ranges`; без `?play=1` файл по-прежнему уходит как `attachment`, чтобы старые ссылки не поменяли поведение
- **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js` - **Limits**: `UPLOAD_FILE_LIMIT_MB` per file (default 50), `UPLOAD_TOTAL_LIMIT_MB` per entry (default 200) — both env-driven; `UPLOAD_REQUEST_TIMEOUT_MS` overrides the auto-computed request timeout. The frontend reads the two MB values from `GET /api/public-settings` (`upload_file_limit_mb`, `upload_total_limit_mb`) — do not hardcode them again in `public/js/index.js`
- **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`) - **Staging**: Multer always writes to `uploads/` (`timestamp-random.ext`); a global `res.on('finish')` hook persists each uploaded file through `storage.persist` on successful responses (only when `STORAGE_DRIVER=s3`)
- **HEIC**: Auto-converted to JPEG via `heic-convert` - **HEIC**: Auto-converted to JPEG via `heic-convert`
@@ -41,7 +42,8 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
### 3a. Storage (`storage.js`) ### 3a. Storage (`storage.js`)
- **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`) - **Drivers**: `local` (default, files in `uploads/`) and `s3` (S3-compatible: SeaweedFS by default, MinIO via `docker-compose.minio.yml`)
- **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs - **Keys are stable**: DB stores `/uploads/<name>`; S3 object keys are the same `<name>` (plus `.originals/<name>`). Never change key format — it would break existing DB rows and URLs
- **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache` - **API**: `put`, `putFile`, `head`, `exists`, `sizeOf`, `getStream`, `getBuffer`, `getRange`, `del`, `copyObject`, `listAll`, `localize`, `persist`, `streamTo`, `streamRangeTo`, `downloadAll`, `uploadTree`, `ensureBucket`, `usage`, `pruneCache`
- **Диапазоны**: `getRange(key, start, end)` и `streamRangeTo(res, key, start, end, opts)` отдают `206` с `Content-Range`/`Accept-Ranges` — только для медиа, разбор `Range` на стороне сервера (`parseByteRange`)
- **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent) - **Rules**: never call `fs.*` on `uploads/` directly in request/worker code — use `storage.*`. `safeUnlink` is the only deletion helper (local + remote, idempotent)
- **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3 - **Read path**: `STORAGE_LOCAL_FALLBACK=1` prefers a local file when it still exists (covers in-flight uploads and partial migration); otherwise the app streams the object from S3
- **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`) - **Cache**: `.thumbs` (WebP miniatures) and `.cache` (originals localized for sharp/zip) live inside `uploads/` and are pruned hourly (`STORAGE_CACHE_MAX_AGE_HOURS`)
+2 -1
View File
@@ -47,6 +47,7 @@
| ENT-6 | Pagination (limit/offset) + total count | Must | | ENT-6 | Pagination (limit/offset) + total count | Must |
| ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must | | ENT-7 | Anti-spam: min interval between entries per student (configurable, default 30 min) | Must |
| ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must | | ENT-8 | Files attached to entry: upload (max 10 files, per-file and total size limits from `UPLOAD_FILE_LIMIT_MB`/`UPLOAD_TOTAL_LIMIT_MB`), download by token | Must |
| ENT-9 | Video attachments: `mp4`/`m4v`/`webm`/`ogv` playable inline in the journal with seeking (Range), other video formats download-only | Should |
### 2.4 Files Management (Centralized) ### 2.4 Files Management (Centralized)
| ID | Requirement | Priority | | ID | Requirement | Priority |
@@ -175,7 +176,7 @@ branches
| GET | `/api/files` | Admin | All files (filters) | | GET | `/api/files` | Admin | All files (filters) |
| GET | `/api/files/detached` | Admin | Detached files | | GET | `/api/files/detached` | Admin | Detached files |
| POST | `/api/files/:id/detach` | Admin | Detach file | | POST | `/api/files/:id/detach` | Admin | Detach file |
| GET | `/api/files/:token` | Public | Download file by token | | GET | `/api/files/:token` | Public | Download file by token; `?play=1` serves `mp4`/`m4v`/`webm`/`ogv` inline with `Range` support |
| GET | `/api/groups` | Public | List groups | | GET | `/api/groups` | Public | List groups |
| POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups | | POST/PUT/DELETE | `/api/groups` | Admin | CRUD groups |
| GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle | | GET/POST | `/api/groups/:id/photos` | Admin | Group photo chronicle |
+1
View File
@@ -528,6 +528,7 @@ node api.smoketest.js # сквозная проверка API (нужен
- **CORS отключён** — кросс-доменные запросы к API запрещены. - **CORS отключён** — кросс-доменные запросы к API запрещены.
- **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин. - **Rate limiting** по IP на публичные роуты: `POST /api/entries` — 10 запросов / 15 мин, загрузка файлов и share-ссылки — 300 / 15 мин.
- **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline. - **Загрузки** ограничены: суммарно на запись и на файл — лимиты из `UPLOAD_TOTAL_LIMIT_MB` / `UPLOAD_FILE_LIMIT_MB` (по умолчанию 200 МБ и 50 МБ); заблокированы опасные расширения (`.html`, `.js`, `.svg`, `.xml`, `.exe` и др.); SVG не отдаётся inline.
- **Видеофайлы** (`.mp4`, `.m4v`, `.webm`, `.ogv`) играются прямо в журнале: `GET /api/files/:token?play=1` отдаёт файл **inline** с `Accept-Ranges: bytes` и поддержкой `Range` (`206`), поэтому перемотка работает без скачивания целиком. Остальные форматы (`.mov`, `.mkv`, `.avi` и пр.) браузер не играет — они остаются ссылками на скачивание.
- **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`. - **Restore** проходит полную валидацию данных бэкапа; удаление файлов ограничено каталогом `uploads/`.
- **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`. - **Заголовки**: `helmet` — `X-Frame-Options`, `nosniff`, HSTS, `Referrer-Policy`.
- **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети). - **Порт БД** 5432 наружу не публикуется (доступ только внутри docker-сети).
+92
View File
@@ -0,0 +1,92 @@
# TODO — Инструкция по системе WhatIDo (со скриншотами)
Статус: `☐` todo · `☑` готово · `◐` в работе
Цель: папка `instructions/` с полной русскоязычной инструкцией для администратора/преподавателя,
**каждый раздел проиллюстрирован реальными скриншотами живого стенда** (стенд поднят,
в БД 358 записей, 151 ученик, 22 группы, 72 модуля, 35 ссылок — данные реальные, не моки).
## Часть 0. Подготовка
- [x] 0.1 Поднять стенд: `docker compose ps` — app на `http://localhost:3003`
- [x] 0.2 Проверить доступность `GET /` → 200, `GET /api/public-settings` → `system_name=KIBERone`
- [x] 0.3 Получить учётку админа (`admin` / `ADMIN_PASSWORD`), `POST /api/auth/login` → токен
- [x] 0.4 Инвентаризация всех страниц `public/*.html` (меню, ID, русские подписи, роли)
- [x] 0.5 Снять инвентарь данных в БД, чтобы скриншоты были непустыми
- [x] 0.6 Создать папку `instructions/` и этот TODO
## Часть 1. Публичная часть — то, что видит ученик
- [x] 1.1 `index.html` — форма «Что мы узнали на занятии»: общий вид, шапка, футер
- [x] 1.2 Блок «Фото»: превью, кнопка «Камера», модалка камеры (снять/отмена)
- [x] 1.3 Блок «Файлы проекта»: выбор, вставка из буфера, список с размерами, очистка
- [x] 1.4 Блок полей: ФИО, Группа, Тема модуля, «Что сделал» (+ автодополнение)
- [x] 1.5 Кнопка «Отправить» + панель успеха `#sentPanel` с таймером антиспама
- [x] 1.6 Ошибки/повторная отправка, тосты
## Часть 2. Вход и оболочка админки
- [x] 2.1 `login.html` — форма входа (логин, пароль, honeypot, ошибки)
- [x] 2.2 `admin.js` — сайдбар: логотип, user-box, две группы меню, версия
- [x] 2.3 Выпадающий список уведомлений в сайдбаре + бейдж
## Часть 3. Разделы по порядку (основное → администрирование)
- [x] 3.1 **Дашборд** — плитки статистики, динамика за 14 дней, последние записи, активные группы, топ учеников, быстрые действия
- [x] 3.2 **Журнал** — фильтры, список/карточки, пагинация, карточка записи
- [x] 3.3 Журнал — модалка «Редактирование записи» (+ `✨` ИИ-исправление, `ИИ предлагает вариант`)
- [x] 3.4 Журнал — «Улучшение фото»: сравнение до/после, слайдеры, режимы ИИ, история версий
- [x] 3.5 Журнал — модалка «Создать ссылку»
- [x] 3.6 **Ученики** — список, фильтры, пагинация, кнопки действий
- [x] 3.7 Ученики — пакетное добавление, прикрепление к группе
- [x] 3.8 Ученики — «Данные профиля» (все поля отчёта)
- [x] 3.9 Ученики — «Экспорт отчёта» (ZIP)
- [x] 3.10 **Группы** — карточки, расписание, филиал/тутор
- [x] 3.11 Группы — галерея фото группы (загрузка, обложка, порядок, правка)
- [x] 3.12 Группы — «Архив файлов группы» (ZIP-выгрузка)
- [x] 3.13 **Фото** — все фото, фильтры по источнику, карточки
- [x] 3.14 **Файлы** — прикреплённые / откреплённые
- [x] 3.15 **Ссылки** — список share-ссылок, бейджи, создание
- [x] 3.16 **Корзина** — восстановление, «помеченные на удаление»
- [x] 3.17 **Темы модулей** — список, модалка модуля, пакетное добавление
- [x] 3.18 **Филиалы** — CRUD
- [x] 3.19 **Пользователи** — таблица, модалка, мультивыбор филиалов
- [x] 3.20 **Воркер ИИ** — статус, очередь, последние проверки, фото-задания, ошибки
- [x] 3.21 **Аудит** — таблица действий, модалка «Детали действия» с диффом
- [x] 3.22 **Блокировки** — таблица IP, модалка ручного бана
- [x] 3.23 **Уведомления** — полная история, фильтр, «прочитать все», «очистить всё»
- [x] 3.24 **Настройки** — 13 секций (система, стек, брендинг, антиспам, ссылки, футер, фото, фото-ИИ, уведомления, ИИ, бэкапы, корзина, блокировки)
## Часть 4. Публичные страницы по ссылкам
- [x] 4.1 `share.html` (`/s/<token>`) — карточки записей, фото группы, cookie-баннер, лайтбокс
- [x] 4.2 `share.html` — защита паролем `#passwordModal`
- [x] 4.3 `report.html` (`/r/<token>`) — публичный отчёт: hero, «Обо мне», хроника, работы, файлы, фото, контакты
- [x] 4.4 `error.html` — страница ошибки 404
## Часть 5. Сборка инструкции
- [x] 5.1 Скриншоты → `instructions/img/` с нумерацией
- [x] 5.2 `instructions/README.md` — оглавление, роли, вход, быстрый старт
- [x] 5.3 Постраничные файлы инструкции `01-…` … `05-…` с вставленными картинками
- [x] 5.4 `instructions/CHEATSHEET.md` — краткая шпаргалка + горячие клавиши/API
- [x] 5.5 Проверка: все ссылки на изображения существуют, нет битых `.md`
## Итог
| Артефакт | Описание |
|---|---|
| `instructions/README.md` | Оглавление + обзор системы + порядок работы |
| `instructions/01-public-form.md` | Публичная форма ученика (пошагово) |
| `instructions/02-login-shell.md` | Вход, сайдбар, уведомления |
| `instructions/03-dashboard.md` | Дашборд |
| `instructions/04-journal.md` | Журнал: фильтры, редактирование, фото-ИИ, ссылки |
| `instructions/05-students.md` | Ученики: профили, группы, экспорт |
| `instructions/06-groups-photos-files.md` | Группы, Фото, Файлы |
| `instructions/07-links-trash.md` | Ссылки, Корзина |
| `instructions/08-admin-sections.md` | Модули, Филиалы, Пользователи, Блокировки |
| `instructions/09-worker-audit-notifications.md` | Воркер ИИ, Аудит, Уведомления |
| `instructions/10-settings.md` | Настройки: все 13 секций |
| `instructions/11-share-report-pages.md` | Публичные `/s/` и `/r/` страницы |
| `instructions/CHEATSHEET.md` | Шпаргалка |
| `instructions/img/*.png` | Скриншоты |
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+9
View File
@@ -215,6 +215,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center} .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;display:none;align-items:center;justify-content:center}
.modal-overlay.open{display:flex} .modal-overlay.open{display:flex}
.modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)} .modal-overlay > img{max-width:90%;max-height:90dvh;border-radius:var(--radius)}
#videoModal{background:rgba(0,0,0,.85)}
.video-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:16px;width:min(1000px,94vw);max-height:94dvh;display:flex;flex-direction:column;gap:12px}
.video-head{display:flex;align-items:center;gap:12px;font-size:.85rem;color:var(--text)}
.video-head > span:first-child{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.video-dl{background:none;border:1px solid var(--border);border-radius:6px;padding:3px 10px;color:var(--muted);text-decoration:none;font-size:.75rem}
.video-dl:hover{color:var(--accent);border-color:var(--accent)}
.video-close{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px}
.video-close:hover{color:var(--text)}
#videoPlayer{width:100%;max-height:78dvh;background:#000;border-radius:var(--radius);display:block}
.edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px} .edit-modal{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:24px;max-width:420px;width:90%;display:flex;flex-direction:column;gap:16px}
.edit-modal h3{font-size:1.1rem;font-weight:600} .edit-modal h3{font-size:1.1rem;font-weight:600}
.edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px} .edit-modal label{font-size:.8rem;color:var(--muted);margin-bottom:4px}
+11
View File
@@ -66,6 +66,17 @@
<img id="imgModalSrc"> <img id="imgModalSrc">
</div> </div>
<div class="modal-overlay" id="videoModal">
<div class="video-modal">
<div class="video-head">
<span id="videoTitle"></span>
<a class="video-dl" id="videoDownload" download title="Скачать файл">Скачать</a>
<button type="button" class="video-close" id="videoClose" title="Закрыть">&times;</button>
</div>
<video id="videoPlayer" controls playsinline preload="metadata"></video>
</div>
</div>
<div class="modal-overlay" id="editModal"> <div class="modal-overlay" id="editModal">
<div class="edit-modal" style="max-width:720px"> <div class="edit-modal" style="max-width:720px">
<h3>Редактирование записи</h3> <h3>Редактирование записи</h3>
+60 -3
View File
@@ -34,6 +34,14 @@ function isImageFile(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
} }
function isPlayableVideoFile(name) {
return /\.(mp4|m4v|webm|ogv)$/i.test(name || '');
}
function isVideoFile(name) {
return /\.(mp4|m4v|webm|ogv|mov|mkv|avi|mpeg|mpg|3gp|ts)$/i.test(name || '');
}
function mainPhotoPath(e) { function mainPhotoPath(e) {
if (e.photo_path) return e.photo_path; if (e.photo_path) return e.photo_path;
if (e.photos && e.photos.length) return e.photos[0].photo_path; if (e.photos && e.photos.length) return e.photos[0].photo_path;
@@ -43,9 +51,16 @@ function mainPhotoPath(e) {
function filesHTML(files) { function filesHTML(files) {
if (!files || !files.length) return ''; if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => { return `<div class="entry-files">${files.map(f => {
const link = isImageFile(f.name) const url = `${API}/api/files/${f.token}`;
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>` let link;
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`; if (isImageFile(f.name)) {
link = `<a class="f-link" href="#" data-img="${url}" title="Открыть">${esc(f.name)}</a>`;
} else if (isPlayableVideoFile(f.name)) {
link = `<a class="f-link" href="${url}?play=1" data-video="${url}?play=1" data-video-name="${esc(f.name)}" data-video-token="${f.token}" title="Смотреть">${esc(f.name)}</a>`;
} else {
const note = isVideoFile(f.name) ? 'Скачать (браузер не умеет играть этот формат)' : 'Скачать';
link = `<a class="f-link" href="${url}" download title="${note}">${esc(f.name)}</a>`;
}
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`; return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
}).join('')}</div>`; }).join('')}</div>`;
} }
@@ -1589,6 +1604,48 @@ document.getElementById('aiCorrectModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeAiCorrect(); if (e.target === e.currentTarget) closeAiCorrect();
}); });
function openVideo(url, name, token) {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
const title = document.getElementById('videoTitle');
const dl = document.getElementById('videoDownload');
if (!modal || !player) return;
player.src = url;
player.currentTime = 0;
title.textContent = name || 'Видео';
dl.href = `${API}/api/files/${token}`;
dl.setAttribute('download', '');
modal.classList.add('open');
const play = player.play();
if (play && typeof play.catch === 'function') play.catch(() => {});
}
function closeVideo() {
const modal = document.getElementById('videoModal');
const player = document.getElementById('videoPlayer');
if (!modal || !player) return;
try { player.pause(); } catch {}
player.removeAttribute('src');
player.load();
modal.classList.remove('open');
}
document.getElementById('videoModal').addEventListener('click', e => {
if (e.target === e.currentTarget) closeVideo();
});
document.getElementById('videoClose').addEventListener('click', closeVideo);
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('videoModal').classList.contains('open')) closeVideo();
});
document.addEventListener('click', e => {
const v = e.target.closest('[data-video]');
if (!v) return;
e.preventDefault();
openVideo(v.dataset.video, v.dataset.videoName, v.dataset.videoToken);
});
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV); document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate); document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view))); document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
+66 -1
View File
@@ -8,7 +8,7 @@ const heicConvert = require('heic-convert');
const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker'); const { createEntryAutoChecker, createPhotoEnhanceWorker } = require('./worker');
const { createZipWriter, renderStudentReport } = require('./student-report'); const { createZipWriter, renderStudentReport } = require('./student-report');
const { createStorage } = require('./storage'); const { createStorage, mimeFor } = require('./storage');
const { createRedis } = require('./redis'); const { createRedis } = require('./redis');
const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('./diff'); const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('./diff');
@@ -16,6 +16,7 @@ const https = require('https');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { pipeline } = require('stream/promises');
types.setTypeParser(1082, v => v); types.setTypeParser(1082, v => v);
@@ -4513,6 +4514,65 @@ function isImageName(name) {
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || ''); return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i.test(name || '');
} }
const BROWSER_VIDEO_EXT = /\.(?:mp4|m4v|webm|ogv)$/i;
const OTHER_VIDEO_EXT = /\.(?:mov|mkv|avi|mpeg|mpg|3gp|ts)$/i;
function isVideoName(name) {
return BROWSER_VIDEO_EXT.test(name || '') || OTHER_VIDEO_EXT.test(name || '');
}
function isPlayableVideoName(name) {
return BROWSER_VIDEO_EXT.test(name || '');
}
function parseByteRange(header, size) {
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header || '').trim());
if (!m || size <= 0) return null;
const hasStart = m[1] !== '';
const hasEnd = m[2] !== '';
if (!hasStart && !hasEnd) return null;
let start;
let end;
if (hasStart) {
start = parseInt(m[1], 10);
end = hasEnd ? parseInt(m[2], 10) : size - 1;
} else {
const suffix = parseInt(m[2], 10);
if (!Number.isFinite(suffix) || suffix <= 0) return null;
start = Math.max(0, size - suffix);
end = size - 1;
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start >= size) return { unsatisfiable: true };
return { start, end: Math.min(end, size - 1) };
}
async function sendPlayableFile(req, res, key, name) {
const size = await storage.sizeOf(key);
if (!size) return false;
const contentType = mimeFor(name);
const cacheControl = 'private, max-age=3600';
const range = parseByteRange(req.headers.range, size);
if (range && range.unsatisfiable) {
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Range', `bytes */${size}`);
res.status(416).end();
return true;
}
if (!range) {
const source = await storage.getStream(key);
if (!source) return false;
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', String(size));
res.setHeader('Cache-Control', cacheControl);
await pipeline(source.stream, res);
return true;
}
const ok = await storage.streamRangeTo(res, key, range.start, range.end, { contentType, cacheControl });
return ok;
}
app.get('/api/files', requireAuth, async (req, res) => { app.get('/api/files', requireAuth, async (req, res) => {
const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query; const { search, student_name, group_id, date_from, date_to, limit, offset } = req.query;
const conditions = []; const conditions = [];
@@ -4641,6 +4701,11 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
if (!ok && !res.headersSent) return res.status(404).json({ error: 'File missing' }); if (!ok && !res.headersSent) return res.status(404).json({ error: 'File missing' });
return; return;
} }
if (isPlayableVideoName(r.name) && req.query.play) {
const played = await sendPlayableFile(req, res, key, r.name);
if (!played && !res.headersSent) return res.status(404).json({ error: 'File missing' });
return;
}
if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) { if (!(await storage.streamTo(res, key, { download: true, name: r.name })) && !res.headersSent) {
return res.status(404).json({ error: 'File missing' }); return res.status(404).json({ error: 'File missing' });
} }
+63
View File
@@ -35,6 +35,22 @@ const MIME_TYPES = {
'.7z': 'application/x-7z-compressed', '.7z': 'application/x-7z-compressed',
'.doc': 'application/msword', '.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.mp4': 'video/mp4',
'.m4v': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.ogv': 'video/ogg',
'.mpeg': 'video/mpeg',
'.mpg': 'video/mpeg',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
'.3gp': 'video/3gpp',
'.m3u8': 'application/vnd.apple.mpegurl',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.oga': 'audio/ogg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
}; };
const SAFE_SEGMENT = /^[\w,.()-]+$/; const SAFE_SEGMENT = /^[\w,.()-]+$/;
@@ -249,6 +265,33 @@ function createStorage(options = {}) {
return null; return null;
} }
async function getRange(key, start, end) {
const k = normalizeKey(key);
if (!k || !Number.isInteger(start) || start < 0) return null;
const last = Number.isInteger(end) && end >= start ? end : null;
if (localFallback && localExists(k)) {
const fp = localFile(k);
const size = fs.statSync(fp).size;
const to = last === null ? size - 1 : Math.min(last, size - 1);
if (start > to) return null;
return { stream: fs.createReadStream(fp, { start, end: to }), contentLength: to - start + 1, contentType: mimeFor(k) };
}
const objKey = objectKey(k);
if (!objKey) return null;
try {
const out = await client.send(new GetObjectCommand({
Bucket: bucket,
Key: objKey,
Range: `bytes=${start}-${last === null ? '' : last}`,
}));
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(k) };
} catch (err) {
if (isMissingError(err)) return null;
if (err && (err.name === 'InvalidRange' || (err.$metadata && err.$metadata.httpStatusCode === 416))) return null;
throw err;
}
}
async function del(key) { async function del(key) {
const k = normalizeKey(key); const k = normalizeKey(key);
if (!k) return false; if (!k) return false;
@@ -402,6 +445,24 @@ function createStorage(options = {}) {
return true; return true;
} }
async function streamRangeTo(res, key, start, end, opts = {}) {
const k = normalizeKey(key);
if (!k) return false;
const total = await sizeOf(k);
if (!total) return false;
const source = await getRange(k, start, end);
if (!source || !source.contentLength) return false;
const lastByte = start + source.contentLength - 1;
res.status(206);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Range', `bytes ${start}-${lastByte}/${total}`);
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
res.setHeader('Content-Length', String(source.contentLength));
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
await pipeline(source.stream, res);
return true;
}
async function downloadAll(destDir, opts = {}) { async function downloadAll(destDir, opts = {}) {
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
const root = path.resolve(destDir); const root = path.resolve(destDir);
@@ -525,12 +586,14 @@ function createStorage(options = {}) {
sizeOf, sizeOf,
getStream, getStream,
getBuffer, getBuffer,
getRange,
del, del,
copyObject, copyObject,
listAll, listAll,
localize, localize,
persist, persist,
streamTo, streamTo,
streamRangeTo,
downloadAll, downloadAll,
uploadTree, uploadTree,
ensureBucket, ensureBucket,