Redesign B3 bordeaux + finish lab-page rework

- Lab page is now read-only with a settings screen for levels/variants;
  variant issuance moved to a large modal on the group page (assign-fragment).
- Header: bordeaux logo and 2px accent underline, bordeaux login/logout/theme
  buttons; smaller radii across the UI; filled secondary/danger buttons.
- Includes prior agent work (login backoff, no-cache pages, API timeouts,
  content tweaks) and fixes (variant usage Map.values, import order).
- Bump static asset version to v=9.
This commit is contained in:
2026-10-02 15:41:16 +03:00
parent 7a781fea33
commit 556fdf3b5e
67 changed files with 3019 additions and 653 deletions

View File

@@ -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<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
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<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
@@ -285,4 +315,10 @@ class ContentImporter(
val seenSections: MutableSet<UUID> = mutableSetOf()
val seenArticles: MutableSet<UUID> = mutableSetOf()
}
private companion object {
const val MB = 1024L * 1024L
const val MAX_ENTRY_BYTES = 128L * MB
const val MAX_TOTAL_BYTES = 256L * MB
}
}

View File

@@ -1,5 +1,6 @@
package ru.ysu.lms.academic.controller
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@@ -11,8 +12,10 @@ import ru.ysu.lms.academic.content.ReorderSections
import ru.ysu.lms.academic.content.ReorderTree
import java.util.UUID
/** Реордер материалов: меняет публичный порядок глав/разделов/статей — только админ. */
@RestController
@RequestMapping("/api/v1")
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentOrderController(
private val order: ContentOrderService,
) {

View File

@@ -60,12 +60,19 @@ class ContentPublicController(
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity
.ok()
.contentType(media)
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
val builder =
ResponseEntity
.ok()
.contentType(media)
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
)
// SVG может содержать скрипты: при открытии напрямую как документа запрещаем их исполнение.
if (media.isCompatibleWith(MediaType.parseMediaType("image/svg+xml"))) {
builder.header("Content-Security-Policy", "script-src 'none'; object-src 'none'")
}
builder.header("X-Content-Type-Options", "nosniff")
return builder.body(FileSystemResource(path))
}
}

View File

@@ -7,6 +7,7 @@ import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.ZipInputStream
/**
@@ -55,16 +56,45 @@ class LabImportParser {
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>()
var totalUncompressed = 0L
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) entries[entry.name] = 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} МБ",
)
}
entries[entry.name] = data
}
}
return entries
}
/** Читает запись архива с ограничением размера — защита от 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 readMeta(entries: Map<String, ByteArray>): LabMeta {
val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
@@ -146,4 +176,10 @@ class LabImportParser {
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
private companion object {
const val MB = 1024L * 1024L
const val MAX_ENTRY_BYTES = 128L * MB
const val MAX_TOTAL_BYTES = 256L * MB
}
}