Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 9b7748571e
119 changed files with 4671 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Copy to .env and adjust for your environment.
LMS_DB_NAME=lms
LMS_DB_USER=lms
LMS_DB_PASSWORD=lms
LMS_DB_PORT=5432
# JDBC connection string shared by the services (schema is selected per service).
LMS_DB_URL=jdbc:postgresql://localhost:5432/lms

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
### LMS ###
.env
/data/
node_modules/
# Reference implementation, kept as a separate nested repository.
oldLMS/
!**/src/main/**/build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Kotlin ###
.kotlin
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# LMS
Микросервисная система для проведения практических занятий (Kotlin + Spring Boot + PostgreSQL 18).
## Модули
- `libs/common-http` — общий формат ошибок и обработчик исключений.
- `libs/security-jwt` — выпуск/проверка JWT (RSA) и `PasswordEncoder`.
- `libs/markdown` — серверный рендер markdown со спойлерами и санитизацией.
- `libs/test-support` — общая тестовая инфраструктура (Testcontainers, `postgres:18`).
- `services/identity-service` — аккаунты, студенты, группы, зачисления (схема `identity`).
- `services/academic-service` — курсы, статьи, лабораторные, уровни, варианты (схема `academic`).
- `services/progress-service` — уровни студентов, распределение вариантов, работы, оценки (схема `progress`).
- `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT.
- `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF.
## Требования
- JDK 21+ (сборка использует toolchain 21)
- Docker (для PostgreSQL 18)
## Запуск
```bash
cp .env.example .env
docker compose up -d postgres
./gradlew :services:identity-service:bootRun
./gradlew :services:academic-service:bootRun
./gradlew :services:progress-service:bootRun
./gradlew :services:gateway:bootRun
./gradlew :web:bootRun
```
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
progress `:8083`, web `:8090`. Проверка: `GET :8080/actuator/health`.
## Текущий функционал
Открыть `http://localhost:8090`. При первом запуске будет предложено создать
аккаунт администратора (`/setup`); далее — вход по логину и паролю.
- вход администратора и студентов, обязательная смена пароля при первом входе;
- группы: создание, импорт студентов из `.xls/.xlsx/.csv`, выгрузка логинов и
паролей в XLSX/CSV, сброс пароля студента;
- JWT (RSA, ключи генерируются при первом старте identity и не хранятся в репозитории),
проверка токена на gateway, роли ADMIN/STUDENT.
## Сборка и тесты
```bash
./gradlew build # сборка всех модулей
./gradlew check # тесты и проверки
```
Переменные окружения для БД: `LMS_DB_URL`, `LMS_DB_USER`, `LMS_DB_PASSWORD`.

16
buildSrc/build.gradle.kts Normal file
View File

@@ -0,0 +1,16 @@
plugins {
// The Kotlin DSL plugin provides a convenient way to develop convention plugins.
`kotlin-dsl`
}
kotlin {
jvmToolchain(21)
}
dependencies {
// Plugins applied by the convention plugins in `src/main/kotlin`.
implementation(libs.kotlinGradlePlugin)
implementation(libs.kotlinAllopenPlugin)
implementation(libs.kotlinNoargPlugin)
implementation(libs.springBootGradlePlugin)
}

View File

@@ -0,0 +1,17 @@
dependencyResolutionManagement {
// Use Maven Central and the Gradle Plugin Portal for resolving dependencies in the shared build logic (`buildSrc`) project.
@Suppress("UnstableApiUsage")
repositories {
mavenCentral()
}
// Reuse the version catalog from the main build.
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "buildSrc"

View File

@@ -0,0 +1,31 @@
// Spring Boot application module: Kotlin/JVM + Spring/JPA plugins + Boot plugin.
import org.gradle.api.tasks.testing.logging.TestLogEvent
plugins {
kotlin("jvm")
id("org.jetbrains.kotlin.plugin.spring")
id("org.jetbrains.kotlin.plugin.jpa")
id("org.springframework.boot")
}
group = "ru.ysu.lms"
version = "0.1.0-SNAPSHOT"
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
}
}
dependencies {
// Required by Spring for Kotlin reflection (Spring Data, @ConfigurationProperties binding).
"implementation"(kotlin("reflect"))
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED)
}
}

View File

@@ -0,0 +1,24 @@
// Shared Kotlin library module: Kotlin/JVM + Spring (all-open) compiler plugin.
import org.gradle.api.tasks.testing.logging.TestLogEvent
plugins {
kotlin("jvm")
id("org.jetbrains.kotlin.plugin.spring")
}
group = "ru.ysu.lms"
version = "0.1.0-SNAPSHOT"
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.add("-Xjsr305=strict")
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED)
}
}

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
# Local development infrastructure. Start PostgreSQL 18 with: docker compose up -d postgres
services:
postgres:
image: postgres:18
container_name: lms-postgres
environment:
POSTGRES_DB: ${LMS_DB_NAME:-lms}
POSTGRES_USER: ${LMS_DB_USER:-lms}
POSTGRES_PASSWORD: ${LMS_DB_PASSWORD:-lms}
ports:
- "${LMS_DB_PORT:-5432}:5432"
volumes:
# PostgreSQL 18+ stores data under a versioned subdirectory of /var/lib/postgresql.
- lms_pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${LMS_DB_USER:-lms} -d ${LMS_DB_NAME:-lms}"]
interval: 5s
timeout: 3s
retries: 10
volumes:
lms_pgdata:

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
# Enable the build cache to save time by reusing outputs produced by other successful builds.
# https://docs.gradle.org/current/userguide/build_cache.html
org.gradle.caching=true
# Enable the configuration cache to reuse the build configuration and enable parallel task execution.
# (Note that some plugins may not yet be compatible with the configuration cache.)
# https://docs.gradle.org/current/userguide/configuration_cache.html
org.gradle.configuration-cache=true

80
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,80 @@
# Version catalog: central place for dependency versions.
# https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog
[versions]
# Spring Boot 4.0.x is the release train matched by Spring Cloud 2025.1.x.
springBoot = "4.0.8"
springCloud = "2025.1.3"
kotlin = "2.4.20"
springdoc = "3.0.3"
flexmark = "0.64.8"
owaspSanitizer = "20260313.1"
apachePoi = "5.5.1"
[libraries]
# --- Gradle plugins used from buildSrc ---
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" }
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" }
# --- Platform BOMs (versions only) ---
springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }
springCloudBom = { module = "org.springframework.cloud:spring-cloud-dependencies", version.ref = "springCloud" }
# --- Spring Boot starters (versions managed by the Boot BOM) ---
springBootStarterWeb = { module = "org.springframework.boot:spring-boot-starter-web" }
springBootStarterWebflux = { module = "org.springframework.boot:spring-boot-starter-webflux" }
springBootStarterValidation = { module = "org.springframework.boot:spring-boot-starter-validation" }
springBootStarterActuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
springBootStarterDataJpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
springBootStarterFlyway = { module = "org.springframework.boot:spring-boot-starter-flyway" }
springBootStarterSecurity = { module = "org.springframework.boot:spring-boot-starter-security" }
springBootStarterOauth2ResourceServer = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
springBootStarterThymeleaf = { module = "org.springframework.boot:spring-boot-starter-thymeleaf" }
springBootStarterTest = { module = "org.springframework.boot:spring-boot-starter-test" }
springBootDevtools = { module = "org.springframework.boot:spring-boot-devtools" }
springSecurityTest = { module = "org.springframework.security:spring-security-test" }
# --- Spring Cloud ---
springCloudStarterGatewayWebflux = { module = "org.springframework.cloud:spring-cloud-starter-gateway-server-webflux" }
# --- Plain Spring libraries (for shared modules) ---
springWeb = { module = "org.springframework:spring-web" }
springContext = { module = "org.springframework:spring-context" }
springBootAutoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure" }
springSecurityConfig = { module = "org.springframework.security:spring-security-config" }
springSecurityWeb = { module = "org.springframework.security:spring-security-web" }
springSecurityOauth2Jose = { module = "org.springframework.security:spring-security-oauth2-jose" }
# --- Persistence / migrations ---
flywayCore = { module = "org.flywaydb:flyway-core" }
flywayPostgresql = { module = "org.flywaydb:flyway-database-postgresql" }
postgresql = { module = "org.postgresql:postgresql" }
jacksonYaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" }
jacksonModuleKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
# --- API docs ---
springdocStarterWebmvcUi = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }
# --- Markdown rendering ---
flexmarkAll = { module = "com.vladsch.flexmark:flexmark-all", version.ref = "flexmark" }
owaspHtmlSanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer", version.ref = "owaspSanitizer" }
# --- Spreadsheet import/export ---
apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" }
# --- Tests / Testcontainers (versions managed by the Boot BOM) ---
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
[bundles]
springWeb = ["springBootStarterWeb", "springBootStarterValidation", "springBootStarterActuator"]
springPersistence = ["springBootStarterDataJpa", "flywayCore", "flywayPostgresql", "postgresql"]
[plugins]
kotlinPluginJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Thu Sep 10 14:24:06 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored Executable file
View File

