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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user