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:
@@ -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-пароли содержат персональные сведения — храните их во
|
||||||
|
|||||||
@@ -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
111
docs/06-authoring.md
Normal 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>`). Работает и обычная
|
||||||
|
вставка ``, но тогда подписи не будет.
|
||||||
|
|
||||||
|
Поддерживаются `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`)
|
||||||
|
или публиковать ссылкой на внешний хостинг.
|
||||||
@@ -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("&", "&")
|
.replace("&", "&")
|
||||||
.replace("<", "<")
|
.replace("<", "<")
|
||||||
@@ -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]*$",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 МБ на видео
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user