@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,10 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
api(libs.springWeb)
api(libs.springBootAutoconfigure)
}

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.common
import java.time.Instant
/**
* Uniform error payload, close to RFC 9457 "Problem Details for HTTP APIs".
*/
data class ApiError(
val type: String = "about:blank",
val title: String,
val status: Int,
val detail: String? = null,
val code: String? = null,
val violations: List<FieldViolation> = emptyList(),
val timestamp: Instant = Instant.now(),
)
data class FieldViolation(
val field: String,
val message: String,
)

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.common
import java.security.SecureRandom
import java.util.UUID
/**
* Time-ordered UUIDv7 identifiers (the same layout PostgreSQL 18 provides with
* `uuidv7()`), so application-generated and database-generated ids match.
*/
object Ids {
private val random = SecureRandom()
fun v7(): UUID {
val timestamp = System.currentTimeMillis() and 0x0000_FFFF_FFFF_FFFFL
val randA = (random.nextInt() and 0x0FFF).toLong()
val randB = random.nextLong() and 0x3FFF_FFFF_FFFF_FFFFL
val msb = (timestamp shl 16) or 0x7000L or randA
val lsb = Long.MIN_VALUE or randB
return UUID(msb, lsb)
}
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.common
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean
@AutoConfiguration
class LmsCommonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
}

View File

@@ -0,0 +1,29 @@
package ru.ysu.lms.common
import org.springframework.http.HttpStatus
/**
* Base class for expected, domain-level failures. Mapped to [ApiError] by
* [LmsExceptionHandler].
*/
open class LmsException(
val status: HttpStatus,
override val message: String,
val code: String? = null,
val violations: List<FieldViolation> = emptyList(),
) : RuntimeException(message)
class BadRequestException(message: String, code: String? = null) :
LmsException(HttpStatus.BAD_REQUEST, message, code)
class NotFoundException(message: String, code: String? = null) :
LmsException(HttpStatus.NOT_FOUND, message, code)
class ConflictException(message: String, code: String? = null) :
LmsException(HttpStatus.CONFLICT, message, code)
class ForbiddenException(message: String, code: String? = null) :
LmsException(HttpStatus.FORBIDDEN, message, code)
class UnauthorizedException(message: String, code: String? = null) :
LmsException(HttpStatus.UNAUTHORIZED, message, code)

View File

@@ -0,0 +1,37 @@
package ru.ysu.lms.common
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
@RestControllerAdvice
class LmsExceptionHandler {
@ExceptionHandler(LmsException::class)
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
ResponseEntity.status(ex.status).body(
ApiError(
title = ex.status.reasonPhrase,
status = ex.status.value(),
detail = ex.message,
code = ex.code,
violations = ex.violations,
),
)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
val violations = ex.bindingResult.fieldErrors.map {
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
return ResponseEntity.badRequest().body(
ApiError(
title = "Validation failed",
status = 400,
detail = "Request validation failed",
violations = violations,
),
)
}
}

View File

@@ -0,0 +1 @@
ru.ysu.lms.common.LmsCommonAutoConfiguration

View File

@@ -0,0 +1,12 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
api(libs.flexmarkAll)
api(libs.owaspHtmlSanitizer)
api(libs.springContext)
api(libs.springBootAutoconfigure)
}

View File

@@ -0,0 +1,16 @@
package ru.ysu.lms.markdown
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
@AutoConfiguration
@EnableConfigurationProperties(MarkdownProperties::class)
class LmsMarkdownAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
MarkdownRenderer(allowHtml = properties.allowHtml)
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.markdown
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "lms.markdown")
data class MarkdownProperties(
/** When true, raw HTML in the source markdown is passed through (still sanitized). */
val allowHtml: Boolean = false,
)

View File

@@ -0,0 +1,113 @@
package ru.ysu.lms.markdown
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.footnotes.FootnoteExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.ext.tables.TablesExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import org.owasp.html.HtmlPolicyBuilder
import org.owasp.html.PolicyFactory
/**
* Renders LMS markdown to sanitized HTML.
*
* Supported beyond CommonMark: tables, strikethrough, task lists, footnotes,
* autolinks and the custom spoiler container:
*
* ```
* :::spoiler Заголовок
* скрытый текст (тоже markdown)
* :::
* ```
*
* The result is always passed through an allow-list HTML sanitizer, so authors
* cannot inject scripts or arbitrary markup.
*/
class MarkdownRenderer(
private val allowHtml: Boolean = false,
) {
private val options = MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.SOFT_BREAK, "<br />\n")
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
private val sanitizer: PolicyFactory = buildSanitizer()
fun render(markdown: String): String = sanitizer.sanitize(renderRaw(markdown, depth = 0))
private fun renderRaw(markdown: String, depth: Int): String {
val out = StringBuilder()
var cursor = 0
for (match in SPOILER.findAll(markdown)) {
out.append(renderSegment(markdown.substring(cursor, match.range.first)))
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
val body = match.groupValues[2]
val inner = if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1)
} else {
renderSegment(body)
}
out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title))
out.append("</summary><div class=\"spoiler-body\">")
out.append(inner)
out.append("</div></details>")
cursor = match.range.last + 1
}
out.append(renderSegment(markdown.substring(cursor)))
return out.toString()
}
private fun renderSegment(markdown: String): String =
renderer.render(parser.parse(markdown))
private fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
.allowElements(
"p", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "del", "s", "blockquote",
"ul", "ol", "li",
"a", "img",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
)
.allowAttributes("href").onElements("a")
.allowAttributes("title").onElements("a", "img")
.allowAttributes("src", "alt").onElements("img")
.allowAttributes("class").onElements("code", "pre", "div", "span", "details", "summary", "table", "th", "td")
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
val SPOILER = Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
}
}

View File

@@ -0,0 +1 @@
ru.ysu.lms.markdown.LmsMarkdownAutoConfiguration

View File

@@ -0,0 +1,11 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
api(libs.springContext)
api(libs.springBootAutoconfigure)
api(libs.springSecurityOauth2Jose)
}

View File

@@ -0,0 +1,15 @@
package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
STUDENT;
fun authority(): String = "ROLE_$name"
}
data class JwtPrincipal(
val accountId: String,
val login: String,
val role: LmsRole,
val displayName: String? = null,
)

View File

