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:
10
libs/common-http/build.gradle.kts
Normal file
10
libs/common-http/build.gradle.kts
Normal file
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id("lms.spring-library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
api(libs.springWeb)
|
||||
api(libs.springBootAutoconfigure)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Uniform error payload, close to RFC 9457 "Problem Details for HTTP APIs".
|
||||
*/
|
||||
data class ApiError(
|
||||
val type: String = "about:blank",
|
||||
val title: String,
|
||||
val status: Int,
|
||||
val detail: String? = null,
|
||||
val code: String? = null,
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
val timestamp: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
data class FieldViolation(
|
||||
val field: String,
|
||||
val message: String,
|
||||
)
|
||||
21
libs/common-http/src/main/kotlin/ru/ysu/lms/common/Ids.kt
Normal file
21
libs/common-http/src/main/kotlin/ru/ysu/lms/common/Ids.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Time-ordered UUIDv7 identifiers (the same layout PostgreSQL 18 provides with
|
||||
* `uuidv7()`), so application-generated and database-generated ids match.
|
||||
*/
|
||||
object Ids {
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun v7(): UUID {
|
||||
val timestamp = System.currentTimeMillis() and 0x0000_FFFF_FFFF_FFFFL
|
||||
val randA = (random.nextInt() and 0x0FFF).toLong()
|
||||
val randB = random.nextLong() and 0x3FFF_FFFF_FFFF_FFFFL
|
||||
val msb = (timestamp shl 16) or 0x7000L or randA
|
||||
val lsb = Long.MIN_VALUE or randB
|
||||
return UUID(msb, lsb)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
|
||||
@AutoConfiguration
|
||||
class LmsCommonAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
/**
|
||||
* Base class for expected, domain-level failures. Mapped to [ApiError] by
|
||||
* [LmsExceptionHandler].
|
||||
*/
|
||||
open class LmsException(
|
||||
val status: HttpStatus,
|
||||
override val message: String,
|
||||
val code: String? = null,
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
) : RuntimeException(message)
|
||||
|
||||
class BadRequestException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.BAD_REQUEST, message, code)
|
||||
|
||||
class NotFoundException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.NOT_FOUND, message, code)
|
||||
|
||||
class ConflictException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.CONFLICT, message, code)
|
||||
|
||||
class ForbiddenException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.FORBIDDEN, message, code)
|
||||
|
||||
class UnauthorizedException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
@RestControllerAdvice
|
||||
class LmsExceptionHandler {
|
||||
|
||||
@ExceptionHandler(LmsException::class)
|
||||
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
|
||||
ResponseEntity.status(ex.status).body(
|
||||
ApiError(
|
||||
title = ex.status.reasonPhrase,
|
||||
status = ex.status.value(),
|
||||
detail = ex.message,
|
||||
code = ex.code,
|
||||
violations = ex.violations,
|
||||
),
|
||||
)
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
|
||||
val violations = ex.bindingResult.fieldErrors.map {
|
||||
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
ApiError(
|
||||
title = "Validation failed",
|
||||
status = 400,
|
||||
detail = "Request validation failed",
|
||||
violations = violations,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ru.ysu.lms.common.LmsCommonAutoConfiguration
|
||||
12
libs/markdown/build.gradle.kts
Normal file
12
libs/markdown/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
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]*$",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ru.ysu.lms.markdown.LmsMarkdownAutoConfiguration
|
||||
11
libs/security-jwt/build.gradle.kts
Normal file
11
libs/security-jwt/build.gradle.kts
Normal file
@@ -0,0 +1,11 @@
|
||||
plugins {
|
||||
id("lms.spring-library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
api(libs.springContext)
|
||||
api(libs.springBootAutoconfigure)
|
||||
api(libs.springSecurityOauth2Jose)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.ysu.lms.security
|
||||
|
||||
enum class LmsRole {
|
||||
ADMIN,
|
||||
STUDENT;
|
||||
|
||||
fun authority(): String = "ROLE_$name"
|
||||
}
|
||||
|
||||
data class JwtPrincipal(
|
||||
val accountId: String,
|
||||
val login: String,
|
||||
val role: LmsRole,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.ysu.lms.security
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import java.time.Duration
|
||||
|
||||
@ConfigurationProperties(prefix = "lms.jwt")
|
||||
data class JwtProperties(
|
||||
val issuer: String = "lms",
|
||||
/** PKCS#8 PEM private key. Empty disables the encoder. */
|
||||
val privateKey: String = "",
|
||||
/** X.509 PEM public key. Empty disables the decoder. */
|
||||
val publicKey: String = "",
|
||||
/** JWKS endpoint used to build the decoder when [publicKey] is empty. */
|
||||
val jwkSetUri: String = "",
|
||||
val accessTokenTtl: Duration = Duration.ofHours(12),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package ru.ysu.lms.security
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet
|
||||
import com.nimbusds.jose.jwk.RSAKey
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
|
||||
import com.nimbusds.jose.proc.SecurityContext
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
||||
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class LmsSecurityAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
|
||||
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
|
||||
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
|
||||
.privateKey(RsaKeys.privateKey(properties.privateKey))
|
||||
.build()
|
||||
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun jwtDecoder(properties: JwtProperties): JwtDecoder =
|
||||
if (properties.jwkSetUri.isNotBlank()) {
|
||||
NimbusJwtDecoder.withJwkSetUri(properties.jwkSetUri).build()
|
||||
} else {
|
||||
require(properties.publicKey.isNotBlank()) {
|
||||
"Configure either lms.jwt.jwk-set-uri or lms.jwt.public-key"
|
||||
}
|
||||
NimbusJwtDecoder.withPublicKey(RsaKeys.publicKey(properties.publicKey)).build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ru.ysu.lms.security
|
||||
|
||||
import java.security.KeyFactory
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.Base64
|
||||
|
||||
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
|
||||
object RsaKeys {
|
||||
|
||||
fun publicKey(pem: String): RSAPublicKey {
|
||||
val der = decode(pem)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
|
||||
}
|
||||
|
||||
fun privateKey(pem: String): RSAPrivateKey {
|
||||
val der = decode(pem)
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
|
||||
}
|
||||
|
||||
private fun decode(pem: String): ByteArray {
|
||||
val body = pem
|
||||
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
|
||||
.replace(Regex("-----END [A-Z ]+-----"), "")
|
||||
.replace(Regex("\\s"), "")
|
||||
return Base64.getDecoder().decode(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
13
libs/test-support/build.gradle.kts
Normal file
13
libs/test-support/build.gradle.kts
Normal file
@@ -0,0 +1,13 @@
|
||||
plugins {
|
||||
id("lms.spring-library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
|
||||
api(libs.springBootTestcontainers)
|
||||
api(libs.testcontainersPostgresql)
|
||||
api(libs.testcontainersJunitJupiter)
|
||||
api(libs.springBootStarterTest)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.ysu.lms.test
|
||||
|
||||
object DatabaseTestSupport {
|
||||
const val POSTGRES_IMAGE: String = "postgres:18"
|
||||
const val SCHEMA_IDENTITY: String = "identity"
|
||||
const val SCHEMA_ACADEMIC: String = "academic"
|
||||
const val SCHEMA_PROGRESS: String = "progress"
|
||||
}
|
||||
Reference in New Issue
Block a user