feat(academic): защита активов материалов
This commit is contained in:
@@ -2,3 +2,5 @@ package ru.ysu.lms.academic.content
|
|||||||
|
|
||||||
/** Аудитория глобальной главы, доступной без авторизации. */
|
/** Аудитория глобальной главы, доступной без авторизации. */
|
||||||
const val AUDIENCE_ALL = "ALL"
|
const val AUDIENCE_ALL = "ALL"
|
||||||
|
const val AUDIENCE_STUDENTS = "STUDENTS"
|
||||||
|
const val AUDIENCE_TEACHERS = "TEACHERS"
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package ru.ysu.lms.academic.content
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.academic.domain.ArticleAsset
|
||||||
import ru.ysu.lms.academic.domain.ContentArticle
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
import ru.ysu.lms.academic.domain.ContentChapter
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
import ru.ysu.lms.academic.domain.ContentSection
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.repo.ArticleAssetRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
@@ -27,6 +29,7 @@ class ContentImporter(
|
|||||||
private val chapters: ContentChapterRepository,
|
private val chapters: ContentChapterRepository,
|
||||||
private val sections: ContentSectionRepository,
|
private val sections: ContentSectionRepository,
|
||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
|
private val assetLinks: ArticleAssetRepository,
|
||||||
private val assets: AssetStorage,
|
private val assets: AssetStorage,
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -43,9 +46,9 @@ class ContentImporter(
|
|||||||
if (contentFiles.isEmpty()) {
|
if (contentFiles.isEmpty()) {
|
||||||
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
|
||||||
}
|
}
|
||||||
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
|
val storedAssets = storeAssets(files.filterKeys(::isAssetsPath))
|
||||||
val parsed = parseArticles(contentFiles)
|
val parsed = parseArticles(contentFiles)
|
||||||
return mergeIntoDb(parsed, linkRewrite, sync)
|
return mergeIntoDb(parsed, storedAssets, sync)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requireZip(filename: String) {
|
private fun requireZip(filename: String) {
|
||||||
@@ -54,22 +57,22 @@ class ContentImporter(
|
|||||||
|
|
||||||
private fun mergeIntoDb(
|
private fun mergeIntoDb(
|
||||||
parsed: List<ParsedArticle>,
|
parsed: List<ParsedArticle>,
|
||||||
linkRewrite: Map<String, String>,
|
storedAssets: StoredAssets,
|
||||||
sync: Boolean,
|
sync: Boolean,
|
||||||
): ContentImportResult {
|
): ContentImportResult {
|
||||||
val state = ImportState()
|
val state = ImportState()
|
||||||
for (p in parsed) {
|
for (p in parsed) {
|
||||||
val chapter = chapterFor(p, state)
|
val chapter = chapterFor(p, state)
|
||||||
val section = sectionFor(chapter, p, state)
|
val section = sectionFor(chapter, p, state)
|
||||||
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
|
val body = storedAssets.rewrite(p.body)
|
||||||
upsertArticle(section, p, body, state)
|
upsertArticle(section, p, body, storedAssets, state)
|
||||||
}
|
}
|
||||||
val removed = if (sync) removeMissing(state) else 0
|
val removed = if (sync) removeMissing(state) else 0
|
||||||
return ContentImportResult(
|
return ContentImportResult(
|
||||||
chapters = state.chapters,
|
chapters = state.chapters,
|
||||||
sections = state.sections,
|
sections = state.sections,
|
||||||
articles = state.articles,
|
articles = state.articles,
|
||||||
assets = linkRewrite.size,
|
assets = storedAssets.refs.size,
|
||||||
removed = removed,
|
removed = removed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,6 +139,7 @@ class ContentImporter(
|
|||||||
section: ContentSection,
|
section: ContentSection,
|
||||||
p: ParsedArticle,
|
p: ParsedArticle,
|
||||||
body: String,
|
body: String,
|
||||||
|
storedAssets: StoredAssets,
|
||||||
state: ImportState,
|
state: ImportState,
|
||||||
) {
|
) {
|
||||||
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
|
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
|
||||||
@@ -148,11 +152,33 @@ class ContentImporter(
|
|||||||
bodyMd = body
|
bodyMd = body
|
||||||
position = p.articlePos
|
position = p.articlePos
|
||||||
}
|
}
|
||||||
articles.save(article)
|
// saveAndFlush гарантирует строку articles до вставки article_assets (FK).
|
||||||
|
articles.saveAndFlush(article)
|
||||||
|
replaceAssetLinks(article, body, storedAssets)
|
||||||
state.seenArticles.add(article.id)
|
state.seenArticles.add(article.id)
|
||||||
state.articles += 1
|
state.articles += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Связи статьи с активами полностью заменяются при повторном импорте. */
|
||||||
|
private fun replaceAssetLinks(
|
||||||
|
article: ContentArticle,
|
||||||
|
body: String,
|
||||||
|
storedAssets: StoredAssets,
|
||||||
|
) {
|
||||||
|
assetLinks.deleteByArticleId(article.id)
|
||||||
|
for (ref in storedAssets.referencedIn(body)) {
|
||||||
|
assetLinks.save(
|
||||||
|
ArticleAsset(
|
||||||
|
articleId = article.id,
|
||||||
|
filename = ref.originalName,
|
||||||
|
storedPath = ref.storedPath,
|
||||||
|
mime = ref.mime,
|
||||||
|
size = ref.size,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
|
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
|
||||||
private fun removeMissing(state: ImportState): Int =
|
private fun removeMissing(state: ImportState): Int =
|
||||||
state.touchedChapters.sumOf { chapterId ->
|
state.touchedChapters.sumOf { chapterId ->
|
||||||
@@ -180,15 +206,22 @@ class ContentImporter(
|
|||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
|
private fun storeAssets(assetFiles: Map<String, ByteArray>): StoredAssets {
|
||||||
val linkRewrite = mutableMapOf<String, String>()
|
val refs = mutableListOf<StoredAssetRef>()
|
||||||
for ((path, data) in assetFiles) {
|
for ((path, data) in assetFiles) {
|
||||||
val name = path.substringAfterLast('/')
|
val name = path.substringAfterLast('/')
|
||||||
if (name.isBlank()) continue
|
if (name.isBlank()) continue
|
||||||
val stored = assets.store(name, data)
|
val stored = assets.store(name, data)
|
||||||
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
|
refs +=
|
||||||
|
StoredAssetRef(
|
||||||
|
originalName = name,
|
||||||
|
storedPath = stored.storedPath.toString(),
|
||||||
|
mime = stored.mime,
|
||||||
|
size = data.size.toLong(),
|
||||||
|
url = "/api/v1/content/assets/${stored.publicName}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return linkRewrite
|
return StoredAssets(refs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
|
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
|
||||||
@@ -297,6 +330,22 @@ class ContentImporter(
|
|||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class StoredAssetRef(
|
||||||
|
val originalName: String,
|
||||||
|
val storedPath: String,
|
||||||
|
val mime: String,
|
||||||
|
val size: Long,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class StoredAssets(
|
||||||
|
val refs: List<StoredAssetRef>,
|
||||||
|
) {
|
||||||
|
fun rewrite(body: String): String = refs.fold(body) { acc, ref -> acc.replace("assets/${ref.originalName}", ref.url) }
|
||||||
|
|
||||||
|
fun referencedIn(body: String): List<StoredAssetRef> = refs.filter { body.contains(it.url) }
|
||||||
|
}
|
||||||
|
|
||||||
private data class ParsedArticle(
|
private data class ParsedArticle(
|
||||||
val chapterPos: Int,
|
val chapterPos: Int,
|
||||||
val chapterDir: String,
|
val chapterDir: String,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package ru.ysu.lms.academic.content
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import ru.ysu.lms.academic.domain.ArticleAsset
|
||||||
import ru.ysu.lms.academic.domain.ContentChapter
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.repo.ArticleAssetRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
@@ -60,6 +62,17 @@ data class ContentImportResult(
|
|||||||
val removed: Int = 0,
|
val removed: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наследуемая видимость актива: статья → раздел → глава.
|
||||||
|
* `published` — опубликованы все три уровня.
|
||||||
|
*/
|
||||||
|
data class ArticleAssetAccess(
|
||||||
|
val articleId: UUID,
|
||||||
|
val courseId: UUID?,
|
||||||
|
val audience: String?,
|
||||||
|
val published: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Новое расположение материалов: порядок глав, порядок разделов внутри глав
|
* Новое расположение материалов: порядок глав, порядок разделов внутри глав
|
||||||
* (и перенос разделов между главами), порядок статей внутри разделов
|
* (и перенос разделов между главами), порядок статей внутри разделов
|
||||||
@@ -98,6 +111,7 @@ class ContentService(
|
|||||||
private val chapters: ContentChapterRepository,
|
private val chapters: ContentChapterRepository,
|
||||||
private val sections: ContentSectionRepository,
|
private val sections: ContentSectionRepository,
|
||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
|
private val articleAssets: ArticleAssetRepository,
|
||||||
private val importer: ContentImporter,
|
private val importer: ContentImporter,
|
||||||
) {
|
) {
|
||||||
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
||||||
@@ -172,6 +186,38 @@ class ContentService(
|
|||||||
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
return articleView(ch, sectionSlug, articleSlug, includeHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все статьи, к которым привязан актив, с наследуемой видимостью.
|
||||||
|
* Один файл может использоваться несколькими статьями — решение принимает
|
||||||
|
* вызывающая сторона по правилу «доступен, если видима хотя бы одна».
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun assetAccesses(name: String): List<ArticleAssetAccess> =
|
||||||
|
(articleAssets.findByStoredPathEndingWith(name) + articleAssets.findByFilename(name))
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.filter { it.matchesAssetName(name) }
|
||||||
|
.mapNotNull { assetAccess(it) }
|
||||||
|
|
||||||
|
private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? {
|
||||||
|
val articleId = asset.articleId ?: return null
|
||||||
|
val article = articles.findById(articleId).orElse(null)
|
||||||
|
val section = article?.let { sections.findById(it.sectionId).orElse(null) }
|
||||||
|
val chapter = section?.let { chapters.findById(it.chapterId).orElse(null) }
|
||||||
|
return if (article != null && section != null && chapter != null) {
|
||||||
|
ArticleAssetAccess(
|
||||||
|
articleId = article.id,
|
||||||
|
courseId = chapter.courseId,
|
||||||
|
audience = chapter.audience,
|
||||||
|
published = chapter.published && section.published && article.published,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ArticleAsset.matchesAssetName(name: String): Boolean =
|
||||||
|
filename == name || storedPath == name || storedPath.substringAfterLast('/') == name
|
||||||
|
|
||||||
private fun articleView(
|
private fun articleView(
|
||||||
ch: ContentChapter,
|
ch: ContentChapter,
|
||||||
sectionSlug: String,
|
sectionSlug: String,
|
||||||
|
|||||||
@@ -3,20 +3,37 @@ package ru.ysu.lms.academic.controller
|
|||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
|
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.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import ru.ysu.lms.academic.content.AUDIENCE_ALL
|
||||||
|
import ru.ysu.lms.academic.content.AUDIENCE_STUDENTS
|
||||||
|
import ru.ysu.lms.academic.content.AUDIENCE_TEACHERS
|
||||||
|
import ru.ysu.lms.academic.content.ArticleAssetAccess
|
||||||
import ru.ysu.lms.academic.content.AssetStorage
|
import ru.ysu.lms.academic.content.AssetStorage
|
||||||
import ru.ysu.lms.academic.content.ContentArticleView
|
import ru.ysu.lms.academic.content.ContentArticleView
|
||||||
import ru.ysu.lms.academic.content.ContentService
|
import ru.ysu.lms.academic.content.ContentService
|
||||||
|
import ru.ysu.lms.academic.course.CourseAccessService
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import ru.ysu.lms.common.NotFoundException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
/** Публичное чтение учебных материалов — доступно без аутентификации. */
|
/**
|
||||||
|
* Публичное чтение учебных материалов.
|
||||||
|
*
|
||||||
|
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
|
||||||
|
* Активы без записи в `article_assets` исторически публичны (legacy-ссылки);
|
||||||
|
* привязанные активы наследуют видимость статьи, раздела и главы.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/content")
|
@RequestMapping("/api/v1/content")
|
||||||
class ContentPublicController(
|
class ContentPublicController(
|
||||||
private val contentService: ContentService,
|
private val contentService: ContentService,
|
||||||
private val assetStorage: AssetStorage,
|
private val assetStorage: AssetStorage,
|
||||||
|
private val accessService: CourseAccessService,
|
||||||
) {
|
) {
|
||||||
@GetMapping("/tree")
|
@GetMapping("/tree")
|
||||||
fun tree() = contentService.tree()
|
fun tree() = contentService.tree()
|
||||||
@@ -43,7 +60,10 @@ class ContentPublicController(
|
|||||||
@GetMapping("/assets/{name}")
|
@GetMapping("/assets/{name}")
|
||||||
fun asset(
|
fun asset(
|
||||||
@PathVariable name: String,
|
@PathVariable name: String,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt?,
|
||||||
): ResponseEntity<FileSystemResource> {
|
): ResponseEntity<FileSystemResource> {
|
||||||
|
val accesses = contentService.assetAccesses(name)
|
||||||
|
if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt)
|
||||||
val (path, mime) =
|
val (path, mime) =
|
||||||
assetStorage.read(name)
|
assetStorage.read(name)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
@@ -63,6 +83,98 @@ class ContentPublicController(
|
|||||||
builder.header("X-Content-Type-Options", "nosniff")
|
builder.header("X-Content-Type-Options", "nosniff")
|
||||||
return builder.body(FileSystemResource(path))
|
return builder.body(FileSystemResource(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аноним видит только глобальный опубликованный актив аудитории ALL.
|
||||||
|
* Аутентифицированный — если видима хотя бы одна связанная статья.
|
||||||
|
* Скрытые материалы отдают 404, чтобы не раскрывать их существование.
|
||||||
|
*/
|
||||||
|
private fun requireAssetAccess(
|
||||||
|
accesses: List<ArticleAssetAccess>,
|
||||||
|
jwt: Jwt?,
|
||||||
|
) {
|
||||||
|
if (jwt == null) {
|
||||||
|
requireAnonymousAssetAccess(accesses)
|
||||||
|
} else {
|
||||||
|
requireAuthenticatedAssetAccess(accesses, jwt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireAnonymousAssetAccess(accesses: List<ArticleAssetAccess>) {
|
||||||
|
if (accesses.any { it.isPublic }) return
|
||||||
|
throw NotFoundException("Файл не найден")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireAuthenticatedAssetAccess(
|
||||||
|
accesses: List<ArticleAssetAccess>,
|
||||||
|
jwt: Jwt,
|
||||||
|
) {
|
||||||
|
val role = jwt.getClaimAsString("role")
|
||||||
|
if (role == CourseAccessService.ROLE_ADMIN) return
|
||||||
|
var hidden = false
|
||||||
|
var denied = false
|
||||||
|
for (access in accesses) {
|
||||||
|
when (decision(access, role, jwt)) {
|
||||||
|
AssetDecision.ALLOW -> return
|
||||||
|
AssetDecision.HIDDEN -> hidden = true
|
||||||
|
AssetDecision.DENY -> denied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hidden && !denied) throw NotFoundException("Файл не найден")
|
||||||
|
throw ForbiddenException("Файл недоступен")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decision(
|
||||||
|
access: ArticleAssetAccess,
|
||||||
|
role: String?,
|
||||||
|
jwt: Jwt,
|
||||||
|
): AssetDecision {
|
||||||
|
val courseId = access.courseId
|
||||||
|
return if (courseId != null) courseDecision(courseId, access, role, jwt) else globalDecision(access, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun courseDecision(
|
||||||
|
courseId: UUID,
|
||||||
|
access: ArticleAssetAccess,
|
||||||
|
role: String?,
|
||||||
|
jwt: Jwt,
|
||||||
|
): AssetDecision {
|
||||||
|
try {
|
||||||
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
|
} catch (_: ForbiddenException) {
|
||||||
|
return AssetDecision.DENY
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
role !in COURSE_ROLES -> AssetDecision.DENY
|
||||||
|
role == CourseAccessService.ROLE_STUDENT && !access.published -> AssetDecision.HIDDEN
|
||||||
|
else -> AssetDecision.ALLOW
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun globalDecision(
|
||||||
|
access: ArticleAssetAccess,
|
||||||
|
role: String?,
|
||||||
|
): AssetDecision =
|
||||||
|
when {
|
||||||
|
!access.published -> AssetDecision.HIDDEN
|
||||||
|
access.audience == AUDIENCE_ALL && role in COURSE_ROLES -> AssetDecision.ALLOW
|
||||||
|
access.audience == AUDIENCE_STUDENTS && role == CourseAccessService.ROLE_STUDENT -> AssetDecision.ALLOW
|
||||||
|
access.audience == AUDIENCE_TEACHERS && role == CourseAccessService.ROLE_TEACHER -> AssetDecision.ALLOW
|
||||||
|
else -> AssetDecision.DENY
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ArticleAssetAccess.isPublic: Boolean
|
||||||
|
get() = courseId == null && audience == AUDIENCE_ALL && published
|
||||||
|
|
||||||
|
private enum class AssetDecision {
|
||||||
|
ALLOW,
|
||||||
|
HIDDEN,
|
||||||
|
DENY,
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val COURSE_ROLES = setOf(CourseAccessService.ROLE_STUDENT, CourseAccessService.ROLE_TEACHER)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =
|
internal fun ContentArticleView.toDto(): ContentPublicController.ContentArticleViewDto =
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import jakarta.persistence.Entity
|
|||||||
import jakarta.persistence.Id
|
import jakarta.persistence.Id
|
||||||
import jakarta.persistence.Table
|
import jakarta.persistence.Table
|
||||||
import ru.ysu.lms.common.Ids
|
import ru.ysu.lms.common.Ids
|
||||||
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -41,3 +42,19 @@ class ContentArticle(
|
|||||||
@Column(nullable = false) var position: Int = 0,
|
@Column(nullable = false) var position: Int = 0,
|
||||||
@Column(nullable = false) var published: Boolean = true,
|
@Column(nullable = false) var published: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Файл, на который ссылается статья. Видимость наследуется от статьи:
|
||||||
|
* `article_assets → articles → sections → chapters`.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "article_assets")
|
||||||
|
class ArticleAsset(
|
||||||
|
@Id var id: UUID = Ids.v7(),
|
||||||
|
@Column(name = "article_id") var articleId: UUID? = null,
|
||||||
|
@Column(nullable = false, length = 255) var filename: String = "",
|
||||||
|
@Column(name = "stored_path", nullable = false, length = 512) var storedPath: String = "",
|
||||||
|
@Column(nullable = false, length = 128) var mime: String = "",
|
||||||
|
@Column(nullable = false) var size: Long = 0,
|
||||||
|
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.ysu.lms.academic.repo
|
package ru.ysu.lms.academic.repo
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import ru.ysu.lms.academic.domain.ArticleAsset
|
||||||
import ru.ysu.lms.academic.domain.ContentArticle
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
import ru.ysu.lms.academic.domain.ContentChapter
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
import ru.ysu.lms.academic.domain.ContentSection
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
@@ -56,3 +57,13 @@ interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
|
|||||||
|
|
||||||
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArticleAssetRepository : JpaRepository<ArticleAsset, UUID> {
|
||||||
|
fun findByStoredPathEndingWith(suffix: String): List<ArticleAsset>
|
||||||
|
|
||||||
|
fun findByFilename(filename: String): List<ArticleAsset>
|
||||||
|
|
||||||
|
fun findByArticleId(articleId: UUID): List<ArticleAsset>
|
||||||
|
|
||||||
|
fun deleteByArticleId(articleId: UUID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Backfill связей статья → актив из markdown-ссылок.
|
||||||
|
-- Исторические статьи уже содержат публичные URL /api/v1/content/assets/{name},
|
||||||
|
-- но строки article_assets не создавались при импорте. Восстанавливаем связи,
|
||||||
|
-- чтобы видимость актива наследовалась от статьи/раздела/главы.
|
||||||
|
WITH refs AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
a.id AS article_id,
|
||||||
|
(m.match)[1] AS asset_name
|
||||||
|
FROM articles a
|
||||||
|
CROSS JOIN LATERAL regexp_matches(
|
||||||
|
a.body_md,
|
||||||
|
'/api/v1/content/assets/([A-Za-z0-9._-]+)',
|
||||||
|
'g'
|
||||||
|
) AS m(match)
|
||||||
|
)
|
||||||
|
INSERT INTO article_assets (article_id, filename, stored_path, mime, size)
|
||||||
|
SELECT r.article_id,
|
||||||
|
r.asset_name,
|
||||||
|
r.asset_name,
|
||||||
|
CASE lower(substring(r.asset_name from '\.([^.]+)$'))
|
||||||
|
WHEN 'png' THEN 'image/png'
|
||||||
|
WHEN 'jpg' THEN 'image/jpeg'
|
||||||
|
WHEN 'jpeg' THEN 'image/jpeg'
|
||||||
|
WHEN 'gif' THEN 'image/gif'
|
||||||
|
WHEN 'svg' THEN 'image/svg+xml'
|
||||||
|
WHEN 'webp' THEN 'image/webp'
|
||||||
|
WHEN 'mp4' THEN 'video/mp4'
|
||||||
|
WHEN 'webm' THEN 'video/webm'
|
||||||
|
WHEN 'ogg' THEN 'video/ogg'
|
||||||
|
WHEN 'ogv' THEN 'video/ogg'
|
||||||
|
ELSE 'application/octet-stream'
|
||||||
|
END,
|
||||||
|
0
|
||||||
|
FROM refs r
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM article_assets aa
|
||||||
|
WHERE aa.article_id = r.article_id
|
||||||
|
AND (
|
||||||
|
aa.filename = r.asset_name
|
||||||
|
OR aa.stored_path = r.asset_name
|
||||||
|
OR right(aa.stored_path, length(r.asset_name)) = r.asset_name
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry
|
||||||
|
import org.springframework.test.context.DynamicPropertySource
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.context.WebApplicationContext
|
||||||
|
import org.testcontainers.junit.jupiter.Container
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
|
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||||
|
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseGroupRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseService
|
||||||
|
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||||
|
import ru.ysu.lms.academic.domain.ArticleAsset
|
||||||
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.repo.ArticleAssetRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наследуемая видимость активов: курсовой актив требует доступ к курсу,
|
||||||
|
* глобальный ALL-актив публичен, а старые несвязанные файлы остаются публичными.
|
||||||
|
*/
|
||||||
|
@Testcontainers
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class ContentAssetAccessIntegrationTest {
|
||||||
|
companion object {
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
@JvmStatic
|
||||||
|
val postgres = PostgreSQLContainer("postgres:18")
|
||||||
|
|
||||||
|
private val assetsDir: Path = Files.createTempDirectory("lms-assets")
|
||||||
|
private val ASSET_BYTES = byteArrayOf(1, 2, 3, 4)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@DynamicPropertySource
|
||||||
|
fun assetsDir(registry: DynamicPropertyRegistry) {
|
||||||
|
registry.add("lms.content.assets-dir") { assetsDir.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var context: WebApplicationContext
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var courseService: CourseService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var chapters: ContentChapterRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var sections: ContentSectionRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var articles: ContentArticleRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var articleAssets: ArticleAssetRepository
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private lateinit var identity: IdentityClient
|
||||||
|
|
||||||
|
private val mockMvc: MockMvc by lazy {
|
||||||
|
MockMvcBuilders
|
||||||
|
.webAppContextSetup(context)
|
||||||
|
.apply<DefaultMockMvcBuilder>(springSecurity())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course asset requires authentication`() {
|
||||||
|
val chapter = seedChapter(courseId = newCourse(), audience = null)
|
||||||
|
val material = seedMaterial(chapter)
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/assets/{name}", name))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course teacher can read asset of own course`() {
|
||||||
|
val courseId = newCourse()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
|
||||||
|
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
|
||||||
|
val response =
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(teacherId, "TEACHER")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
.response
|
||||||
|
|
||||||
|
assertThat(response.contentAsByteArray).isEqualTo(ASSET_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foreign teacher and student are forbidden for course asset`() {
|
||||||
|
val courseId = newCourse()
|
||||||
|
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(UUID.randomUUID()))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
|
||||||
|
).andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
|
||||||
|
).andExpect(status().isForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `course student of current group can read published asset`() {
|
||||||
|
val courseId = newCourse()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
|
||||||
|
val material = seedMaterial(seedChapter(courseId = courseId, audience = null))
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hidden course asset is not served to student but is served to teacher`() {
|
||||||
|
val courseId = newCourse()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString()))
|
||||||
|
val material = seedMaterial(seedChapter(courseId = courseId, audience = null), published = false)
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString()))
|
||||||
|
`when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId))
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
|
||||||
|
).andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(teacherId, "TEACHER")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `published global ALL asset stays public`() {
|
||||||
|
val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL))
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/assets/{name}", name))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy asset without article association stays public`() {
|
||||||
|
val name = storeAsset()
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/assets/{name}", name))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `global STUDENTS asset requires student role`() {
|
||||||
|
val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_STUDENTS))
|
||||||
|
val name = storeAsset()
|
||||||
|
link(material.article, name)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/assets/{name}", name))
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "TEACHER")),
|
||||||
|
).andExpect(status().isForbidden)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(
|
||||||
|
get("/api/v1/content/assets/{name}", name)
|
||||||
|
.with(jwtFor(UUID.randomUUID(), "STUDENT")),
|
||||||
|
).andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `asset is visible when at least one associated article is public`() {
|
||||||
|
val name = storeAsset()
|
||||||
|
val hidden = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL), published = false)
|
||||||
|
val visible = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL))
|
||||||
|
link(hidden.article, name)
|
||||||
|
link(visible.article, name)
|
||||||
|
|
||||||
|
mockMvc
|
||||||
|
.perform(get("/api/v1/content/assets/{name}", name))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newCourse(): UUID =
|
||||||
|
UUID.fromString(
|
||||||
|
courseService
|
||||||
|
.create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID())
|
||||||
|
.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun seedChapter(
|
||||||
|
courseId: UUID?,
|
||||||
|
audience: String?,
|
||||||
|
published: Boolean = true,
|
||||||
|
): ContentChapter =
|
||||||
|
chapters.saveAndFlush(
|
||||||
|
ContentChapter(
|
||||||
|
title = "Глава ${UUID.randomUUID()}",
|
||||||
|
slug = "chapter-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
courseId = courseId,
|
||||||
|
audience = audience,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun seedMaterial(
|
||||||
|
chapter: ContentChapter,
|
||||||
|
published: Boolean = true,
|
||||||
|
): SeededMaterial {
|
||||||
|
val section =
|
||||||
|
sections.saveAndFlush(
|
||||||
|
ContentSection(
|
||||||
|
chapterId = chapter.id,
|
||||||
|
title = "Раздел ${UUID.randomUUID()}",
|
||||||
|
slug = "section-${UUID.randomUUID()}",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val article =
|
||||||
|
articles.saveAndFlush(
|
||||||
|
ContentArticle(
|
||||||
|
sectionId = section.id,
|
||||||
|
title = "Статья ${UUID.randomUUID()}",
|
||||||
|
slug = "article-${UUID.randomUUID()}",
|
||||||
|
bodyMd = "Текст",
|
||||||
|
position = 1,
|
||||||
|
published = published,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SeededMaterial(chapter, section, article)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun link(
|
||||||
|
article: ContentArticle,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
articleAssets.saveAndFlush(
|
||||||
|
ArticleAsset(
|
||||||
|
articleId = article.id,
|
||||||
|
filename = name,
|
||||||
|
storedPath = name,
|
||||||
|
mime = "image/png",
|
||||||
|
size = ASSET_BYTES.size.toLong(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storeAsset(): String {
|
||||||
|
val name = "${UUID.randomUUID()}.png"
|
||||||
|
Files.write(assetsDir.resolve(name), ASSET_BYTES)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jwtFor(
|
||||||
|
subject: UUID,
|
||||||
|
role: String,
|
||||||
|
) = jwt().jwt { it.subject(subject.toString()).claim("role", role) }
|
||||||
|
|
||||||
|
private data class SeededMaterial(
|
||||||
|
val chapter: ContentChapter,
|
||||||
|
val section: ContentSection,
|
||||||
|
val article: ContentArticle,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.springframework.test.context.DynamicPropertySource
|
|||||||
import org.testcontainers.junit.jupiter.Container
|
import org.testcontainers.junit.jupiter.Container
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers
|
import org.testcontainers.junit.jupiter.Testcontainers
|
||||||
import org.testcontainers.postgresql.PostgreSQLContainer
|
import org.testcontainers.postgresql.PostgreSQLContainer
|
||||||
|
import ru.ysu.lms.academic.repo.ArticleAssetRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
@@ -42,9 +43,15 @@ class ContentImportIntegrationTest {
|
|||||||
ZipOutputStream(out).use { zos ->
|
ZipOutputStream(out).use { zos ->
|
||||||
zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md"))
|
zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md"))
|
||||||
zos.write(
|
zos.write(
|
||||||
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\nТело статьи".toByteArray(),
|
(
|
||||||
|
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\n" +
|
||||||
|
"\n\nТело статьи"
|
||||||
|
).toByteArray(),
|
||||||
)
|
)
|
||||||
zos.closeEntry()
|
zos.closeEntry()
|
||||||
|
zos.putNextEntry(ZipEntry("assets/pic.png"))
|
||||||
|
zos.write(byteArrayOf(1, 2, 3))
|
||||||
|
zos.closeEntry()
|
||||||
}
|
}
|
||||||
return out.toByteArray()
|
return out.toByteArray()
|
||||||
}
|
}
|
||||||
@@ -62,12 +69,16 @@ class ContentImportIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var articles: ContentArticleRepository
|
private lateinit var articles: ContentArticleRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var articleAssets: ArticleAssetRepository
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `re-import does not duplicate chapter, section or article`() {
|
fun `re-import does not duplicate chapter, section or article`() {
|
||||||
val zip = articleZip()
|
val zip = articleZip()
|
||||||
|
|
||||||
val first = importer.importZip("content.zip", zip, sync = false)
|
val first = importer.importZip("content.zip", zip, sync = false)
|
||||||
assertThat(first.chapters).isEqualTo(1)
|
assertThat(first.chapters).isEqualTo(1)
|
||||||
|
assertThat(first.assets).isEqualTo(1)
|
||||||
|
|
||||||
val chapter = chapters.findByTitleIgnoreCase("Тестовая")
|
val chapter = chapters.findByTitleIgnoreCase("Тестовая")
|
||||||
assertThat(chapter).isNotNull
|
assertThat(chapter).isNotNull
|
||||||
@@ -76,6 +87,12 @@ class ContentImportIntegrationTest {
|
|||||||
val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья")
|
val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья")
|
||||||
assertThat(article).isNotNull
|
assertThat(article).isNotNull
|
||||||
assertThat(article!!.slug).isEqualTo("test-article")
|
assertThat(article!!.slug).isEqualTo("test-article")
|
||||||
|
assertThat(article.bodyMd).contains("/api/v1/content/assets/")
|
||||||
|
assertThat(articleAssets.findByArticleId(article.id)).hasSize(1)
|
||||||
|
val firstAsset = articleAssets.findByArticleId(article.id).single()
|
||||||
|
assertThat(firstAsset.filename).isEqualTo("pic.png")
|
||||||
|
assertThat(firstAsset.mime).isEqualTo("image/png")
|
||||||
|
assertThat(firstAsset.size).isEqualTo(3)
|
||||||
|
|
||||||
// повторный импорт того же архива обновляет существующие записи, а не создаёт новые
|
// повторный импорт того же архива обновляет существующие записи, а не создаёт новые
|
||||||
importer.importZip("content.zip", zip, sync = false)
|
importer.importZip("content.zip", zip, sync = false)
|
||||||
@@ -83,5 +100,8 @@ class ContentImportIntegrationTest {
|
|||||||
assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id)
|
assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id)
|
||||||
assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id)).hasSize(1)
|
assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id)).hasSize(1)
|
||||||
assertThat(articles.findBySectionIdOrderByPositionAscIdAsc(section.id)).hasSize(1)
|
assertThat(articles.findBySectionIdOrderByPositionAscIdAsc(section.id)).hasSize(1)
|
||||||
|
val assetsAfterReimport = articleAssets.findByArticleId(article.id)
|
||||||
|
assertThat(assetsAfterReimport).hasSize(1)
|
||||||
|
assertThat(assetsAfterReimport.single().id).isNotEqualTo(firstAsset.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package ru.ysu.lms.academic.content
|
package ru.ysu.lms.academic.content
|
||||||
|
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.runs
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -9,6 +11,7 @@ import org.junit.jupiter.api.io.TempDir
|
|||||||
import ru.ysu.lms.academic.domain.ContentArticle
|
import ru.ysu.lms.academic.domain.ContentArticle
|
||||||
import ru.ysu.lms.academic.domain.ContentChapter
|
import ru.ysu.lms.academic.domain.ContentChapter
|
||||||
import ru.ysu.lms.academic.domain.ContentSection
|
import ru.ysu.lms.academic.domain.ContentSection
|
||||||
|
import ru.ysu.lms.academic.repo.ArticleAssetRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
import ru.ysu.lms.academic.repo.ContentArticleRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
import ru.ysu.lms.academic.repo.ContentChapterRepository
|
||||||
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
import ru.ysu.lms.academic.repo.ContentSectionRepository
|
||||||
@@ -25,6 +28,7 @@ class ContentImporterTest {
|
|||||||
private val chapters = mockk<ContentChapterRepository>()
|
private val chapters = mockk<ContentChapterRepository>()
|
||||||
private val sections = mockk<ContentSectionRepository>()
|
private val sections = mockk<ContentSectionRepository>()
|
||||||
private val articles = mockk<ContentArticleRepository>()
|
private val articles = mockk<ContentArticleRepository>()
|
||||||
|
private val assetLinks = mockk<ArticleAssetRepository>()
|
||||||
|
|
||||||
private fun importer(): ContentImporter {
|
private fun importer(): ContentImporter {
|
||||||
every { chapters.findByTitleIgnoreCase(any()) } returns null
|
every { chapters.findByTitleIgnoreCase(any()) } returns null
|
||||||
@@ -39,9 +43,11 @@ class ContentImporterTest {
|
|||||||
|
|
||||||
every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null
|
every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null
|
||||||
every { articles.findBySectionIdAndSlug(any(), any()) } returns null
|
every { articles.findBySectionIdAndSlug(any(), any()) } returns null
|
||||||
every { articles.save(any()) } answers { firstArg() }
|
every { articles.saveAndFlush(any()) } answers { firstArg() }
|
||||||
|
every { assetLinks.deleteByArticleId(any()) } just runs
|
||||||
|
every { assetLinks.save(any()) } answers { firstArg() }
|
||||||
|
|
||||||
return ContentImporter(chapters, sections, articles, AssetStorage(tempDir.toString()))
|
return ContentImporter(chapters, sections, articles, assetLinks, AssetStorage(tempDir.toString()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun articleZip(
|
private fun articleZip(
|
||||||
@@ -63,7 +69,7 @@ class ContentImporterTest {
|
|||||||
val savedChapter = slot<ContentChapter>()
|
val savedChapter = slot<ContentChapter>()
|
||||||
val savedArticle = slot<ContentArticle>()
|
val savedArticle = slot<ContentArticle>()
|
||||||
every { chapters.save(capture(savedChapter)) } answers { firstArg() }
|
every { chapters.save(capture(savedChapter)) } answers { firstArg() }
|
||||||
every { articles.save(capture(savedArticle)) } answers { firstArg() }
|
every { articles.saveAndFlush(capture(savedArticle)) } answers { firstArg() }
|
||||||
|
|
||||||
val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false)
|
val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false)
|
||||||
|
|
||||||
|
|||||||
@@ -131,12 +131,14 @@ fun ApiClient.deleteContentArticle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ApiClient.contentAsset(name: String): DownloadDto {
|
fun ApiClient.contentAsset(
|
||||||
|
name: String,
|
||||||
|
token: String? = null,
|
||||||
|
): DownloadDto {
|
||||||
val entity =
|
val entity =
|
||||||
call {
|
call {
|
||||||
client
|
val spec = client.get().uri("/api/v1/content/assets/{name}", name)
|
||||||
.get()
|
(if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec)
|
||||||
.uri("/api/v1/content/assets/{name}", name)
|
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.toEntity(ByteArray::class.java)
|
.toEntity(ByteArray::class.java)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.ysu.lms.web.learn
|
package ru.ysu.lms.web.learn
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
@@ -7,10 +8,13 @@ import org.springframework.web.bind.annotation.PathVariable
|
|||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import ru.ysu.lms.web.api.ApiClient
|
import ru.ysu.lms.web.api.ApiClient
|
||||||
import ru.ysu.lms.web.api.contentAsset
|
import ru.ysu.lms.web.api.contentAsset
|
||||||
|
import ru.ysu.lms.web.session.SessionKeys
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проксирует картинки статей (public path) через BFF: страницы /learn/...
|
* Проксирует картинки статей через BFF: страницы /learn/... отдаются web,
|
||||||
* отдаются web, а src картинок указывает на этот же хост.
|
* а src картинок указывает на этот же хост. Токен из web-сессии нужен,
|
||||||
|
* чтобы academic-service проверил доступ к активам курсовых статей;
|
||||||
|
* для публичных страниц токена нет — прокси остаётся анонимным.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
class ContentAssetProxy(
|
class ContentAssetProxy(
|
||||||
@@ -19,8 +23,10 @@ class ContentAssetProxy(
|
|||||||
@GetMapping("/api/v1/content/assets/{name}")
|
@GetMapping("/api/v1/content/assets/{name}")
|
||||||
fun asset(
|
fun asset(
|
||||||
@PathVariable name: String,
|
@PathVariable name: String,
|
||||||
|
request: HttpServletRequest,
|
||||||
): ResponseEntity<ByteArray> {
|
): ResponseEntity<ByteArray> {
|
||||||
val asset = api.contentAsset(name)
|
val token = request.getSession(false)?.getAttribute(SessionKeys.TOKEN) as? String
|
||||||
|
val asset = api.contentAsset(name, token)
|
||||||
val media =
|
val media =
|
||||||
runCatching { MediaType.parseMediaType(asset.contentType) }
|
runCatching { MediaType.parseMediaType(asset.contentType) }
|
||||||
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package ru.ysu.lms.web.api
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import tools.jackson.databind.ObjectMapper
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
class ApiClientContentAssetTest {
|
||||||
|
@Test
|
||||||
|
fun `contentAsset sends bearer token when session token is present`() {
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAssetServer(authorization) { baseUrl ->
|
||||||
|
val asset = ApiClient(baseUrl, ObjectMapper()).contentAsset("picture.png", "session-token")
|
||||||
|
|
||||||
|
assertThat(authorization.get()).isEqualTo("Bearer session-token")
|
||||||
|
assertThat(asset.bytes).containsExactly(1, 2, 3)
|
||||||
|
assertThat(asset.contentType).isEqualTo("image/png")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `contentAsset stays anonymous for public pages without a session token`() {
|
||||||
|
val authorization = AtomicReference<String?>()
|
||||||
|
withAssetServer(authorization) { baseUrl ->
|
||||||
|
val asset = ApiClient(baseUrl, ObjectMapper()).contentAsset("picture.png")
|
||||||
|
|
||||||
|
assertThat(authorization.get()).isNull()
|
||||||
|
assertThat(asset.bytes).containsExactly(1, 2, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withAssetServer(
|
||||||
|
authorization: AtomicReference<String?>,
|
||||||
|
block: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||||
|
server.createContext("/api/v1/content/assets/") { exchange ->
|
||||||
|
authorization.set(exchange.requestHeaders.getFirst("Authorization"))
|
||||||
|
val body = byteArrayOf(1, 2, 3)
|
||||||
|
exchange.responseHeaders.add("Content-Type", "image/png")
|
||||||
|
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(body) }
|
||||||
|
}
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
block("http://127.0.0.1:${server.address.port}")
|
||||||
|
} finally {
|
||||||
|
server.stop(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user