Materials: images with captions and video support

- 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 "медиафайлов"
This commit is contained in:
2026-09-17 15:44:16 +03:00
parent 1238265c8e
commit 396221047c
8 changed files with 200 additions and 17 deletions

View File

@@ -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-пароли содержат персональные сведения — храните их во * Данные в `*.xls`/PDF/PDF-пароли содержат персональные сведения — храните их во

View File

@@ -23,7 +23,7 @@ server {
} }
# Большие загрузки: ZIP лаб, решения, материалы. # Большие загрузки: ZIP лаб, решения, материалы.
client_max_body_size 120m; client_max_body_size 200m; # крупные ZIP с изображениями и видео
location / { location / {
proxy_pass http://127.0.0.1:8090; 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_certificate_key /etc/letsencrypt/live/lms.our-place.ru/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3; # ssl_protocols TLSv1.2 TLSv1.3;
# #
# client_max_body_size 120m; # client_max_body_size 200m; # крупные ZIP с изображениями и видео
# #
# location / { # location / {
# proxy_pass http://127.0.0.1:8090; # proxy_pass http://127.0.0.1:8090;

111
docs/06-authoring.md Normal file
View File

@@ -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/<NN-глава>/<NN-раздел>/<NN-статья>.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. Скачивание установщика
```
Получится оформленный рисунок с подписью (`<figure><img><figcaption>`). Работает и обычная
вставка `![Рис. 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`)
или публиковать ссылкой на внешний хостинг.

View File

@@ -15,12 +15,15 @@ import org.owasp.html.PolicyFactory
* Renders LMS markdown to sanitized HTML. * Renders LMS markdown to sanitized HTML.
* *
* Supported beyond CommonMark: tables, strikethrough, task lists, footnotes, * Supported beyond CommonMark: tables, strikethrough, task lists, footnotes,
* autolinks and the custom spoiler container: * autolinks and custom containers:
* *
* ``` * ```
* :::spoiler Заголовок * :::spoiler Заголовок
* скрытый текст (тоже markdown) * скрытый текст (тоже markdown)
* ::: * :::
*
* :::figure assets/scheme.svg | Рис. 1. Подпись к рисунку
* :::video assets/demo.mp4 | Видео: установка за 5 минут
* ``` * ```
* *
* The result is always passed through an allow-list HTML sanitizer, so authors * The result is always passed through an allow-list HTML sanitizer, so authors
@@ -54,13 +57,13 @@ class MarkdownRenderer(
val out = StringBuilder() val out = StringBuilder()
var cursor = 0 var cursor = 0
for (match in SPOILER.findAll(markdown)) { 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 title = match.groupValues[1].trim().ifBlank { "Спойлер" }
val body = match.groupValues[2] val body = match.groupValues[2]
val inner = if (depth < MAX_DEPTH) { val inner = if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1) renderRaw(body, depth + 1)
} else { } else {
renderSegment(body) renderWithDirectives(body)
} }
out.append("<details class=\"spoiler\"><summary>") out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title)) out.append(escapeHtml(title))
@@ -69,13 +72,50 @@ class MarkdownRenderer(
out.append("</div></details>") out.append("</div></details>")
cursor = match.range.last + 1 cursor = match.range.last + 1
} }
out.append(renderSegment(markdown.substring(cursor))) out.append(renderWithDirectives(markdown.substring(cursor)))
return out.toString() return out.toString()
} }
private fun renderSegment(markdown: String): String = private fun renderSegment(markdown: String): String =
renderer.render(parser.parse(markdown)) 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("<figure class=\"md-video-figure\">")
out.append("<video class=\"md-video\" controls preload=\"metadata\" src=\"")
out.append(escapeHtml(url))
out.append("\"></video>")
if (caption.isNotBlank()) {
out.append("<figcaption>").append(renderInline(caption)).append("</figcaption>")
}
out.append("</figure>")
} else {
out.append("<figure class=\"md-figure\">")
out.append("<img src=\"").append(escapeHtml(url)).append("\" alt=\"")
out.append(escapeHtml(caption)).append("\">")
if (caption.isNotBlank()) {
out.append("<figcaption>").append(renderInline(caption)).append("</figcaption>")
}
out.append("</figure>")
}
cursor = match.range.last + 1
}
out.append(renderSegment(markdown.substring(cursor)))
return out.toString()
}
private fun renderInline(text: String): String =
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun escapeHtml(value: String): String = value private fun escapeHtml(value: String): String = value
.replace("&", "&amp;") .replace("&", "&amp;")
.replace("<", "&lt;") .replace("<", "&lt;")
@@ -92,11 +132,19 @@ class MarkdownRenderer(
"code", "pre", "code", "pre",
"table", "thead", "tbody", "tr", "th", "td", "table", "thead", "tbody", "tr", "th", "td",
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption", "details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
"video", "source", "track", "audio",
) )
.allowAttributes("href").onElements("a") .allowAttributes("href").onElements("a")
.allowAttributes("title").onElements("a", "img") .allowAttributes("title").onElements("a", "img")
.allowAttributes("src", "alt").onElements("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("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan").onElements("td", "th") .allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto") .allowUrlProtocols("http", "https", "mailto")
@@ -105,6 +153,9 @@ class MarkdownRenderer(
private companion object { private companion object {
const val MAX_DEPTH = 5 const val MAX_DEPTH = 5
val MEDIA = Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER = Regex( val SPOILER = Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$", pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
) )

View File

@@ -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) data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
fun store(originalName: String, bytes: ByteArray): StoredAsset { 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 ext = originalName.substringAfterLast('.', "").lowercase()
val allowed = setOf("png", "jpg", "jpeg", "gif", "svg", "webp") val isVideo = ext in VIDEO_EXTENSIONS
if (ext !in allowed) { val isImage = ext in IMAGE_EXTENSIONS
throw BadRequestException("Недопустимый тип файла: $originalName (разрешены ${allowed.joinToString(", ")})") 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 publicName = "${UUID.randomUUID()}.$ext"
val target = rootDir.resolve(publicName).normalize() val target = rootDir.resolve(publicName).normalize()
@@ -50,10 +56,16 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
"gif" -> "image/gif" "gif" -> "image/gif"
"svg" -> "image/svg+xml" "svg" -> "image/svg+xml"
"webp" -> "image/webp" "webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream" else -> "application/octet-stream"
} }
private companion object { 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 МБ на видео
} }
} }

View File

@@ -4,6 +4,10 @@ server:
spring: spring:
application: application:
name: academic-service name: academic-service
servlet:
multipart:
max-file-size: 120MB
max-request-size: 200MB
datasource: datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic} url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic}
username: ${LMS_DB_USER:lms} username: ${LMS_DB_USER:lms}

View File

@@ -42,7 +42,7 @@ class ContentAdminController(
redirect.addFlashAttribute( redirect.addFlashAttribute(
"notice", "notice",
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " + "Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
"статей ${result.articles}, картинок ${result.assets}$removedNote", "статей ${result.articles}, медиафайлов ${result.assets}$removedNote",
) )
"redirect:/admin/content" "redirect:/admin/content"
} catch (ex: ApiException) { } catch (ex: ApiException) {

View File

@@ -16,8 +16,8 @@ spring:
name: web name: web
servlet: servlet:
multipart: multipart:
max-file-size: 30MB max-file-size: 120MB
max-request-size: 120MB max-request-size: 200MB
thymeleaf: thymeleaf:
cache: false cache: false