@@ -0,0 +1,16 @@
package ru.ysu.lms.security
import org.springframework.boot.context.properties.ConfigurationProperties
import java.time.Duration
@ConfigurationProperties(prefix = "lms.jwt")
data class JwtProperties(
val issuer: String = "lms",
/** PKCS#8 PEM private key. Empty disables the encoder. */
val privateKey: String = "",
/** X.509 PEM public key. Empty disables the decoder. */
val publicKey: String = "",
/** JWKS endpoint used to build the decoder when [publicKey] is empty. */
val jwkSetUri: String = "",
val accessTokenTtl: Duration = Duration.ofHours(12),
)

View File

@@ -0,0 +1,48 @@
package ru.ysu.lms.security
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.proc.SecurityContext
import org.springframework.boot.autoconfigure.AutoConfiguration
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
@AutoConfiguration
@EnableConfigurationProperties(JwtProperties::class)
class LmsSecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
}
@Bean
@ConditionalOnMissingBean
fun jwtDecoder(properties: JwtProperties): JwtDecoder =
if (properties.jwkSetUri.isNotBlank()) {
NimbusJwtDecoder.withJwkSetUri(properties.jwkSetUri).build()
} else {
require(properties.publicKey.isNotBlank()) {
"Configure either lms.jwt.jwk-set-uri or lms.jwt.public-key"
}
NimbusJwtDecoder.withPublicKey(RsaKeys.publicKey(properties.publicKey)).build()
}
@Bean
@ConditionalOnMissingBean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.security
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
object RsaKeys {
fun publicKey(pem: String): RSAPublicKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
}
fun privateKey(pem: String): RSAPrivateKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
}
private fun decode(pem: String): ByteArray {
val body = pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
return Base64.getDecoder().decode(body)
}
}

View File

@@ -0,0 +1 @@
ru.ysu.lms.security.LmsSecurityAutoConfiguration

View File

@@ -0,0 +1,13 @@
plugins {
id("lms.spring-library")
}
dependencies {
implementation(platform(libs.springBootBom))
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
api(libs.springBootTestcontainers)
api(libs.testcontainersPostgresql)
api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest)
}

View File

@@ -0,0 +1,8 @@
package ru.ysu.lms.test
object DatabaseTestSupport {
const val POSTGRES_IMAGE: String = "postgres:18"
const val SCHEMA_IDENTITY: String = "identity"
const val SCHEMA_ACADEMIC: String = "academic"
const val SCHEMA_PROGRESS: String = "progress"
}

View File

@@ -0,0 +1,30 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
implementation(project(":libs:markdown"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.academic.AcademicServiceApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.academic
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class AcademicServiceApplication
fun main(args: Array<String>) {
runApplication<AcademicServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) {
labService.delete(id)
}
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "labs")
class Lab(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false)
var number: Int = 0,
@Column(nullable = false, length = 200)
var title: String = "",
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null,
@Column(name = "description_md", nullable = false)
var descriptionMd: String = "",
@Column(name = "deadline_at")
var deadlineAt: Instant? = null,
@Column(name = "defense_at")
var defenseAt: Instant? = null,
@Column(name = "points_max", nullable = false, columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal = java.math.BigDecimal.valueOf(5.0),
@Column(nullable = false, length = 16)
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "lab_levels")
class LabLevel(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@Column(nullable = false, length = 32)
var code: String = "",
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var description: String = "",
@Column(nullable = false)
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "semesters")
class Semester(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "variants")
class Variant(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_level_id", nullable = false)
var labLevel: LabLevel? = null,
@Column(nullable = false, length = 32)
var number: String = "",
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "statement_md", nullable = false)
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.academic.lab
import java.math.BigDecimal
data class LabMeta(
val number: Int,
val title: String,
val pointsMax: BigDecimal = BigDecimal.valueOf(5.0),
val deadlineAt: String? = null,
val defenseAt: String? = null,
val status: String = "draft",
val levels: List<LabLevelMeta> = emptyList(),
)
data class LabLevelMeta(
val code: String,
val title: String,
val description: String = "",
val position: Int = 0,
val pointsMax: BigDecimal? = null,
)
data class VariantMeta(
val level: String,
val number: String,
val title: String = "",
val statement: String = "",
)
data class LabImportBundle(
val meta: LabMeta,
val descriptionMd: String,
val variants: List<VariantMeta>,
val assets: Map<String, ByteArray> = emptyMap(),
)
data class LabImportResult(
val labId: String,
val number: Int,
val title: String,
val levelCount: Int,
val variantCount: Int,
)
data class LabSummary(
val id: String,
val number: Int,
val title: String,
val status: String,
val semesterTitle: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val variantCount: Int,
)
data class LabDetail(
val id: String,
val number: Int,
val title: String,
val status: String,
val descriptionMd: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>,
)
data class LabLevelDetail(
val code: String,
val title: String,
val variants: List<VariantDetail>,
)
data class VariantDetail(
val id: String,
val number: String,
val title: String,
val statementMd: String,
)

View File

@@ -0,0 +1,165 @@
package ru.ysu.lms.academic.lab
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.math.BigDecimal
import java.util.zip.ZipInputStream
@Component
class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val json = ObjectMapper()
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
val lower = filename.lowercase()
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
}
return if (lower.endsWith(".json")) {
parseJson(bytes)
} else {
parseZip(bytes)
}
}
private fun parseJson(bytes: ByteArray): LabImportBundle {
val tree = json.readTree(bytes)
val meta = LabMeta(
number = tree.path("number").asInt(),
title = tree.path("title").asText(),
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
status = tree.path("status").asText("published"),
levels = parseLevels(tree),
)
val descriptionMd = tree.path("common_requirements").asText("")
val variants = parseVariantsFromTree(tree, meta.levels)
return LabImportBundle(meta, descriptionMd, variants)
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
entries[entry.name] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
val levels = tree.path("levels")
if (levels.isArray && levels.size() > 0) {
return levels.map { n ->
LabLevelMeta(
code = n.path("code").asText(),
title = n.path("title").asText(""),
description = n.path("description").asText(""),
position = n.path("position").asInt(0),
)
}
}
return listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
val variants = tree.path("variants")
if (!variants.isArray) return emptyList()
val levelMap = levels.associateBy { it.code }
return variants.map { v ->
val levelCode = normalizeLevel(v.path("level").asText("basic"))
if (!levelMap.containsKey(levelCode)) {
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
}
VariantMeta(
level = levelCode,
number = v.path("number").asText(),
title = v.path("title").asText(""),
statement = v.path("statement").asText(""),
)
}
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" }
if (firstSep != 0) {
return VariantMeta(
level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"),
title = "",
statement = content.trim(),
)
}
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
if (secondSep < 0) return null
val fmLines = lines.subList(1, secondSep)
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = parts[1].trim()
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"] ?: return null,
title = fm["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -0,0 +1,137 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException
import java.math.BigDecimal
import java.util.UUID
@Service
class LabService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
))
val levelMetas = bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
val levelEntities = levelMetas.map { meta ->
labLevels.save(LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description,
position = meta.position,
pointsMax = meta.pointsMax,
))
}
val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
))
}
return LabImportResult(
labId = lab.id.toString(),
number = lab.number,
title = lab.title,
levelCount = levelEntities.size,
variantCount = bundle.variants.size,
)
}
@Transactional(readOnly = true)
fun list(): List<LabSummary> =
labs.findAllByOrderByNumberAsc().map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
variantCount = variants.size,
)
}
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
LabLevelDetail(
code = level.code,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
throw NotFoundException("Лабораторная не найдена")
}
labs.deleteById(labId)
}
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Lab
import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Semester
import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}

View File

@@ -0,0 +1,60 @@
package ru.ysu.lms.academic.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder =
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
}
return converter
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8082}
spring:
application:
name: academic-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: academic
flyway:
enabled: true
locations: classpath:db/migration
schemas: academic
default-schema: academic
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline for the academic schema (courses, articles, labs, levels, variants).
CREATE SCHEMA IF NOT EXISTS academic;

View File

