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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,18 @@ class JwtAuthenticationFilter(
|
||||
): Mono<Void> {
|
||||
val path = exchange.request.path.value()
|
||||
if (isPublic(path)) {
|
||||
return chain.filter(exchange)
|
||||
// Клиентские X-Account-* не должны доходить до сервисов: эти заголовки ставит только gateway.
|
||||
val stripped = stripAccountHeaders(exchange)
|
||||
return chain.filter(stripped)
|
||||
}
|
||||
val token =
|
||||
bearerToken(exchange)
|
||||
?: return unauthorized(exchange, "Требуется вход")
|
||||
return try {
|
||||
val jwt = jwtDecoder.decode(token)
|
||||
val stripped = stripAccountHeaders(exchange)
|
||||
val request =
|
||||
exchange.request
|
||||
stripped.request
|
||||
.mutate()
|
||||
.header("X-Account-Id", jwt.subject.orEmpty())
|
||||
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
|
||||
@@ -48,6 +51,17 @@ class JwtAuthenticationFilter(
|
||||
|
||||
override fun getOrder(): Int = -100
|
||||
|
||||
private fun stripAccountHeaders(exchange: ServerWebExchange): ServerWebExchange =
|
||||
exchange
|
||||
.mutate()
|
||||
.request { req ->
|
||||
req.headers { headers ->
|
||||
headers.remove("X-Account-Id")
|
||||
headers.remove("X-Account-Login")
|
||||
headers.remove("X-Account-Role")
|
||||
}
|
||||
}.build()
|
||||
|
||||
private fun isPublic(path: String): Boolean =
|
||||
path.startsWith("/actuator") ||
|
||||
path == "/api/v1/auth/login" ||
|
||||
|
||||
@@ -27,6 +27,7 @@ class AuthService(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
private val backoff: LoginBackoff,
|
||||
) {
|
||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||
|
||||
@@ -51,14 +52,16 @@ class AuthService(
|
||||
return toResponse(account, account.login)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
/** Без @Transactional на методе: задержка backoff не должна держать JDBC-соединение. */
|
||||
fun login(request: LoginRequest): AuthResponse {
|
||||
val account =
|
||||
accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
val login = request.login.trim()
|
||||
backoff.delayBeforeAttempt(login)
|
||||
val account = accounts.findByLogin(login)
|
||||
if (account == null || !passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
backoff.recordFailure(login)
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
backoff.reset(login)
|
||||
return toResponse(account, displayName(account))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.ysu.lms.identity.auth
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.TooManyRequestsException
|
||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||
import ru.ysu.lms.identity.repo.LoginAttemptRepository
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Экспоненциальная задержка между неудачными попытками входа (1с, 2с, 4с…).
|
||||
* Задержка растёт по числу неудач, а не по времени ожидания ответа, поэтому
|
||||
* перебор пароля становится бессмысленно долгим, а легитимный пользователь,
|
||||
* знающий пароль, блокировок не видит. Счётчик живёт в БД (переживает рестарт)
|
||||
* и обнуляется по успеху либо по истечении окна [WINDOW].
|
||||
*/
|
||||
@Service
|
||||
class LoginBackoff(
|
||||
private val attempts: LoginAttemptRepository,
|
||||
) {
|
||||
/** Короткие задержки выдерживаем в потоке запроса; длиннее — сразу 429 с паузой для клиента. */
|
||||
fun delayBeforeAttempt(login: String) {
|
||||
val failures = effectiveFailures(login)
|
||||
if (failures <= 0) return
|
||||
val delaySec = delaySecondsFor(failures)
|
||||
if (delaySec > INLINE_SLEEP_SECONDS) {
|
||||
throw TooManyRequestsException(
|
||||
"Слишком много неудачных попыток входа. Повторите через $delaySec с.",
|
||||
)
|
||||
}
|
||||
Thread.sleep(delaySec * 1000)
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun recordFailure(login: String) {
|
||||
val now = Instant.now()
|
||||
val row = attempts.findById(login).orElse(null)
|
||||
if (row == null) {
|
||||
attempts.save(LoginAttempt(login = login, failures = 1, lastFailureAt = now))
|
||||
} else {
|
||||
row.failures += 1
|
||||
row.lastFailureAt = now
|
||||
attempts.save(row)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun reset(login: String) {
|
||||
attempts.findById(login).orElse(null)?.let(attempts::delete)
|
||||
}
|
||||
|
||||
/** Неудачи внутри окна [WINDOW]; устаревшую строку сразу гасим. */
|
||||
private fun effectiveFailures(login: String): Int {
|
||||
val row = attempts.findById(login).orElse(null) ?: return 0
|
||||
if (row.lastFailureAt.isBefore(Instant.now().minus(WINDOW))) {
|
||||
attempts.delete(row)
|
||||
return 0
|
||||
}
|
||||
return row.failures
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val WINDOW: Duration = Duration.ofMinutes(15)
|
||||
private const val MAX_DELAY_SECONDS = 60L
|
||||
private const val INLINE_SLEEP_SECONDS = 5L
|
||||
|
||||
/** 1, 2, 4, 8 … секунд, с потолком [MAX_DELAY_SECONDS]. */
|
||||
fun delaySecondsFor(failures: Int): Long = min(2.0.pow((failures - 1).coerceAtMost(6)), MAX_DELAY_SECONDS.toDouble()).toLong()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.Instant
|
||||
|
||||
/** Счётчик неудачных попыток входа для конкретного логина (см. LoginBackoff). */
|
||||
@Entity
|
||||
@Table(name = "login_attempts")
|
||||
class LoginAttempt(
|
||||
@Id
|
||||
@Column(nullable = false, length = 64)
|
||||
var login: String = "",
|
||||
@Column(nullable = false)
|
||||
var failures: Int = 0,
|
||||
@Column(name = "last_failure_at", nullable = false)
|
||||
var lastFailureAt: Instant = Instant.now(),
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||
|
||||
interface LoginAttemptRepository : JpaRepository<LoginAttempt, String>
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Счётчик неудачных входов для экспоненциальной задержки (защита от перебора паролей).
|
||||
|
||||
CREATE TABLE login_attempts (
|
||||
login varchar(64) PRIMARY KEY,
|
||||
failures integer NOT NULL DEFAULT 0,
|
||||
last_failure_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
package ru.ysu.lms.identity
|
||||
|
||||
import io.mockk.Runs
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
|
||||
import ru.ysu.lms.common.TooManyRequestsException
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.identity.auth.LoginBackoff
|
||||
import ru.ysu.lms.identity.auth.LoginRequest
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.LoginAttempt
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.LoginAttemptRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.Optional
|
||||
|
||||
class AuthServiceLoginBackoffTest {
|
||||
private val login = "2025001"
|
||||
private val account = Account(login = login, passwordHash = "hash", role = LmsRole.STUDENT)
|
||||
|
||||
/** Не relaxed: дженерик save у JpaRepository в relaxed-режиме ломает тип возвращаемого значения. */
|
||||
private fun attemptsRepo(): LoginAttemptRepository {
|
||||
val repo = mockk<LoginAttemptRepository>()
|
||||
every { repo.save(any<LoginAttempt>()) } answers { firstArg() }
|
||||
every { repo.delete(any<LoginAttempt>()) } just Runs
|
||||
return repo
|
||||
}
|
||||
|
||||
private fun service(
|
||||
accounts: AccountRepository,
|
||||
attempts: LoginAttemptRepository,
|
||||
matches: Boolean,
|
||||
): AuthService {
|
||||
val students = mockk<StudentRepository>()
|
||||
every { students.findById(account.id) } returns Optional.empty()
|
||||
val passwordEncoder = mockk<PasswordEncoder>()
|
||||
every { passwordEncoder.matches(any(), any()) } returns matches
|
||||
val jwtEncoder = mockk<JwtEncoder>()
|
||||
every { jwtEncoder.encode(any<JwtEncoderParameters>()) } returns
|
||||
mockk<Jwt> { every { tokenValue } returns "token" }
|
||||
val backoff = LoginBackoff(attempts)
|
||||
return AuthService(
|
||||
accounts,
|
||||
students,
|
||||
passwordEncoder,
|
||||
jwtEncoder,
|
||||
JwtProperties(accessTokenTtl = Duration.ofMinutes(10)),
|
||||
backoff,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed login records failure`() {
|
||||
val attempts = attemptsRepo()
|
||||
val accounts = mockk<AccountRepository>()
|
||||
every { accounts.findByLogin(login) } returns account
|
||||
every { attempts.findById(login) } returns Optional.empty()
|
||||
val svc = service(accounts, attempts, matches = false)
|
||||
|
||||
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
|
||||
.isInstanceOf(UnauthorizedException::class.java)
|
||||
|
||||
verify {
|
||||
attempts.save(
|
||||
match {
|
||||
it.login == login &&
|
||||
it.failures == 1 &&
|
||||
it.lastFailureAt.isAfter(Instant.now().minusSeconds(5))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `many failures rejected with 429 before password check`() {
|
||||
val attempts = attemptsRepo()
|
||||
val accounts = mockk<AccountRepository>()
|
||||
every { attempts.findById(login) } returns
|
||||
Optional.of(LoginAttempt(login = login, failures = 6, lastFailureAt = Instant.now()))
|
||||
val svc = service(accounts, attempts, matches = true)
|
||||
|
||||
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "secret")) }
|
||||
.isInstanceOf(TooManyRequestsException::class.java)
|
||||
.hasMessageContaining("32")
|
||||
verify(exactly = 0) { accounts.findByLogin(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful login resets counter`() {
|
||||
val attempts = attemptsRepo()
|
||||
val accounts = mockk<AccountRepository>()
|
||||
val stale = LoginAttempt(login = login, failures = 2, lastFailureAt = Instant.now())
|
||||
every { accounts.findByLogin(login) } returns account
|
||||
every { attempts.findById(login) } returnsMany
|
||||
listOf(Optional.empty(), Optional.of(stale))
|
||||
val svc = service(accounts, attempts, matches = true)
|
||||
|
||||
val response = svc.login(LoginRequest(login = login, password = "secret"))
|
||||
|
||||
assertThat(response.token).isEqualTo("token")
|
||||
verify { attempts.delete(stale) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backoff grows exponentially and caps at 60s`() {
|
||||
assertThat(LoginBackoff.delaySecondsFor(1)).isEqualTo(1)
|
||||
assertThat(LoginBackoff.delaySecondsFor(2)).isEqualTo(2)
|
||||
assertThat(LoginBackoff.delaySecondsFor(3)).isEqualTo(4)
|
||||
assertThat(LoginBackoff.delaySecondsFor(4)).isEqualTo(8)
|
||||
assertThat(LoginBackoff.delaySecondsFor(7)).isEqualTo(60)
|
||||
assertThat(LoginBackoff.delaySecondsFor(50)).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale failures outside window are ignored and cleared`() {
|
||||
val attempts = attemptsRepo()
|
||||
val accounts = mockk<AccountRepository>()
|
||||
val stale = LoginAttempt(login = login, failures = 9, lastFailureAt = Instant.now().minusSeconds(16 * 60))
|
||||
every { accounts.findByLogin(login) } returns account
|
||||
every { attempts.findById(login) } returns Optional.of(stale)
|
||||
val svc = service(accounts, attempts, matches = false)
|
||||
|
||||
// failures=9 должны были дать 429; из-за устаревания строки — обычная неудача входа.
|
||||
assertThatThrownBy { svc.login(LoginRequest(login = login, password = "wrong")) }
|
||||
.isInstanceOf(UnauthorizedException::class.java)
|
||||
verify { attempts.delete(stale) }
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,18 @@ package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.core.ParameterizedTypeReference
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.RestClient
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import java.net.http.HttpClient
|
||||
import java.time.Duration
|
||||
|
||||
@Service
|
||||
class AcademicClient(
|
||||
@Value("\${lms.academic.base-url}") baseUrl: String,
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
private val client = restClient(baseUrl)
|
||||
|
||||
fun getLab(
|
||||
labId: String,
|
||||
@@ -41,7 +44,7 @@ class AcademicClient(
|
||||
class IdentityClient(
|
||||
@Value("\${lms.identity.base-url}") baseUrl: String,
|
||||
) {
|
||||
private val client = RestClient.builder().baseUrl(baseUrl).build()
|
||||
private val client = restClient(baseUrl)
|
||||
|
||||
fun getGroup(
|
||||
groupId: String,
|
||||
@@ -65,3 +68,20 @@ class IdentityClient(
|
||||
.body(type) ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Таймауты обязательны: зависший downstream держит JDBC-соединение из пула и вешает сервис. */
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
|
||||
private fun restClient(baseUrl: String): RestClient =
|
||||
RestClient
|
||||
.builder()
|
||||
.baseUrl(baseUrl)
|
||||
.requestFactory(
|
||||
JdkClientHttpRequestFactory(
|
||||
HttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
||||
.build(),
|
||||
).apply { setReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS)) },
|
||||
).build()
|
||||
|
||||
@@ -102,7 +102,8 @@ class DefenseService(
|
||||
sessions.save(s)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
/** Не readOnly: внутри переносим истёкшие защиты в ready — изменения должны сохраниться. */
|
||||
@Transactional
|
||||
fun listForLab(labId: UUID): List<DefenseDto> {
|
||||
syncTimeouts()
|
||||
return sessions.findByLabIdOrderByStudentIdAsc(labId).map { toDto(it) }
|
||||
|
||||
@@ -180,3 +180,18 @@ data class PublicationSetRequest(
|
||||
val labId: String,
|
||||
val studentIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Агрегированные данные одной лабораторной по всем студентам группы (для страницы группы одним запросом). */
|
||||
data class LabDataResponse(
|
||||
val labId: String,
|
||||
val assignments: List<VariantAssignmentResponse> = emptyList(),
|
||||
val levels: List<LevelAssignmentResponse> = emptyList(),
|
||||
val submissions: List<SubmissionDto> = emptyList(),
|
||||
val defenses: List<DefenseDto> = emptyList(),
|
||||
val publication: PublicationState = PublicationState(),
|
||||
)
|
||||
|
||||
data class GroupLabDataResponse(
|
||||
val groupId: String,
|
||||
val labs: List<LabDataResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
class GroupLabDataController(
|
||||
private val service: GroupLabDataService,
|
||||
) {
|
||||
/** Данные всех студентов группы по выбранным лабораторным — только для админа. */
|
||||
@GetMapping("/api/v1/assignments/group/{groupId}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun groupLabData(
|
||||
@PathVariable groupId: UUID,
|
||||
@RequestParam("labId") labIds: List<UUID>,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): GroupLabDataResponse = service.labData(groupId, labIds, jwt.tokenValue)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Агрегатор данных страницы группы: назначения, уровни, последние сдачи,
|
||||
* защиты и состояние публикации по каждой лабораторной. Заменяет ~4N запросов
|
||||
* BFF к сервисам одним вызовом. Без своей транзакции: внутренние сервисы
|
||||
* управляют своими (внутри пишущий syncTimeouts — readOnly снаружи нельзя).
|
||||
*/
|
||||
@Service
|
||||
class GroupLabDataService(
|
||||
private val progress: ProgressService,
|
||||
private val submissions: SubmissionService,
|
||||
private val defenses: DefenseService,
|
||||
private val publications: GradePublicationService,
|
||||
) {
|
||||
fun labData(
|
||||
groupId: UUID,
|
||||
labIds: List<UUID>,
|
||||
token: String,
|
||||
): GroupLabDataResponse =
|
||||
GroupLabDataResponse(
|
||||
groupId = groupId.toString(),
|
||||
labs =
|
||||
labIds.map { labId ->
|
||||
LabDataResponse(
|
||||
labId = labId.toString(),
|
||||
assignments = progress.assignmentsForLabEnriched(labId, token),
|
||||
levels =
|
||||
progress.getLevelsForLab(labId).map { sll ->
|
||||
LevelAssignmentResponse(
|
||||
studentId = sll.studentId.toString(),
|
||||
labId = sll.labId.toString(),
|
||||
labLevelId = sll.labLevelId.toString(),
|
||||
manual = sll.manual,
|
||||
)
|
||||
},
|
||||
submissions = submissions.latestPerStudent(labId),
|
||||
defenses = defenses.listForLab(labId),
|
||||
publication = publications.state(groupId, labId),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.ysu.lms.progress
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
@@ -8,6 +9,7 @@ import ru.ysu.lms.common.ForbiddenException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
import ru.ysu.lms.progress.repo.SubmissionRepository
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
@@ -28,8 +30,22 @@ class SubmissionService(
|
||||
val payload = validatedPayload(files)
|
||||
ensureDeadline(requireLab(labId, token))
|
||||
val seq = nextSeq(studentId, labId)
|
||||
val stored = storage.store(studentId, labId, seq, payload)
|
||||
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
|
||||
// Сначала запись в БД: unique-констрейнт (student, lab, seq) гасит двойную отправку,
|
||||
// и проигравшая попытка не трогает файлы на диске (иначе перезаписала бы предыдущую сдачу).
|
||||
val entity =
|
||||
try {
|
||||
submissions.saveAndFlush(Submission(studentId = studentId, labId = labId, seq = seq))
|
||||
} catch (ignore: DataIntegrityViolationException) {
|
||||
throw BadRequestException("Эта попытка уже отправлена — обновите страницу и попробуйте ещё раз")
|
||||
}
|
||||
val stored =
|
||||
try {
|
||||
storage.store(studentId, labId, seq, payload)
|
||||
} catch (ignore: IOException) {
|
||||
// строка откатится вместе с транзакцией; убираем недописанные файлы
|
||||
runCatching { storage.deleteAttemptDir(studentId, labId, seq) }
|
||||
throw BadRequestException("Не удалось сохранить файлы решения, попробуйте ещё раз")
|
||||
}
|
||||
return toDto(entity, stored.files)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,32 @@ class SubmissionStorage(
|
||||
val names = mutableListOf<String>()
|
||||
for ((rawName, bytes) in files) {
|
||||
val safe = sanitize(rawName)
|
||||
Files.write(dir.resolve(safe), bytes)
|
||||
val target = dir.resolve(safe).normalize()
|
||||
require(target.startsWith(dir)) { "path escape blocked" }
|
||||
Files.write(target, bytes)
|
||||
names.add(safe)
|
||||
}
|
||||
return StoredSubmission(dir, names)
|
||||
}
|
||||
|
||||
/** Страховка после сбоя записи: недописанная попытка не должна оставлять мусор на диске. */
|
||||
fun deleteAttemptDir(
|
||||
studentId: UUID,
|
||||
labId: UUID,
|
||||
seq: Int,
|
||||
) {
|
||||
val dir = dirFor(studentId, labId, seq).normalize()
|
||||
require(dir.startsWith(rootDir) && dir != rootDir) { "path escape blocked" }
|
||||
if (!Files.exists(dir)) return
|
||||
Files
|
||||
.walk(dir)
|
||||
.use { walk ->
|
||||
walk
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach { p -> runCatching { Files.delete(p) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Собирает zip из сохранённых файлов сдачи, сохраняя реальное время изменения файлов. */
|
||||
fun zipOf(storedDir: Path): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
|
||||
@@ -79,4 +79,46 @@ class ProgressDistributionIntegrationTest {
|
||||
assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2)
|
||||
assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB)
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private lateinit var groupLabData: GroupLabDataService
|
||||
|
||||
@Test
|
||||
fun `group lab data aggregates per-lab data in one call`() {
|
||||
val labId = UUID.randomUUID()
|
||||
val student1 = UUID.randomUUID().toString()
|
||||
val student2 = UUID.randomUUID().toString()
|
||||
val variantA = UUID.randomUUID().toString()
|
||||
|
||||
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
|
||||
`when`(identity.getGroup("g1", "t")).thenReturn(
|
||||
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
|
||||
)
|
||||
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
|
||||
LabInfo(
|
||||
id = labId.toString(),
|
||||
levels =
|
||||
listOf(
|
||||
LevelInfo(
|
||||
id = "lvl",
|
||||
code = "basic",
|
||||
title = "Основная",
|
||||
variants = listOf(VariantInfo(id = variantA, number = "1")),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
service.distribute(DistributeRequest(labIds = listOf(labId.toString()), groupIds = listOf("g1")), "t")
|
||||
|
||||
val data = groupLabData.labData(UUID.randomUUID(), listOf(labId), "t")
|
||||
|
||||
assertThat(data.labs).hasSize(1)
|
||||
val lab = data.labs.first()
|
||||
assertThat(lab.labId).isEqualTo(labId.toString())
|
||||
assertThat(lab.assignments).hasSize(2)
|
||||
assertThat(lab.assignments.all { it.variantNumber == "1" }).isTrue()
|
||||
assertThat(lab.defenses).isEmpty()
|
||||
assertThat(lab.submissions).isEmpty()
|
||||
assertThat(lab.publication.all).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.progress.domain.Submission
|
||||
@@ -27,7 +28,7 @@ class SubmissionServiceTest {
|
||||
var saved: Submission? = null
|
||||
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
|
||||
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
|
||||
every { repo.save(any()) } answers {
|
||||
every { repo.saveAndFlush(any()) } answers {
|
||||
firstArg<Submission>().also { saved = it }
|
||||
}
|
||||
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
|
||||
@@ -73,6 +74,17 @@ class SubmissionServiceTest {
|
||||
assertThat(dto.files).containsExactly("project.zip")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate concurrent attempt is rejected as bad request`() {
|
||||
val (service, repo) = service()
|
||||
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
|
||||
every { repo.saveAndFlush(any()) } throws DataIntegrityViolationException("duplicate key")
|
||||
|
||||
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
|
||||
.isInstanceOf(BadRequestException::class.java)
|
||||
.hasMessageContaining("уже отправлена")
|
||||
}
|
||||
|
||||
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
|
||||
val result = linkedMapOf<String, ByteArray>()
|
||||
ZipInputStream(bytes.inputStream()).use { zin ->
|
||||
|
||||
Reference in New Issue
Block a user