diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0a65cdd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{kt,kts}] +max_line_length = 140 +# Значения enum используются как строковые статусы в API/БД — оставляем lowerCamelCase. +ktlint_standard_enum-entry-name-case = disabled diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 4adb3bf..ca4e9a4 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -13,4 +13,6 @@ dependencies { implementation(libs.kotlinAllopenPlugin) implementation(libs.kotlinNoargPlugin) implementation(libs.springBootGradlePlugin) + implementation(libs.ktlintGradlePlugin) + implementation(libs.detektGradlePlugin) } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index 705bfb5..54c2b36 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -4,6 +4,7 @@ dependencyResolutionManagement { @Suppress("UnstableApiUsage") repositories { mavenCentral() + gradlePluginPortal() } // Reuse the version catalog from the main build. diff --git a/buildSrc/src/main/kotlin/lms.lint.gradle.kts b/buildSrc/src/main/kotlin/lms.lint.gradle.kts new file mode 100644 index 0000000..4afbf61 --- /dev/null +++ b/buildSrc/src/main/kotlin/lms.lint.gradle.kts @@ -0,0 +1,34 @@ +// Единая конфигурация статического анализа Kotlin: ktlint (формат) + detekt (качество). +// На время миграции ignoreFailures=true — задачи только отчитываются, не ломая сборку. +import io.gitlab.arturbosch.detekt.Detekt + +plugins { + id("org.jlleitschuh.gradle.ktlint") + id("io.gitlab.arturbosch.detekt") +} + +ktlint { + ignoreFailures.set(true) + filter { + exclude("**/build/**") + } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom(rootProject.files("config/detekt/detekt.yml")) + ignoreFailures = true + parallel = true +} + +tasks.withType().configureEach { + jvmTarget = "21" + exclude("**/build/**") + reports { + html.required.set(true) + xml.required.set(false) + txt.required.set(false) + sarif.required.set(false) + md.required.set(false) + } +} diff --git a/buildSrc/src/main/kotlin/lms.spring-boot.gradle.kts b/buildSrc/src/main/kotlin/lms.spring-boot.gradle.kts index 56843d4..1d0a7c1 100644 --- a/buildSrc/src/main/kotlin/lms.spring-boot.gradle.kts +++ b/buildSrc/src/main/kotlin/lms.spring-boot.gradle.kts @@ -6,6 +6,7 @@ plugins { id("org.jetbrains.kotlin.plugin.spring") id("org.jetbrains.kotlin.plugin.jpa") id("org.springframework.boot") + id("lms.lint") } group = "ru.ysu.lms" diff --git a/buildSrc/src/main/kotlin/lms.spring-library.gradle.kts b/buildSrc/src/main/kotlin/lms.spring-library.gradle.kts index 6ac5f59..1008de6 100644 --- a/buildSrc/src/main/kotlin/lms.spring-library.gradle.kts +++ b/buildSrc/src/main/kotlin/lms.spring-library.gradle.kts @@ -4,6 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { kotlin("jvm") id("org.jetbrains.kotlin.plugin.spring") + id("lms.lint") } group = "ru.ysu.lms" diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..b1c9f9e --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,50 @@ +# Конфигурация detekt для LMS. Значения подобраны под текущий стиль кода: +# базовые правила включены (buildUponDefaultConfig = true), шумные отключены или ослаблены. +build: + maxIssues: 0 + weights: {} + +config: + validation: true + warningsAsErrors: false + +complexity: + LongMethod: + threshold: 60 + LongParameterList: + functionThreshold: 6 + constructorThreshold: 7 + ignoreDefaultParameters: true + TooManyFunctions: + thresholdInFiles: 20 + thresholdInClasses: 20 + thresholdInInterfaces: 20 + thresholdInObjects: 20 + thresholdInEnums: 20 + ComplexCondition: + threshold: 4 + +style: + MagicNumber: + active: false + MaxLineLength: + active: false + ReturnCount: + max: 4 + WildcardImport: + active: true + UnusedPrivateMember: + active: true + LoopWithTooManyJumpStatements: + maxJumpCount: 2 + +naming: + FunctionNaming: + ignoreAnnotated: + - 'Test' + MatchingDeclarationName: + active: false + +empty-blocks: + EmptyCatchBlock: + allowedExceptionNameRegex: 'ignored|expected|_' diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..07addd8 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/faa59212208d027d066d1bbe337c5da5/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/adf5b89d64773eff73f900e61dd2d632/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e0e37f85a97c5f2df685b37b14a25072/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ccc3884e65cd85bb71a09d2223c77229/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 67f8f2f..b0e454a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,9 @@ springdoc = "3.0.3" flexmark = "0.64.8" owaspSanitizer = "20260313.1" apachePoi = "5.5.1" +ktlint = "14.2.0" +detekt = "1.23.8" +mockk = "1.14.11" [libraries] # --- Gradle plugins used from buildSrc --- @@ -17,6 +20,8 @@ kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ver kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" } kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" } springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" } +ktlintGradlePlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } +detektGradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } # --- Platform BOMs (versions only) --- springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" } @@ -65,6 +70,8 @@ owaspHtmlSanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp- apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" } # --- Tests / Testcontainers (versions managed by the Boot BOM) --- +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" } springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" } testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" } testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" } @@ -78,3 +85,5 @@ kotlinPluginJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" } springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" } +ktlintPlugin = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +detektPlugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } diff --git a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsCommonAutoConfiguration.kt b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsCommonAutoConfiguration.kt index 3ce3579..b4b889f 100644 --- a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsCommonAutoConfiguration.kt +++ b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsCommonAutoConfiguration.kt @@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean @AutoConfiguration class LmsCommonAutoConfiguration { - @Bean @ConditionalOnMissingBean fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler() diff --git a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsException.kt b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsException.kt index fa64d78..ba2eaa0 100644 --- a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsException.kt +++ b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsException.kt @@ -13,17 +13,27 @@ open class LmsException( val violations: List = emptyList(), ) : RuntimeException(message) -class BadRequestException(message: String, code: String? = null) : - LmsException(HttpStatus.BAD_REQUEST, message, code) +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 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 ConflictException( + message: String, + code: String? = null, +) : LmsException(HttpStatus.CONFLICT, message, code) -class ForbiddenException(message: String, code: String? = null) : - LmsException(HttpStatus.FORBIDDEN, 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) +class UnauthorizedException( + message: String, + code: String? = null, +) : LmsException(HttpStatus.UNAUTHORIZED, message, code) diff --git a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt index be75c54..b6a8d0e 100644 --- a/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt +++ b/libs/common-http/src/main/kotlin/ru/ysu/lms/common/LmsExceptionHandler.kt @@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class LmsExceptionHandler { - @ExceptionHandler(LmsException::class) fun handleDomain(ex: LmsException): ResponseEntity = ResponseEntity.status(ex.status).body( @@ -22,9 +21,10 @@ class LmsExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity { - val violations = ex.bindingResult.fieldErrors.map { - FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value") - } + val violations = + ex.bindingResult.fieldErrors.map { + FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value") + } return ResponseEntity.badRequest().body( ApiError( title = "Validation failed", diff --git a/libs/markdown/build.gradle.kts b/libs/markdown/build.gradle.kts index 37797db..b09039c 100644 --- a/libs/markdown/build.gradle.kts +++ b/libs/markdown/build.gradle.kts @@ -9,4 +9,6 @@ dependencies { api(libs.owaspHtmlSanitizer) api(libs.springContext) api(libs.springBootAutoconfigure) + + testImplementation(project(":libs:test-support")) } diff --git a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/LmsMarkdownAutoConfiguration.kt b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/LmsMarkdownAutoConfiguration.kt index 06d823b..1305086 100644 --- a/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/LmsMarkdownAutoConfiguration.kt +++ b/libs/markdown/src/main/kotlin/ru/ysu/lms/markdown/LmsMarkdownAutoConfiguration.kt @@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean @AutoConfiguration @EnableConfigurationProperties(MarkdownProperties::class) class LmsMarkdownAutoConfiguration { - @Bean @ConditionalOnMissingBean - fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = - MarkdownRenderer(allowHtml = properties.allowHtml) + fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml) } 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 04b5b6d..fea3b8b 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 @@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory 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.ESCAPE_HTML, !allowHtml) - } + 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.ESCAPE_HTML, !allowHtml) + } private val parser: Parser = Parser.builder(options).build() private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build() @@ -55,21 +56,26 @@ class MarkdownRenderer( /** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */ private fun wrapTables(html: String): String = - html.replace("", "") - private fun renderRaw(markdown: String, depth: Int): String { + private fun renderRaw( + markdown: String, + depth: Int, + ): String { val out = StringBuilder() var cursor = 0 for (match in SPOILER.findAll(markdown)) { 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 { - renderWithDirectives(body) - } + val inner = + if (depth < MAX_DEPTH) { + renderRaw(body, depth + 1) + } else { + renderWithDirectives(body) + } out.append("
") out.append(escapeHtml(title)) out.append("
") @@ -81,8 +87,7 @@ class MarkdownRenderer( return out.toString() } - private fun renderSegment(markdown: String): String = - renderer.render(parser.parse(markdown)) + private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown)) /** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */ private fun renderWithDirectives(markdown: String): String { @@ -118,51 +123,104 @@ class MarkdownRenderer( return out.toString() } - private fun renderInline(text: String): String = - renderSegment(text).removePrefix("