@@ -0,0 +1,88 @@
-- Courses, articles, semesters, labs, levels, variants for the academic service.
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE articles (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title varchar(200) NOT NULL,
slug varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
body_md text NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'draft',
position integer NOT NULL DEFAULT 0,
published_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT articles_course_slug_unique UNIQUE (course_id, slug)
);
CREATE TABLE article_assets (
id uuid PRIMARY KEY DEFAULT uuidv7(),
article_id uuid REFERENCES articles(id) ON DELETE CASCADE,
filename varchar(255) NOT NULL,
stored_path varchar(512) NOT NULL,
mime varchar(128) NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE semesters (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(100) NOT NULL,
year integer NOT NULL,
active boolean NOT NULL DEFAULT false
);
CREATE TABLE labs (
id uuid PRIMARY KEY DEFAULT uuidv7(),
number integer NOT NULL,
title varchar(200) NOT NULL,
semester_id uuid NOT NULL REFERENCES semesters(id) ON DELETE RESTRICT,
description_md text NOT NULL DEFAULT '',
deadline_at timestamptz,
defense_at timestamptz,
points_max numeric(4,1) NOT NULL DEFAULT 5,
status varchar(16) NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT labs_number_semester_unique UNIQUE (number, semester_id)
);
CREATE TABLE lab_levels (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
code varchar(32) NOT NULL,
title varchar(100) NOT NULL,
description text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
points_max numeric(4,1),
CONSTRAINT lab_levels_lab_code_unique UNIQUE (lab_id, code)
);
CREATE TABLE variants (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
lab_level_id uuid NOT NULL REFERENCES lab_levels(id) ON DELETE CASCADE,
number varchar(32) NOT NULL,
title varchar(200) NOT NULL DEFAULT '',
statement_md text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
CONSTRAINT variants_lab_level_number_unique UNIQUE (lab_id, lab_level_id, number)
);
CREATE TABLE variant_examples (
id uuid PRIMARY KEY DEFAULT uuidv7(),
variant_id uuid NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
input text NOT NULL DEFAULT '',
output text NOT NULL DEFAULT '',
explanation text NOT NULL DEFAULT ''
);

View File

@@ -0,0 +1,4 @@
-- Seed data: default semester for development.
INSERT INTO semesters (id, title, year, active)
VALUES ('00000000-0000-7000-8000-000000000001', 'осень', 2026, true)
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,19 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(platform(libs.springCloudBom))
implementation(libs.springCloudStarterGatewayWebflux)
implementation(libs.springBootStarterActuator)
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.gateway.GatewayApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.gateway
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class GatewayApplication
fun main(args: Array<String>) {
runApplication<GatewayApplication>(*args)
}

View File

@@ -0,0 +1,66 @@
package ru.ysu.lms.gateway.security
import org.springframework.cloud.gateway.filter.GatewayFilterChain
import org.springframework.cloud.gateway.filter.GlobalFilter
import org.springframework.core.Ordered
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.JwtException
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
/**
* Validates the bearer token on every routed request and forwards the resolved
* identity to the downstream services as `X-Account-*` headers.
*/
@Component
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
val path = exchange.request.path.value()
if (isPublic(path)) {
return chain.filter(exchange)
}
val token = bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
return try {
val jwt = jwtDecoder.decode(token)
val request = exchange.request.mutate()
.header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
.build()
chain.filter(exchange.mutate().request(request).build())
} catch (_: JwtException) {
unauthorized(exchange, "Недействительный или истёкший токен")
}
}
override fun getOrder(): Int = -100
private fun isPublic(path: String): Boolean =
path.startsWith("/actuator") ||
path == "/api/v1/auth/login" ||
path == "/api/v1/auth/setup" ||
path == "/api/v1/auth/health" ||
path == "/api/v1/auth/jwks.json"
private fun bearerToken(exchange: ServerWebExchange): String? {
val header = exchange.request.headers.getFirst("Authorization") ?: return null
if (!header.startsWith("Bearer ")) {
return null
}
return header.removePrefix("Bearer ").trim().ifBlank { null }
}
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
val response = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.contentType = MediaType.APPLICATION_JSON
val body = """{"status":401,"title":"Unauthorized","detail":"$message"}"""
val buffer = response.bufferFactory().wrap(body.toByteArray(Charsets.UTF_8))
return response.writeWith(Mono.just(buffer))
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8080}
spring:
application:
name: gateway
cloud:
gateway:
server:
webflux:
routes:
- id: identity
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
predicates:
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**
- id: academic
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
predicates:
- Path=/api/v1/courses/**,/api/v1/articles/**,/api/v1/labs/**,/api/v1/assets/**
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assignments/**,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute/**
management:
endpoints:
web:
exposure:
include: health,info,gateway
endpoint:
health:
probes:
enabled: true
lms:
jwt:
jwk-set-uri: ${LMS_JWT_JWK_SET_URI:http://localhost:8081/api/v1/auth/jwks.json}

View File

@@ -0,0 +1,31 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.springdocStarterWebmvcUi)
implementation(libs.apachePoiOoxml)
implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
testImplementation(libs.springBootStarterTest)
testImplementation(libs.springSecurityTest)
}
springBoot {
mainClass.set("ru.ysu.lms.identity.IdentityServiceApplicationKt")
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.identity
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
// The identity service manages its own JWT keys at runtime, so the generic
// auto-configuration from the shared library is excluded.
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
class IdentityServiceApplication
fun main(args: Array<String>) {
runApplication<IdentityServiceApplication>(*args)
}

View File

@@ -0,0 +1,50 @@
package ru.ysu.lms.identity.auth
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import jakarta.validation.Valid
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authService: AuthService,
private val rsaKey: RSAKey,
) {
@GetMapping("/health")
fun health(): Map<String, Any> = mapOf(
"ok" to true,
"service" to "identity-service",
"adminExists" to authService.adminExists(),
)
@PostMapping("/setup")
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
@PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
@GetMapping("/me")
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
authService.me(UUID.fromString(jwt.subject))
@PostMapping("/change-password")
fun changePassword(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody body: ChangePasswordRequest,
): Map<String, Any> {
authService.changePassword(UUID.fromString(jwt.subject), body)
return mapOf("ok" to true)
}
@GetMapping("/jwks.json")
fun jwks(): Map<String, Any> = JWKSet(rsaKey).toPublicJWKSet().toJSONObject()
}

View File

@@ -0,0 +1,45 @@
package ru.ysu.lms.identity.auth
import jakarta.validation.constraints.NotBlank
data class SetupRequest(
@field:NotBlank(message = "Укажите логин")
val login: String,
@field:NotBlank(message = "Укажите пароль")
val password: String,
@field:NotBlank(message = "Повторите пароль")
val password2: String,
)
data class LoginRequest(
@field:NotBlank(message = "Укажите логин")
val login: String,
@field:NotBlank(message = "Укажите пароль")
val password: String,
)
data class ChangePasswordRequest(
@field:NotBlank(message = "Укажите текущий пароль")
val old: String,
@field:NotBlank(message = "Укажите новый пароль")
val new: String,
@field:NotBlank(message = "Повторите новый пароль")
val new2: String,
)
data class AuthResponse(
val token: String,
val id: String,
val login: String,
val role: String,
val name: String,
val mustChangePassword: Boolean,
)
data class MeResponse(
val id: String,
val login: String,
val role: String,
val name: String,
val mustChangePassword: Boolean,
)

View File

@@ -0,0 +1,133 @@
package ru.ysu.lms.identity.auth
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
import org.springframework.security.oauth2.jwt.JwsHeader
import org.springframework.security.oauth2.jwt.JwtClaimsSet
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Service
class AuthService(
private val accounts: AccountRepository,
private val students: StudentRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@Transactional
fun setup(request: SetupRequest): AuthResponse {
if (adminExists()) {
throw ConflictException("Аккаунт администратора уже существует")
}
val login = request.login.trim()
if (login.length < 3) {
throw BadRequestException("Логин: минимум 3 символа")
}
validatePassword(request.password, request.password2)
val account = Account(
login = login,
passwordHash = passwordEncoder.encode(request.password) ?: "",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
accounts.save(account)
return toResponse(account, account.login)
}
@Transactional(readOnly = true)
fun login(request: LoginRequest): AuthResponse {
val account = accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
throw UnauthorizedException("Неверный логин или пароль")
}
return toResponse(account, displayName(account))
}
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
return MeResponse(
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = displayName(account),
mustChangePassword = account.mustChangePassword,
)
}
@Transactional
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
throw UnauthorizedException("Текущий пароль неверный")
}
validatePassword(request.new, request.new2)
account.passwordHash = passwordEncoder.encode(request.new) ?: account.passwordHash
account.mustChangePassword = false
account.tempPassword = null
account.updatedAt = Instant.now()
accounts.save(account)
}
private fun validatePassword(password: String, confirmation: String) {
if (password.length < 8) {
throw BadRequestException("Пароль: минимум 8 символов")
}
if (password != confirmation) {
throw BadRequestException("Пароли не совпадают")
}
}
private fun displayName(account: Account): String =
if (account.role == LmsRole.STUDENT) {
students.findById(account.id)
.map { it.fullName.ifBlank { account.login } }
.orElse(account.login)
} else {
account.login
}
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
token = issueToken(account, name),
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
)
private fun issueToken(account: Account, name: String): String {
val now = Instant.now()
val claims = JwtClaimsSet.builder()
.issuer(jwtProperties.issuer)
.issuedAt(now)
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
.subject(account.id.toString())
.claim("role", account.role.name)
.claim("login", account.login)
.claim("name", name)
.build()
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.identity.auth
import java.security.SecureRandom
object Passwords {
private const val ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
private val random = SecureRandom()
fun generate(length: Int = 10): String =
buildString(length) {
repeat(length) { append(ALPHABET[random.nextInt(ALPHABET.length)]) }
}
}

View File

@@ -0,0 +1,41 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import ru.ysu.lms.security.LmsRole
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "accounts")
class Account(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 64)
var login: String = "",
@Column(name = "password_hash", nullable = false)
var passwordHash: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var role: LmsRole = LmsRole.STUDENT,
@Column(name = "must_change_password", nullable = false)
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,30 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "enrollments")
class Enrollment(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "group_id", nullable = false)
var group: GroupEntity? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id", nullable = false)
var student: Student? = null,
@Column(name = "enrolled_at", nullable = false)
var enrolledAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,25 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "groups")
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100)
var name: String = "",
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "settings")
class Setting(
@Id
@Column(name = "key", length = 100)
var key: String = "",
@Column(name = "value", nullable = false)
var value: String = "",
)

View File

@@ -0,0 +1,47 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.MapsId
import jakarta.persistence.OneToOne
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "students")
class Student(
@Id
@Column(name = "account_id")
var accountId: UUID? = null,
@Column(name = "student_no", nullable = false, unique = true, length = 32)
var studentNo: String = "",
@Column(name = "last_name", nullable = false, length = 100)
var lastName: String = "",
@Column(name = "first_name", nullable = false, length = 100)
var firstName: String = "",
@Column(name = "middle_name", nullable = false, length = 100)
var middleName: String = "",
@Column(name = "basis", nullable = false, length = 100)
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")
var account: Account? = null,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName)
.filter { it.isNotBlank() }
.joinToString(" ")
}

View File

@@ -0,0 +1,13 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.security.LmsRole
import java.util.UUID
interface AccountRepository : JpaRepository<Account, UUID> {
fun findByLogin(login: String): Account?
fun existsByLogin(login: String): Boolean
fun existsByRole(role: LmsRole): Boolean
fun countByRole(role: LmsRole): Long
}

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import ru.ysu.lms.identity.domain.Enrollment
import java.util.UUID
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
@Query(
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
)
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)
fun countByGroup_Id(groupId: UUID): Long
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.GroupEntity
import java.util.UUID
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findByName(name: String): GroupEntity?
fun existsByName(name: String): Boolean
fun findAllByOrderByNameAsc(): List<GroupEntity>
}

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.Setting
interface SettingRepository : JpaRepository<Setting, String>

View File

@@ -0,0 +1,10 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.Student
import java.util.UUID
interface StudentRepository : JpaRepository<Student, UUID> {
fun findByStudentNo(studentNo: String): Student?
fun existsByStudentNo(studentNo: String): Boolean
}

View File

@@ -0,0 +1,53 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
object CredentialsExporter {
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
fun csv(rows: List<StudentRow>): ByteArray {
val builder = StringBuilder()
builder.append(HEADERS.joinToString(";")).append("\r\n")
rows.forEach { row ->
builder.append(
listOf(
row.fullName,
row.studentNo,
row.tempPassword ?: "—",
status(row),
).joinToString(";") { escapeCsv(it) },
).append("\r\n")
}
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
}
fun xlsx(rows: List<StudentRow>): ByteArray {
XSSFWorkbook().use { workbook ->
val sheet = workbook.createSheet("Логины")
val header = sheet.createRow(0)
HEADERS.forEachIndexed { index, title -> header.createCell(index).setCellValue(title) }
rows.forEachIndexed { index, row ->
val cells = sheet.createRow(index + 1)
cells.createCell(0).setCellValue(row.fullName)
cells.createCell(1).setCellValue(row.studentNo)
cells.createCell(2).setCellValue(row.tempPassword ?: "—")
cells.createCell(3).setCellValue(status(row))
}
val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
}
}
private fun status(row: StudentRow): String =
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun escapeCsv(value: String): String =
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
"\"" + value.replace("\"", "\"\"") + "\""
} else {
value
}
}

View File

@@ -0,0 +1,58 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1/groups")
@PreAuthorize("hasRole('ADMIN')")
class GroupController(private val groupService: GroupService) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
}
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
@GetMapping("/{id}/credentials")
fun credentials(
@PathVariable id: UUID,
@RequestParam(defaultValue = "xlsx") format: String,
): ResponseEntity<ByteArray> {
val export = groupService.credentials(id, format)
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
.contentType(MediaType.parseMediaType(export.contentType))
.body(export.bytes)
}
}

View File

@@ -0,0 +1,175 @@
package ru.ysu.lms.identity.roster
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.identity.auth.Passwords
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.Student
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.EnrollmentRepository
import ru.ysu.lms.identity.repo.GroupRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@Service
class GroupService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim()
if (groups.existsByName(name)) {
throw ConflictException("Группа «$name» уже существует")
}
return summary(groups.save(GroupEntity(name = name)))
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
val parsed = parser.parse(filename, bytes)
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo)
val student = if (existing != null) {
existing.lastName = parsedStudent.lastName
existing.firstName = parsedStudent.firstName
existing.middleName = parsedStudent.middleName
existing.basis = parsedStudent.basis
existing.note = parsedStudent.note
students.save(existing)
updated.add(parsedStudent.studentNo)
existing
} else {
val password = Passwords.generate()
val account = accounts.save(
Account(
login = parsedStudent.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created = Student(
studentNo = parsedStudent.studentNo,
lastName = parsedStudent.lastName,
firstName = parsedStudent.firstName,
middleName = parsedStudent.middleName,
basis = parsedStudent.basis,
note = parsedStudent.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsedStudent.studentNo,
fullName = parsedStudent.fullName,
password = password,
),
)
created
}
val accountId = student.accountId!!
val enrollment = enrollments.findByStudent_AccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
@Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
val student = enrollment.student!!
val account = student.account!!
StudentRow(
id = account.id.toString(),
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(Comparator { a, b -> studentNoComparator.compare(a.studentNo, b.studentNo) })
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
}
@Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student = students.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
val password = Passwords.generate()
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
account.mustChangePassword = true
account.tempPassword = password
accounts.save(account)
return ResetPasswordResponse(password)
}
@Transactional(readOnly = true)
fun credentials(groupId: UUID, format: String): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
return when (format.lowercase()) {
"csv" -> ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" -> ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.countByGroup_Id(group.id),
taskDurationMin = group.taskDurationMin,
)
private companion object {
val studentNoComparator = Comparator<String> { a, b ->
val left = a.toIntOrNull()
val right = b.toIntOrNull()
when {
left != null && right != null -> left.compareTo(right)
left != null -> -1
right != null -> 1
else -> a.compareTo(b)
}
}
}
}

View File

@@ -0,0 +1,55 @@
package ru.ysu.lms.identity.roster
import jakarta.validation.constraints.NotBlank
data class GroupSummary(
val id: String,
val name: String,
val studentCount: Long,
val taskDurationMin: Int,
)
data class CreateGroupRequest(
@field:NotBlank(message = "Укажите название группы")
val name: String,
)
data class StudentRow(
val id: String,
val studentNo: String,
val fullName: String,
val basis: String,
val mustChangePassword: Boolean,
val tempPassword: String?,
)
data class GroupDetail(
val id: String,
val name: String,
val taskDurationMin: Int,
val students: List<StudentRow>,
)
data class Credential(
val id: String,
val studentNo: String,
val fullName: String,
val password: String,
)
data class ImportResult(
val groupId: String,
val groupName: String,
val added: List<Credential>,
val updated: List<String>,
)
data class ResetPasswordResponse(
val password: String,
)
class ExportFile(
val bytes: ByteArray,
val filename: String,
val contentType: String,
)

View File

@@ -0,0 +1,18 @@
package ru.ysu.lms.identity.roster
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.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')")
class StudentController(private val groupService: GroupService) {
@PostMapping("/{id}/reset-password")
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
groupService.resetPassword(id)
}

View File

@@ -0,0 +1,160 @@
package ru.ysu.lms.identity.roster
import org.apache.poi.ss.usermodel.DataFormatter
import org.apache.poi.ss.usermodel.WorkbookFactory
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
data class ParsedStudent(
val studentNo: String,
val lastName: String,
val firstName: String,
val middleName: String,
val basis: String,
val note: String,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
}
data class ParsedGroup(
val groupName: String,
val students: List<ParsedStudent>,
)
@Component
class StudentImportParser {
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
}
}
private fun parseExcel(bytes: ByteArray): List<List<String>> {
WorkbookFactory.create(ByteArrayInputStream(bytes)).use { workbook ->
val sheet = workbook.getSheetAt(0)
val formatter = DataFormatter()
return (0..sheet.lastRowNum).map { rowIndex ->
val row = sheet.getRow(rowIndex) ?: return@map emptyList()
val cells = mutableListOf<String>()
for (col in 0 until row.lastCellNum.toInt()) {
cells.add(formatter.formatCellValue(row.getCell(col)).trim())
}
cells
}
}
}
private fun parseCsv(bytes: ByteArray): List<List<String>> {
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
if (lines.isEmpty()) {
throw BadRequestException("Файл пуст")
}
val delimiter = detectDelimiter(lines.first())
return lines.map { splitCsvLine(it, delimiter) }
}
private fun detectDelimiter(header: String): Char {
val candidates = listOf(';', ',', '\t')
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
}
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
for (ch in line) {
when {
ch == '"' -> inQuotes = !inQuotes
ch == delimiter && !inQuotes -> {
result.add(current.toString().trim())
current.clear()
}
else -> current.append(ch)
}
}
result.add(current.toString().trim())
return result
}
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
val columns = mapColumns(rows[headerIndex])
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) {
return@mapNotNull null
}
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
}
if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
}
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
}
private fun mapColumns(header: List<String>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
header.forEachIndexed { index, raw ->
val value = raw.lowercase().replace("\u00a0", " ").trim()
fun putIfAbsent(key: String, vararg prefixes: String) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
}
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
putIfAbsent("lastName", "фамили")
putIfAbsent("firstName", "имя")
putIfAbsent("middleName", "отчеств")
putIfAbsent("basis", "основ")
putIfAbsent("note", "примеч")
}
return result
}
private fun normalizeNumber(value: String): String =
if (value.endsWith(".0")) value.dropLast(2) else value
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) {
for (cell in row) {
val value = cell.trim()
if (value.length in 2..30 && groupRegex.matches(value)) {
return value
}
}
}
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
}
}

View File

@@ -0,0 +1,37 @@
package ru.ysu.lms.identity.security
import com.nimbusds.jose.jwk.JWKSet
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
import com.nimbusds.jose.proc.SecurityContext
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.JwtEncoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
import ru.ysu.lms.security.JwtProperties
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtConfig(private val keyStore: JwtKeyStore) {
@Bean
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

View File

@@ -0,0 +1,50 @@
package ru.ysu.lms.identity.security
import jakarta.annotation.PostConstruct
import org.springframework.stereotype.Service
import ru.ysu.lms.identity.domain.Setting
import ru.ysu.lms.identity.repo.SettingRepository
import ru.ysu.lms.security.RsaKeys
import java.security.KeyPairGenerator
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.util.Base64
/**
* RSA signing keys for the identity service. Generated on first start and kept
* in the `settings` table, so no private key ever lives in the repository.
*/
@Service
class JwtKeyStore(private val settings: SettingRepository) {
lateinit var privateKey: RSAPrivateKey
lateinit var publicKey: RSAPublicKey
@PostConstruct
fun init() {
val storedPrivate = settings.findById(PRIVATE_KEY).map { it.value }.orElse(null)
val storedPublic = settings.findById(PUBLIC_KEY).map { it.value }.orElse(null)
if (storedPrivate != null && storedPublic != null) {
privateKey = RsaKeys.privateKey(storedPrivate)
publicKey = RsaKeys.publicKey(storedPublic)
} else {
val generator = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }
val pair = generator.generateKeyPair()
privateKey = pair.private as RSAPrivateKey
publicKey = pair.public as RSAPublicKey
settings.save(Setting(PRIVATE_KEY, toPem("PRIVATE KEY", privateKey.encoded)))
settings.save(Setting(PUBLIC_KEY, toPem("PUBLIC KEY", publicKey.encoded)))
}
}
private fun toPem(label: String, der: ByteArray): String {
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
}
companion object {
const val PRIVATE_KEY = "jwt_private_key"
const val PUBLIC_KEY = "jwt_public_key"
}
}

