From 396221047c5598c554ad57d798e15a14fafa623d Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 17 Sep 2026 15:44:16 +0300 Subject: [PATCH] Materials: images with captions and video support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - markdown: new directives `:::figure assets/x.png | Подпись` and `:::video assets/x.mp4 | Подпись` rendered as figure/figcaption and a video player (works with HTML escaping on); sanitizer allows video/source/ track/figure with safe attributes only - assets: video extensions (mp4/webm/ogg) allowed, separate limits — images up to 10 MB, video up to 100 MB; correct content types - upload limits raised: web/academic multipart 120 MB per file / 200 MB per request, nginx client_max_body_size 200m (covers media inside ZIP imports) - docs: new docs/06-authoring.md (markdown, images, video, assets, limits, import semantics); import message now says "медиафайлов" --- deploy/IMPORT.md | 5 + deploy/nginx/lms.our-place.ru.conf | 4 +- docs/06-authoring.md | 111 ++++++++++++++++++ .../ru/ysu/lms/markdown/MarkdownRenderer.kt | 61 +++++++++- .../ysu/lms/academic/content/AssetStorage.kt | 26 ++-- .../src/main/resources/application.yml | 4 + .../lms/web/admin/ContentAdminController.kt | 2 +- web/src/main/resources/application.yml | 4 +- 8 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 docs/06-authoring.md diff --git a/deploy/IMPORT.md b/deploy/IMPORT.md index e6c85c8..412bf74 100644 --- a/deploy/IMPORT.md +++ b/deploy/IMPORT.md @@ -70,6 +70,11 @@ assets/ # картинки для md Материалы видны всем; для редактирования — «Материалы» с правами администратора. +Медиа в материалах: картинки и видео кладутся в `assets/` архива и вставляются директивами +`:::figure assets/имя.png | Подпись` и `:::video assets/имя.mp4 | Подпись`. Лимиты: картинки — +до 10 МБ (`png/jpg/jpeg/gif/svg/webp`), видео — до 100 МБ (`mp4/webm/ogg`). Nginx и сервисы +настроены на загрузку до 200 МБ; подробности — в [`docs/06-authoring.md`](../docs/06-authoring.md). + ## Замечания * Данные в `*.xls`/PDF/PDF-пароли содержат персональные сведения — храните их во diff --git a/deploy/nginx/lms.our-place.ru.conf b/deploy/nginx/lms.our-place.ru.conf index 6f904c1..9de6a8c 100644 --- a/deploy/nginx/lms.our-place.ru.conf +++ b/deploy/nginx/lms.our-place.ru.conf @@ -23,7 +23,7 @@ server { } # Большие загрузки: ZIP лаб, решения, материалы. - client_max_body_size 120m; + client_max_body_size 200m; # крупные ZIP с изображениями и видео location / { proxy_pass http://127.0.0.1:8090; @@ -52,7 +52,7 @@ server { # ssl_certificate_key /etc/letsencrypt/live/lms.our-place.ru/privkey.pem; # ssl_protocols TLSv1.2 TLSv1.3; # -# client_max_body_size 120m; +# client_max_body_size 200m; # крупные ZIP с изображениями и видео # # location / { # proxy_pass http://127.0.0.1:8090; diff --git a/docs/06-authoring.md b/docs/06-authoring.md new file mode 100644 index 0000000..ef85d07 --- /dev/null +++ b/docs/06-authoring.md @@ -0,0 +1,111 @@ +# Как оформлять учебные материалы + +Материалы лежат в файлах, а на сервер попадают **импортом ZIP** (админка → `/learn` → +«Импорт zip»). Этот документ — для автора: какой markdown поддерживается, как вставлять +картинки и видео, какие есть ограничения. + +## Структура архива + +```text +chapters/01-Справочная информация/01-Среда разработки/01-Какую среду выбрать.md +chapters/02-Основы языка C/01-Ввод-вывод и типы/01-Типы данных и ввод-вывод.md +assets/choose-01.svg # картинки и видео — в корне архива, в папке assets/ +assets/demo-install.mp4 +``` + +* `chapters///.md` — три уровня обязательны; + число в начале имени задаёт порядок. +* В начале файла — front-matter с заголовком и (необязательно) slug: + +```markdown +--- +title: "Qt Creator: установка и настройка в Windows" +slug: "qt-creator-windows" +--- +``` + +* Если slug не указать, он сгенерируется из названия. Менять slug у существующей статьи + не стоит — изменится адрес страницы. + +## Обычный markdown + +Поддерживаются заголовки `##`, списки, таблицы, цитаты (`>`), ссылки, `**жирный**`, +`*курсив*`, блоки кода с указанием языка и сноски. + +````markdown +| Клавиши | Действие | +|---|---| +| `Ctrl+R` | собрать и запустить | + +```c +int main(void) { return 0; } +``` + +```bash +gcc -Wall -g main.c -o main +``` +```` + +Код в блоках подсвечивается автоматически (нужен язык после открывающих ```). + +## Спойлер (скрытый текст) + +Удобно для решений задач: студент видит условие, а решение открывает по клику. + +````markdown +:::spoiler Решение +```c +printf("Ответ: 42\n"); +``` +::: +```` + +## Картинки + +Картинки кладите в `assets/` архива и вставляйте **директивой с подписью**: + +```markdown +:::figure assets/qtw-01.svg | Рис. 1. Скачивание установщика +``` + +Получится оформленный рисунок с подписью (`
`). Работает и обычная +вставка `![Рис. 1](assets/qtw-01.svg)`, но тогда подписи не будет. + +Поддерживаются `png`, `jpg`, `jpeg`, `gif`, `svg`, `webp` — до **10 МБ** на файл. + +## Видео + +Видеофайл также кладётся в `assets/`, вставка — директивой: + +```markdown +:::video assets/demo-install.mp4 | Видео: установка и первая программа +``` + +На странице появится проигрыватель с элементами управления; подпись необязательна. +Поддерживаются `mp4` (рекомендуется, кодек H.264), `webm`, `ogg` — до **100 МБ** на файл. +Плеер поддерживает перемотку (сервер отдаёт файл частями). + +## Импорт: что происходит с материалами + +* Главы, разделы и статьи из архива **обновляются** (совпадение по главе + разделу + slug статьи). +* Разделы и статьи, которых в архиве нет, но они есть в **главах из архива**, удаляются — + архив считается источником истины (в уведомлении пишется «удалено устаревших: N»). +* Главы, которых нет в архиве совсем, не затрагиваются — их нужно удалить вручную. +* Ссылки вида `assets/имя` автоматически заменяются на публичный адрес + `/api/v1/content/assets/...`; сами файлы складываются в хранилище. + +## Публикация и скрытие + +У каждой главы, раздела и статьи в `/learn` есть кнопка **«Скрыть…»/«Опубликовать…»** и +бейдж «скрыто». Скрытые материалы видны только администратору (со значком), студенты и +гости их не видят; прямые ссылки на скрытое отдают «не найдено». Удобно готовить материал +постепенно. + +## Практические советы по медиа + +* Скриншоты: PNG или SVG, ширина 960–1400 px; обрезайте лишнее, выделяйте важное рамкой. +* Скринкасты: 1280×720, 30 fps, H.264, до 1–2 минут — так файл остаётся в пределах 50 МБ. +* Имена файлов — латиницей и уникально: `qtw-01.svg`, `vsw-02.svg`, `demo-install.mp4`. + Одинаковые имена в разных главах перекрывают друг друга. +* Большие видео лучше сжимать (`ffmpeg -i in.mov -vcodec libx264 -crf 28 -preset veryfast out.mp4`) + или публиковать ссылкой на внешний хостинг. diff --git a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt index 9d5cf2b..2fdbf4c 100644 --- a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt +++ b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/MarkdownRenderer.kt @@ -15,12 +15,15 @@ import org.owasp.html.PolicyFactory * Renders LMS markdown to sanitized HTML. * * Supported beyond CommonMark: tables, strikethrough, task lists, footnotes, - * autolinks and the custom spoiler container: + * autolinks and custom containers: * * ``` * :::spoiler Заголовок * скрытый текст (тоже markdown) * ::: + * + * :::figure assets/scheme.svg | Рис. 1. Подпись к рисунку + * :::video assets/demo.mp4 | Видео: установка за 5 минут * ``` * * The result is always passed through an allow-list HTML sanitizer, so authors @@ -54,13 +57,13 @@ class MarkdownRenderer( val out = StringBuilder() var cursor = 0 for (match in SPOILER.findAll(markdown)) { - out.append(renderSegment(markdown.substring(cursor, match.range.first))) + out.append(renderWithDirectives(markdown.substring(cursor, match.range.first))) val title = match.groupValues[1].trim().ifBlank { "Спойлер" } val body = match.groupValues[2] val inner = if (depth < MAX_DEPTH) { renderRaw(body, depth + 1) } else { - renderSegment(body) + renderWithDirectives(body) } out.append("
") out.append(escapeHtml(title)) @@ -69,13 +72,50 @@ class MarkdownRenderer( out.append("
") cursor = match.range.last + 1 } - out.append(renderSegment(markdown.substring(cursor))) + out.append(renderWithDirectives(markdown.substring(cursor))) return out.toString() } private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown)) + /** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */ + private fun renderWithDirectives(markdown: String): String { + if (!markdown.contains(":::")) return renderSegment(markdown) + val out = StringBuilder() + var cursor = 0 + for (match in MEDIA.findAll(markdown)) { + out.append(renderSegment(markdown.substring(cursor, match.range.first))) + val kind = match.groupValues[1].lowercase() + val url = match.groupValues[2].trim() + val caption = match.groupValues[3].trim() + if (kind == "video") { + out.append("
") + out.append("") + if (caption.isNotBlank()) { + out.append("
").append(renderInline(caption)).append("
") + } + out.append("
") + } else { + out.append("
") + out.append("\"")") + if (caption.isNotBlank()) { + out.append("
").append(renderInline(caption)).append("
") + } + out.append("
") + } + cursor = match.range.last + 1 + } + out.append(renderSegment(markdown.substring(cursor))) + return out.toString() + } + + private fun renderInline(text: String): String = + renderSegment(text).removePrefix("

").removeSuffix("

").trim() + private fun escapeHtml(value: String): String = value .replace("&", "&") .replace("<", "<") @@ -92,11 +132,19 @@ class MarkdownRenderer( "code", "pre", "table", "thead", "tbody", "tr", "th", "td", "details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption", + "video", "source", "track", "audio", ) .allowAttributes("href").onElements("a") .allowAttributes("title").onElements("a", "img") .allowAttributes("src", "alt").onElements("img") - .allowAttributes("class").onElements("code", "pre", "div", "span", "details", "summary", "table", "th", "td") + .allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline") + .onElements("video") + .allowAttributes("src", "type").onElements("source") + .allowAttributes("src", "kind", "label", "srclang", "default").onElements("track") + .allowAttributes("class").onElements( + "code", "pre", "div", "span", "details", "summary", "table", "th", "td", + "figure", "figcaption", "video", "img", + ) .allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6") .allowAttributes("colspan", "rowspan").onElements("td", "th") .allowUrlProtocols("http", "https", "mailto") @@ -105,6 +153,9 @@ class MarkdownRenderer( private companion object { const val MAX_DEPTH = 5 + val MEDIA = Regex( + pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$", + ) val SPOILER = Regex( pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$", ) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt index 39bfc0d..fd80cd0 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt @@ -18,13 +18,19 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String) fun store(originalName: String, bytes: ByteArray): StoredAsset { - if (bytes.size > MAX_ASSET_BYTES) { - throw BadRequestException("Файл $originalName слишком большой (> ${MAX_ASSET_BYTES / 1024 / 1024} МБ)") - } val ext = originalName.substringAfterLast('.', "").lowercase() - val allowed = setOf("png", "jpg", "jpeg", "gif", "svg", "webp") - if (ext !in allowed) { - throw BadRequestException("Недопустимый тип файла: $originalName (разрешены ${allowed.joinToString(", ")})") + val isVideo = ext in VIDEO_EXTENSIONS + val isImage = ext in IMAGE_EXTENSIONS + if (!isVideo && !isImage) { + throw BadRequestException( + "Недопустимый тип файла: $originalName " + + "(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " + + "видео: ${VIDEO_EXTENSIONS.joinToString(", ")})", + ) + } + val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES + if (bytes.size > limit) { + throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)") } val publicName = "${UUID.randomUUID()}.$ext" val target = rootDir.resolve(publicName).normalize() @@ -50,10 +56,16 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo "gif" -> "image/gif" "svg" -> "image/svg+xml" "webp" -> "image/webp" + "mp4" -> "video/mp4" + "webm" -> "video/webm" + "ogg", "ogv" -> "video/ogg" else -> "application/octet-stream" } private companion object { - const val MAX_ASSET_BYTES = 10 * 1024 * 1024 + val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp") + val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv") + const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку + const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео } } diff --git a/services/academic-service/src/main/resources/application.yml b/services/academic-service/src/main/resources/application.yml index 2084e5e..a9d57bf 100644 --- a/services/academic-service/src/main/resources/application.yml +++ b/services/academic-service/src/main/resources/application.yml @@ -4,6 +4,10 @@ server: spring: application: name: academic-service + servlet: + multipart: + max-file-size: 120MB + max-request-size: 200MB datasource: url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic} username: ${LMS_DB_USER:lms} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index df13f54..67396fb 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -42,7 +42,7 @@ class ContentAdminController( redirect.addFlashAttribute( "notice", "Импортировано: глав ${result.chapters}, разделов ${result.sections}, " + - "статей ${result.articles}, картинок ${result.assets}$removedNote", + "статей ${result.articles}, медиафайлов ${result.assets}$removedNote", ) "redirect:/admin/content" } catch (ex: ApiException) { diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index bc14777..2fc5076 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -16,8 +16,8 @@ spring: name: web servlet: multipart: - max-file-size: 30MB - max-request-size: 120MB + max-file-size: 120MB + max-request-size: 200MB thymeleaf: cache: false