diff --git a/design/style-a-university.html b/design/style-a-university.html
new file mode 100644
index 0000000..2a0f134
--- /dev/null
+++ b/design/style-a-university.html
@@ -0,0 +1,267 @@
+
+
+
+
+
+LMS — демо стиля А: университетская классика
+
+
+
+
+
+
+Демо-шаблон «А. Университетская классика» · данные условные · тёмная тема будет в реализации
+
+
+
+
+
+
+
Ведомость группы
+
М202-1 · Первый курс
+
24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Студент |
+ ЛР 1Анализ данных |
+ ЛР 2Сортировки |
+ ЛР 3Деревья поиска |
+ Пароль |
+ Действия |
+
+
+
+
+ Иванова Анна 2025001 |
+
+ В-74.5◉
+
+ |
+
+ В-23.0◉
+
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Петров Павел 2025002 |
+
+ В-15.0◉
+
+ |
+
+ В-4—◉
+
+ |
+
+ В-3—○
+ не сдано
+ |
+ изменён |
+ |
+
+
+ Сидоров Алексей 2025017 |
+
+ В-5—◉
+ не сдано
+ |
+
+ —
+ не выдан
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Кузнецова Мария 2025021 |
+
+ В-34.0○
+
+ |
+
+ В-6—○
+
+ |
+
+ —
+ не выдан
+ |
+ изменён |
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design/style-b-minimal.html b/design/style-b-minimal.html
new file mode 100644
index 0000000..b68dd6f
--- /dev/null
+++ b/design/style-b-minimal.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+LMS — демо стиля Б: строгий минимализм
+
+
+
+
+
+Демо-шаблон «Б. Строгий минимализм» · данные условные · тёмная тема будет в реализации
+
+
+
+
+
+
+
Ведомость группы
+
М202-1 · Первый курс
+
24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Студент |
+ ЛР 1Анализ данных |
+ ЛР 2Сортировки |
+ ЛР 3Деревья поиска |
+ Пароль |
+ Действия |
+
+
+
+
+ Иванова Анна 2025001 |
+
+ В-74.5◉
+
+ |
+
+ В-23.0◉
+
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Петров Павел 2025002 |
+
+ В-15.0◉
+
+ |
+
+ В-4—◉
+
+ |
+
+ В-3—○
+ не сдано
+ |
+ изменён |
+ |
+
+
+ Сидоров Алексей 2025017 |
+
+ В-5—◉
+ не сдано
+ |
+
+ —
+ не выдан
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Кузнецова Мария 2025021 |
+
+ В-34.0○
+
+ |
+
+ В-6—○
+
+ |
+
+ —
+ не выдан
+ |
+ изменён |
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design/style-b2-minimal.html b/design/style-b2-minimal.html
new file mode 100644
index 0000000..bfde7d9
--- /dev/null
+++ b/design/style-b2-minimal.html
@@ -0,0 +1,400 @@
+
+
+
+
+
+LMS — демо стиля Б2: минимализм с живой палитрой
+
+
+
+
+
+Демо-шаблон «Б2» · развитый минимализм: живая палитра, адаптивные таблицы, бейджи-индикаторы · сузьте окно, чтобы увидеть все три режима таблицы
+
+
+ Акцентный цвет:
+
+
+
+
+ — красный остаётся только на действиях «Удалить/Отчислить»
+
+
+
+
+
+
+
+
Ведомость группы
+
М202-1 · Первый курс
+
24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Элементы интерфейса
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Статусы: бейдж — только индикатор, действие — отдельной кнопкой
+
+ ЛР 1 · Анализ данных
+ Опубликована
+
+
+
+ ЛР 2 · Сортировки
+ Черновик
+
+
+
+ Глава 3 · Жадные алгоритмы
+ Скрыто
+
+
+
+ Уведомления
+ Оценки опубликованы всей группе — студенты видят баллы и комментарии.
+ Срок сдачи ЛР 2 истекает завтра в 23:59 МСК.
+ Не удалось сохранить файлы решения — повторите попытку.
+ Новый студент добавлен: логин 2025031, пароль показан один раз.
+
+
+
+
+
+
+
+
diff --git a/design/style-b3-bordeaux.html b/design/style-b3-bordeaux.html
new file mode 100644
index 0000000..d7ede35
--- /dev/null
+++ b/design/style-b3-bordeaux.html
@@ -0,0 +1,348 @@
+
+
+
+
+
+LMS — демо стиля Б3: минимализм, акцент бордо
+
+
+
+
+
+Демо-шаблон «Б3» · сдержанность Б1 + бордовый акцент · цвет только там, где он несёт смысл · сузьте окно: таблица скроллится, колонка «Студент» прилипает
+
+
+ Акцентный цвет:
+
+
+
+
+
+
+
+
+
+
+
Ведомость группы
+
М202-1 · Первый курс
+
24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Элементы интерфейса
+
+
+
+
+
+
+
+
+
+
+
+
+ Статусы: бейдж — только индикатор, действие — отдельной кнопкой
+
+ ЛР 1 · Анализ данных
+ Опубликована
+
+
+
+ ЛР 2 · Сортировки
+ Черновик
+
+
+
+ Глава 3 · Жадные алгоритмы
+ Скрыто
+
+
+
+ Уведомление
+ Оценки опубликованы всей группе — студенты видят баллы и комментарии.
+
+
+
+
+
+
+
+
diff --git a/design/style-c-institutional.html b/design/style-c-institutional.html
new file mode 100644
index 0000000..e448b67
--- /dev/null
+++ b/design/style-c-institutional.html
@@ -0,0 +1,264 @@
+
+
+
+
+
+LMS — демо стиля В: институциональный
+
+
+
+
+
+Демо-шаблон «В. Институциональный» · данные условные · тёмная тема будет в реализации
+
+
+
+
+
+
+
Ведомость группы
+
М202-1 · Первый курс
+
24 студента · 3 опубликованные лабораторные · длительность защиты 30 мин
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Студент |
+ ЛР 1Анализ данных |
+ ЛР 2Сортировки |
+ ЛР 3Деревья поиска |
+ Пароль |
+ Действия |
+
+
+
+
+ Иванова Анна 2025001 |
+
+ В-74.5◉
+
+ |
+
+ В-23.0◉
+
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Петров Павел 2025002 |
+
+ В-15.0◉
+
+ |
+
+ В-4—◉
+
+ |
+
+ В-3—○
+ не сдано
+ |
+ изменён |
+ |
+
+
+ Сидоров Алексей 2025017 |
+
+ В-5—◉
+ не сдано
+ |
+
+ —
+ не выдан
+ |
+
+ —
+ не выдан
+ |
+ временный |
+ |
+
+
+ Кузнецова Мария 2025021 |
+
+ В-34.0○
+
+ |
+
+ В-6—○
+
+ |
+
+ —
+ не выдан
+ |
+ изменён |
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/common-http/build.gradle.kts b/libs/common-http/build.gradle.kts
index 10e74be..15dfc4c 100644
--- a/libs/common-http/build.gradle.kts
+++ b/libs/common-http/build.gradle.kts
@@ -7,6 +7,8 @@ dependencies {
api(libs.springWeb)
api(libs.springBootAutoconfigure)
+ // spring-tx нужен для обработчика DataIntegrityViolationException в LmsExceptionHandler.
+ api("org.springframework:spring-tx")
// Kotlin-модуль Jackson: корректная десериализация data-классов со значениями по умолчанию.
api(libs.jacksonModuleKotlin)
}
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 ba2eaa0..9ed0391 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
@@ -37,3 +37,8 @@ class UnauthorizedException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)
+
+class TooManyRequestsException(
+ message: String,
+ code: String? = null,
+) : LmsException(HttpStatus.TOO_MANY_REQUESTS, 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 b6a8d0e..b2aff1b 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
@@ -1,9 +1,11 @@
package ru.ysu.lms.common
+import org.springframework.dao.DataIntegrityViolationException
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
+import org.springframework.web.multipart.MaxUploadSizeExceededException
@RestControllerAdvice
class LmsExceptionHandler {
@@ -34,4 +36,37 @@ class LmsExceptionHandler {
),
)
}
+
+ /** Некорректный UUID/аргумент в пути или теле — клиентская ошибка, а не 500. */
+ @ExceptionHandler(IllegalArgumentException::class)
+ fun handleIllegalArgument(): ResponseEntity =
+ ResponseEntity.badRequest().body(
+ ApiError(
+ title = "Bad Request",
+ status = 400,
+ detail = "Некорректный параметр запроса",
+ ),
+ )
+
+ /** Нарушение уникальности/ссылок (гонки двойной отправки) — конфликт, а не 500. */
+ @ExceptionHandler(DataIntegrityViolationException::class)
+ fun handleDataIntegrity(): ResponseEntity =
+ ResponseEntity.status(409).body(
+ ApiError(
+ title = "Conflict",
+ status = 409,
+ detail = "Действие конфликтует с уже существующими данными",
+ ),
+ )
+
+ /** Превышение лимита загрузки — вместо 500 понятный 413. */
+ @ExceptionHandler(MaxUploadSizeExceededException::class)
+ fun handleMaxUpload(): ResponseEntity =
+ ResponseEntity.status(413).body(
+ ApiError(
+ title = "Payload Too Large",
+ status = 413,
+ detail = "Файл слишком большой",
+ ),
+ )
}
diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt
index cf8c28c..21a460c 100644
--- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt
+++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt
@@ -10,6 +10,7 @@ import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.zip.ZipInputStream
@@ -233,16 +234,45 @@ class ContentImporter(
private fun readZip(bytes: ByteArray): Map {
val result = mutableMapOf()
+ var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
- var entry = zis.nextEntry
- while (entry != null) {
- if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
- entry = zis.nextEntry
+ while (true) {
+ val entry = zis.nextEntry ?: break
+ if (entry.isDirectory) continue
+ val data = readBounded(zis, MAX_ENTRY_BYTES, "запись «${entry.name}»")
+ totalUncompressed += data.size
+ if (totalUncompressed > MAX_TOTAL_BYTES) {
+ throw BadRequestException(
+ "Архив слишком большой: после распаковки суммарно более ${MAX_TOTAL_BYTES / MB} МБ",
+ )
+ }
+ result[entry.name.removePrefix("/")] = data
}
}
return result
}
+ /** Читает запись архива с ограничением размера — защита от zip-бомб. */
+ private fun readBounded(
+ zis: ZipInputStream,
+ maxEntryBytes: Long,
+ what: String,
+ ): ByteArray {
+ val buffer = ByteArrayOutputStream()
+ val chunk = ByteArray(64 * 1024)
+ var total = 0L
+ while (true) {
+ val n = zis.read(chunk)
+ if (n < 0) break
+ total += n
+ if (total > maxEntryBytes) {
+ throw BadRequestException("$what распаковывается более чем в ${maxEntryBytes / MB} МБ")
+ }
+ buffer.write(chunk, 0, n)
+ }
+ return buffer.toByteArray()
+ }
+
private fun splitFrontMatter(content: String): Pair