View File

@@ -0,0 +1,62 @@
package ru.ysu.lms.identity.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/api/v1/auth/health",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/jwks.json",
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
).permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { resource ->
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
}
return http.build()
}
private fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter(roleAuthoritiesConverter())
return converter
}
private fun roleAuthoritiesConverter(): Converter<Jwt, Collection<GrantedAuthority>> =
Converter { jwt ->
val role = jwt.getClaimAsString("role").orEmpty()
if (role.isBlank()) {
emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8081}
spring:
application:
name: identity-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=identity}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: identity
flyway:
enabled: true
locations: classpath:db/migration
schemas: identity
default-schema: identity
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline for the identity schema (accounts, students, groups, enrollments).
CREATE SCHEMA IF NOT EXISTS identity;

View File

@@ -0,0 +1,45 @@
-- Accounts, students, groups and enrollments for the identity service.
CREATE TABLE accounts (
id uuid PRIMARY KEY DEFAULT uuidv7(),
login varchar(64) NOT NULL UNIQUE,
password_hash varchar(255) NOT NULL,
role varchar(16) NOT NULL,
must_change_password boolean NOT NULL DEFAULT true,
temp_password varchar(64),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN', 'STUDENT'))
);
CREATE TABLE students (
account_id uuid PRIMARY KEY REFERENCES accounts (id) ON DELETE CASCADE,
student_no varchar(32) NOT NULL UNIQUE,
last_name varchar(100) NOT NULL DEFAULT '',
first_name varchar(100) NOT NULL DEFAULT '',
middle_name varchar(100) NOT NULL DEFAULT '',
basis varchar(100) NOT NULL DEFAULT '',
note text NOT NULL DEFAULT ''
);
CREATE TABLE groups (
id uuid PRIMARY KEY DEFAULT uuidv7(),
name varchar(100) NOT NULL UNIQUE,
task_duration_min integer NOT NULL DEFAULT 30,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE enrollments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
group_id uuid NOT NULL REFERENCES groups (id) ON DELETE CASCADE,
student_id uuid NOT NULL REFERENCES students (account_id) ON DELETE CASCADE,
enrolled_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT enrollments_student_unique UNIQUE (student_id)
);
CREATE INDEX enrollments_group_idx ON enrollments (group_id);
CREATE TABLE settings (
key varchar(100) PRIMARY KEY,
value text NOT NULL
);

