Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 9b7748571e
119 changed files with 4671 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
api(libs.flexmarkAll)
api(libs.owaspHtmlSanitizer)
api(libs.springContext)
api(libs.springBootAutoconfigure)
}

View File

@@ -0,0 +1,16 @@
package ru.ysu.lms.markdown
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
@AutoConfiguration
@EnableConfigurationProperties(MarkdownProperties::class)
class LmsMarkdownAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
MarkdownRenderer(allowHtml = properties.allowHtml)
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.markdown
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "lms.markdown")
data class MarkdownProperties(
/** When true, raw HTML in the source markdown is passed through (still sanitized). */
val allowHtml: Boolean = false,
)

View File

@@ -0,0 +1,113 @@
package ru.ysu.lms.markdown
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.footnotes.FootnoteExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.ext.tables.TablesExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import org.owasp.html.HtmlPolicyBuilder
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:
*
* ```
* :::spoiler Заголовок
* скрытый текст (тоже markdown)
* :::
* ```
*
* The result is always passed through an allow-list HTML sanitizer, so authors
* cannot inject scripts or arbitrary markup.
*/
class MarkdownRenderer(
private val allowHtml: Boolean = false,
) {
private val options = MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.SOFT_BREAK, "<br />\n")
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
private val sanitizer: PolicyFactory = buildSanitizer()
fun render(markdown: String): String = sanitizer.sanitize(renderRaw(markdown, depth = 0))
private fun renderRaw(markdown: String, depth: Int): String {
val out = StringBuilder()
var cursor = 0
for (match in SPOILER.findAll(markdown)) {
out.append(renderSegment(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)
}
out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title))
out.append("</summary><div class=\"spoiler-body\">")
out.append(inner)
out.append("</div></details>")
cursor = match.range.last + 1
}
out.append(renderSegment(markdown.substring(cursor)))
return out.toString()
}
private fun renderSegment(markdown: String): String =
renderer.render(parser.parse(markdown))
private fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
.allowElements(
"p", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "del", "s", "blockquote",
"ul", "ol", "li",
"a", "img",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
)
.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("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
val SPOILER = Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
}
}

View File

@@ -0,0 +1 @@
ru.ysu.lms.markdown.LmsMarkdownAutoConfiguration