").removeSuffix("

").trim() + private fun renderInline(text: String): String = renderSegment(text).removePrefix("

").removeSuffix("

").trim() - private fun escapeHtml(value: String): String = value - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) + 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", - "video", "source", "track", "audio", - ) - .allowAttributes("href").onElements("a") - .allowAttributes("title").onElements("a", "img") - .allowAttributes("src", "alt").onElements("img") - .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") - .requireRelNofollowOnLinks() - .toFactory() + 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", + "video", + "source", + "track", + "audio", + ).allowAttributes("href") + .onElements("a") + .allowAttributes("title") + .onElements("a", "img") + .allowAttributes("src", "alt") + .onElements("img") + .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") + .requireRelNofollowOnLinks() + .toFactory() 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]*$", - ) + 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/libs/markdown/src/test/kotlin/ru/ysu/lms/markdown/MarkdownRendererTest.kt b/libs/markdown/src/test/kotlin/ru/ysu/lms/markdown/MarkdownRendererTest.kt new file mode 100644 index 0000000..f9383e9 --- /dev/null +++ b/libs/markdown/src/test/kotlin/ru/ysu/lms/markdown/MarkdownRendererTest.kt @@ -0,0 +1,61 @@ +package ru.ysu.lms.markdown + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class MarkdownRendererTest { + private val renderer = MarkdownRenderer() + + @Test + fun `renders basic markdown`() { + val html = renderer.render("# Заголовок\n\n**жирный** и *курсив*") + + assertThat(html).contains("жирный") + assertThat(html).contains("курсив") + } + + @Test + fun `renders lists and code blocks`() { + val html = renderer.render("- один\n- два\n\n```c\nint x = 1;\n```") + + assertThat(html).contains("
    ") + assertThat(html).contains("
  • ") + assertThat(html).contains("один") + assertThat(html).contains("
    ")
    +        assertThat(html).contains("language-c")
    +        assertThat(html).contains("int x")
    +    }
    +
    +    @Test
    +    fun `wraps tables into scrollable container`() {
    +        val md =
    +            """
    +            | № | Задание |
    +            |---|---|
    +            | 1 | Сумма |
    +            """.trimIndent()
    +
    +        val html = renderer.render(md)
    +
    +        assertThat(html).contains("
    ") + } + + @Test + fun `escapes raw html and strips scripts`() { + val html = renderer.render("") + + assertThat(html).doesNotContain("