View File

@@ -0,0 +1,62 @@
package ru.ysu.lms.identity
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.common.UnauthorizedException
@SpringBootTest
@Testcontainers
class IdentitySmokeTest {
companion object {
@Container
val pg = PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun datasource(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
}
}
@Autowired
lateinit var authService: AuthService
@Test
fun `context loads and admin not exists`() {
assert(!authService.adminExists()) { "admin should not exist" }
}
@Test
fun `setup creates admin and login works`() {
val token = authService.setup(
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
)
assert(token.token.isNotBlank()) { "token should not be blank" }
assert(token.role == "ADMIN")
val login = authService.login(
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
)
assert(login.token.isNotBlank())
val ex = try {
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
null
} catch (e: UnauthorizedException) { e }
assert(ex != null) { "bad password should throw" }
}
}

View File

@@ -0,0 +1,26 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
}

View File

@@ -0,0 +1,12 @@
package ru.ysu.lms.progress
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
@SpringBootApplication
class ProgressServiceApplication
fun main(args: Array<String>) {
runApplication<ProgressServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "variant_assignments")
class VariantAssignment(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false) var studentId: UUID = UUID(0,0),
@Column(nullable = false) var labId: UUID = UUID(0,0),
@Column(nullable = false) var variantId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
)
@Entity
@Table(name = "student_lab_levels")
class StudentLabLevel(
@Id
@Column(name = "student_id", insertable = false, updatable = false)
var studentId: UUID = UUID(0,0),
@Id
@Column(name = "lab_id", insertable = false, updatable = false)
var labId: UUID = UUID(0,0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
@Column(nullable = false) var manual: Boolean = false,
)

View File

@@ -0,0 +1,38 @@
package ru.ysu.lms.progress.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt { } }
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}

