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 c0cf11a5a3
commit 51d1b84297
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
}
}

View File

@@ -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" ||

View File

@@ -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))
}

View File

@@ -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()
}
}

View File

@@ -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(),
)

View File

@@ -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>

View File

@@ -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()
);

View File

@@ -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) }
}
}

View File

@@ -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()

View File

@@ -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) }

View File

@@ -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(),
)

View File

@@ -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)
}

View File

@@ -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),
)
},
)
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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 ->