View File

@@ -0,0 +1,30 @@
server:
port: ${SERVER_PORT:8083}
spring:
application:
name: progress-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: progress
flyway:
enabled: true
locations: classpath:db/migration
schemas: progress
default-schema: progress
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline: student lab levels and variant assignments.
CREATE SCHEMA IF NOT EXISTS progress;

View File

@@ -0,0 +1,22 @@
-- Student levels per lab and variant assignments.
CREATE TABLE student_lab_levels (
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
lab_level_id uuid NOT NULL,
manual boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (student_id, lab_id)
);
CREATE TABLE variant_assignments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
variant_id uuid NOT NULL,
manual boolean NOT NULL DEFAULT false,
assigned_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT va_student_lab_unique UNIQUE (student_id, lab_id)
);
CREATE INDEX va_lab_idx ON variant_assignments (lab_id);

31
settings.gradle.kts Normal file
View File

@@ -0,0 +1,31 @@
// Entry point of the Gradle build: declares repositories and subprojects.
// Docs: https://docs.gradle.org/current/userguide/multi_project_builds.html
dependencyResolutionManagement {
@Suppress("UnstableApiUsage")
repositories {
mavenCentral()
}
}
plugins {
// Automatically provision the JDK declared by toolchains.
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "LMS"
// Shared libraries (no Spring Boot application, plain JVM/Spring libraries).
include(":libs:common-http")
include(":libs:security-jwt")
include(":libs:markdown")
include(":libs:test-support")
// Domain microservices.
include(":services:identity-service")
include(":services:academic-service")
include(":services:progress-service")
include(":services:gateway")
// Server-side web UI (BFF): Thymeleaf + htmx.
include(":web")

23
web/build.gradle.kts Normal file
View File

@@ -0,0 +1,23 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterThymeleaf)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(project(":libs:common-http"))
developmentOnly(platform(libs.springBootBom))
developmentOnly(libs.springBootDevtools)
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.web.WebApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.web
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class WebApplication
fun main(args: Array<String>) {
runApplication<WebApplication>(*args)
}

View File

@@ -0,0 +1,124 @@
package ru.ysu.lms.web.admin
import jakarta.servlet.http.HttpSession
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.servlet.mvc.support.RedirectAttributes
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.session.SessionKeys
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@Controller
@RequestMapping("/admin")
class AdminController(private val api: ApiClient) {
@GetMapping
fun dashboard(session: HttpSession, model: Model): String {
val groups = api.listGroups(token(session))
model.addAttribute("groupCount", groups.size)
model.addAttribute("studentCount", groups.sumOf { it.studentCount })
model.addAttribute("groups", groups.take(5))
return "admin/dashboard"
}
@GetMapping("/groups")
fun groups(session: HttpSession, model: Model): String {
model.addAttribute("groups", api.listGroups(token(session)))
return "admin/groups"
}
@PostMapping("/groups")
fun createGroup(
@RequestParam name: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.createGroup(token(session), name.trim())
redirect.addFlashAttribute("notice", "Группа создана")
"redirect:/admin/groups"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
@PostMapping("/groups/import")
fun importGroup(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String {
if (file.isEmpty) {
redirect.addFlashAttribute("error", "Выберите файл со списком группы")
return "redirect:/admin/groups"
}
return try {
val result = api.importGroup(
token(session),
name,
file.originalFilename ?: "group.xlsx",
file.bytes,
)
redirect.addFlashAttribute("importResult", result)
"redirect:/admin/groups/${result.groupId}"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups"
}
}
@GetMapping("/groups/{id}")
fun groupDetail(
@PathVariable id: String,
session: HttpSession,
model: Model,
): String {
model.addAttribute("group", api.groupDetail(token(session), id))
return "admin/group"
}
@PostMapping("/groups/{id}/students/{studentId}/reset-password")
fun resetPassword(
@PathVariable id: String,
@PathVariable studentId: String,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
val result = api.resetPassword(token(session), studentId)
redirect.addFlashAttribute("resetPassword", result.password)
redirect.addFlashAttribute("resetFor", studentId)
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
redirect.addFlashAttribute("error", ex.message)
"redirect:/admin/groups/$id"
}
@GetMapping("/groups/{id}/credentials")
fun credentials(
@PathVariable id: String,
@RequestParam(defaultValue = "xlsx") format: String,
session: HttpSession,
): ResponseEntity<ByteArray> {
val download = api.credentials(token(session), id, format)
val encoded = URLEncoder.encode(download.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.parseMediaType(download.contentType))
.body(download.bytes)
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")
}

View File

@@ -0,0 +1,149 @@
package ru.ysu.lms.web.api
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.ParameterizedTypeReference
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.util.LinkedMultiValueMap
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
@Component
class ApiClient(
@Value("\${lms.gateway.base-url}") baseUrl: String,
) {
private val client: RestClient = RestClient.builder().baseUrl(baseUrl).build()
fun adminExists(): Boolean =
call { client.get().uri("/api/v1/auth/health").retrieve().body(HealthDto::class.java) }.adminExists
fun setup(login: String, password: String, password2: String): AuthResponseDto =
call {
client.post().uri("/api/v1/auth/setup")
.contentType(MediaType.APPLICATION_JSON)
.body(SetupPayload(login, password, password2))
.retrieve().body(AuthResponseDto::class.java)
}
fun login(login: String, password: String): AuthResponseDto =
call {
client.post().uri("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(LoginPayload(login, password))
.retrieve().body(AuthResponseDto::class.java)
}
fun changePassword(token: String, old: String, new: String, new2: String) {
call {
client.post().uri("/api/v1/auth/change-password")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(ChangePasswordPayload(old, new, new2))
.retrieve().toBodilessEntity()
}
}
fun listGroups(token: String): List<GroupDto> {
val type = object : ParameterizedTypeReference<List<GroupDto>>() {}
return call {
client.get().uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun createGroup(token: String, name: String): GroupDto =
call {
client.post().uri("/api/v1/groups")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.body(CreateGroupPayload(name))
.retrieve().body(GroupDto::class.java)
}
fun importGroup(
token: String,
name: String?,
filename: String,
bytes: ByteArray,
): ImportResultDto {
val parts = LinkedMultiValueMap<String, Any>()
parts.add(
"file",
object : ByteArrayResource(bytes) {
override fun getFilename(): String = filename
},
)
if (!name.isNullOrBlank()) {
parts.add("name", name)
}
return call {
client.post().uri("/api/v1/groups/import")
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve().body(ImportResultDto::class.java)
}
}
fun groupDetail(token: String, groupId: String): GroupDetailDto =
call {
client.get().uri("/api/v1/groups/{id}", groupId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(GroupDetailDto::class.java)
}
fun resetPassword(token: String, studentId: String): ResetPasswordDto =
call {
client.post().uri("/api/v1/students/{id}/reset-password", studentId)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(ResetPasswordDto::class.java)
}
fun credentials(token: String, groupId: String, format: String): DownloadDto {
val entity = call {
client.get()
.uri { builder ->
builder.path("/api/v1/groups/{id}/credentials").queryParam("format", format).build(groupId)
}
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().toEntity(ByteArray::class.java)
}
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "credentials.$format" }
val filename = URLDecoder.decode(rawName, StandardCharsets.UTF_8)
val contentType = entity.headers.contentType?.toString() ?: "application/octet-stream"
return DownloadDto(entity.body ?: ByteArray(0), filename, contentType)
}
private fun bearer(token: String): String = "Bearer $token"
private fun <T> call(block: () -> T?): T =
try {
block() ?: throw ApiException(502, "Пустой ответ сервиса")
} catch (ex: ApiException) {
throw ex
} catch (ex: RestClientResponseException) {
throw ApiException(ex.statusCode.value(), detail(ex))
} catch (ex: RestClientException) {
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
}
private fun detail(ex: RestClientResponseException): String {
val body = ex.responseBodyAsString
val match = DETAIL_PATTERN.find(body)
val parsed = match?.groupValues?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
}
private companion object {
val DETAIL_PATTERN = Regex("\"detail\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"")
}
}

View File

@@ -0,0 +1,78 @@
package ru.ysu.lms.web.api
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class HealthDto(
val ok: Boolean = false,
val adminExists: Boolean = false,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class AuthResponseDto(
val token: String = "",
val id: String = "",
val login: String = "",
val role: String = "",
val name: String = "",
val mustChangePassword: Boolean = false,
)
data class LoginPayload(val login: String, val password: String)
data class SetupPayload(val login: String, val password: String, val password2: String)
data class ChangePasswordPayload(val old: String, val new: String, val new2: String)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDto(
val id: String = "",
val name: String = "",
val studentCount: Long = 0,
val taskDurationMin: Int = 30,
)
data class CreateGroupPayload(val name: String)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CredentialDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val password: String = "",
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ImportResultDto(
val groupId: String = "",
val groupName: String = "",
val added: List<CredentialDto> = emptyList(),
val updated: List<String> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class StudentRowDto(
val id: String = "",
val studentNo: String = "",
val fullName: String = "",
val basis: String = "",
val mustChangePassword: Boolean = false,
val tempPassword: String? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class GroupDetailDto(
val id: String = "",
val name: String = "",
val taskDurationMin: Int = 30,
val students: List<StudentRowDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ResetPasswordDto(val password: String = "")
class DownloadDto(
val bytes: ByteArray,
val filename: String,
val contentType: String,
)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.web.api
class ApiException(
val status: Int,
override val message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,126 @@
package ru.ysu.lms.web.auth
import jakarta.servlet.http.HttpSession
import org.springframework.stereotype.Controller
import org.springframework.ui.Model
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import ru.ysu.lms.web.api.ApiClient
import ru.ysu.lms.web.api.ApiException
import ru.ysu.lms.web.api.AuthResponseDto
import ru.ysu.lms.web.session.SessionKeys
import ru.ysu.lms.web.session.SessionUser
@Controller
class AuthPageController(private val api: ApiClient) {
@GetMapping("/")
fun root(session: HttpSession): String {
val user = currentUser(session) ?: return "redirect:/login"
return if (user.isAdmin) "redirect:/admin" else "redirect:/student"
}
@GetMapping("/login")
fun loginPage(session: HttpSession, model: Model): String {
if (currentUser(session) != null) {
return "redirect:/"
}
model.addAttribute("adminExists", api.adminExists())
return "login"
}
@PostMapping("/login")
fun login(
@RequestParam login: String,
@RequestParam password: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.login(login.trim(), password)
store(session, auth)
redirectFor(auth)
} catch (ex: ApiException) {
model.addAttribute("adminExists", api.adminExists())
model.addAttribute("error", ex.message)
model.addAttribute("login", login)
"login"
}
@GetMapping("/setup")
fun setupPage(model: Model): String {
if (api.adminExists()) {
return "redirect:/login"
}
return "setup"
}
@PostMapping("/setup")
fun setup(
@RequestParam login: String,
@RequestParam password: String,
@RequestParam password2: String,
session: HttpSession,
model: Model,
): String = try {
val auth = api.setup(login.trim(), password, password2)
store(session, auth)
"redirect:/admin"
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
model.addAttribute("login", login)
"setup"
}
@GetMapping("/change-password")
fun changePasswordPage(): String = "change_password"
@PostMapping("/change-password")
fun changePassword(
@RequestParam old: String,
@RequestParam("new") new: String,
@RequestParam new2: String,
session: HttpSession,
model: Model,
): String {
val user = currentUser(session) ?: return "redirect:/login"
val token = session.getAttribute(SessionKeys.TOKEN) as? String ?: return "redirect:/login"
return try {
api.changePassword(token, old, new, new2)
session.setAttribute(SessionKeys.USER, user.copy(mustChangePassword = false))
"redirect:/"
} catch (ex: ApiException) {
model.addAttribute("error", ex.message)
"change_password"
}
}
@PostMapping("/logout")
fun logout(session: HttpSession): String {
session.invalidate()
return "redirect:/login"
}
private fun currentUser(session: HttpSession): SessionUser? =
session.getAttribute(SessionKeys.USER) as? SessionUser
private fun store(session: HttpSession, auth: AuthResponseDto) {
session.setAttribute(SessionKeys.TOKEN, auth.token)
session.setAttribute(
SessionKeys.USER,
SessionUser(
id = auth.id,
login = auth.login,
role = auth.role,
name = auth.name,
mustChangePassword = auth.mustChangePassword,
),
)
}
private fun redirectFor(auth: AuthResponseDto): String = when {
auth.mustChangePassword -> "redirect:/change-password"
auth.role == "ADMIN" -> "redirect:/admin"
else -> "redirect:/student"
}
}

Some files were not shown because too many files have changed in this diff Show More