diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt index 2c7d86d..f4f2088 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAudience.kt @@ -2,3 +2,5 @@ package ru.ysu.lms.academic.content /** Аудитория глобальной главы, доступной без авторизации. */ const val AUDIENCE_ALL = "ALL" +const val AUDIENCE_STUDENTS = "STUDENTS" +const val AUDIENCE_TEACHERS = "TEACHERS" diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt index cdaebc5..0df3e14 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentImporter.kt @@ -2,9 +2,11 @@ package ru.ysu.lms.academic.content import org.springframework.stereotype.Service 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.ContentChapter 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.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentSectionRepository @@ -27,6 +29,7 @@ class ContentImporter( private val chapters: ContentChapterRepository, private val sections: ContentSectionRepository, private val articles: ContentArticleRepository, + private val assetLinks: ArticleAssetRepository, private val assets: AssetStorage, ) { @Transactional @@ -43,9 +46,9 @@ class ContentImporter( if (contentFiles.isEmpty()) { throw BadRequestException("В архиве нет каталога chapters/ с .md файлами") } - val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath)) + val storedAssets = storeAssets(files.filterKeys(::isAssetsPath)) val parsed = parseArticles(contentFiles) - return mergeIntoDb(parsed, linkRewrite, sync) + return mergeIntoDb(parsed, storedAssets, sync) } private fun requireZip(filename: String) { @@ -54,22 +57,22 @@ class ContentImporter( private fun mergeIntoDb( parsed: List, - linkRewrite: Map, + storedAssets: StoredAssets, sync: Boolean, ): ContentImportResult { val state = ImportState() for (p in parsed) { val chapter = chapterFor(p, state) val section = sectionFor(chapter, p, state) - val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) } - upsertArticle(section, p, body, state) + val body = storedAssets.rewrite(p.body) + upsertArticle(section, p, body, storedAssets, state) } val removed = if (sync) removeMissing(state) else 0 return ContentImportResult( chapters = state.chapters, sections = state.sections, articles = state.articles, - assets = linkRewrite.size, + assets = storedAssets.refs.size, removed = removed, ) } @@ -136,6 +139,7 @@ class ContentImporter( section: ContentSection, p: ParsedArticle, body: String, + storedAssets: StoredAssets, state: ImportState, ) { val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}" @@ -148,11 +152,33 @@ class ContentImporter( bodyMd = body position = p.articlePos } - articles.save(article) + // saveAndFlush гарантирует строку articles до вставки article_assets (FK). + articles.saveAndFlush(article) + replaceAssetLinks(article, body, storedAssets) state.seenArticles.add(article.id) 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 = state.touchedChapters.sumOf { chapterId -> @@ -180,15 +206,22 @@ class ContentImporter( return removed } - private fun storeAssets(assetFiles: Map): Map { - val linkRewrite = mutableMapOf() + private fun storeAssets(assetFiles: Map): StoredAssets { + val refs = mutableListOf() for ((path, data) in assetFiles) { val name = path.substringAfterLast('/') if (name.isBlank()) continue 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/") @@ -297,6 +330,22 @@ class ContentImporter( 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, + ) { + fun rewrite(body: String): String = refs.fold(body) { acc, ref -> acc.replace("assets/${ref.originalName}", ref.url) } + + fun referencedIn(body: String): List = refs.filter { body.contains(it.url) } + } + private data class ParsedArticle( val chapterPos: Int, val chapterDir: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index dca6163..b5fc765 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -2,7 +2,9 @@ package ru.ysu.lms.academic.content import org.springframework.stereotype.Service 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.repo.ArticleAssetRepository import ru.ysu.lms.academic.repo.ContentArticleRepository import ru.ysu.lms.academic.repo.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentSectionRepository @@ -60,6 +62,17 @@ data class ContentImportResult( 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 sections: ContentSectionRepository, private val articles: ContentArticleRepository, + private val articleAssets: ArticleAssetRepository, private val importer: ContentImporter, ) { /** Публичное дерево: только глобальные главы с аудиторией ALL. */ @@ -172,6 +186,38 @@ class ContentService( return articleView(ch, sectionSlug, articleSlug, includeHidden) } + /** + * Все статьи, к которым привязан актив, с наследуемой видимостью. + * Один файл может использоваться несколькими статьями — решение принимает + * вызывающая сторона по правилу «доступен, если видима хотя бы одна». + */ + @Transactional(readOnly = true) + fun assetAccesses(name: String): List = + (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( ch: ContentChapter, sectionSlug: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt index f38f19c..21ca561 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentPublicController.kt @@ -3,20 +3,37 @@ package ru.ysu.lms.academic.controller import org.springframework.core.io.FileSystemResource import org.springframework.http.MediaType 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.PathVariable import org.springframework.web.bind.annotation.RequestMapping 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.ContentArticleView 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 @RequestMapping("/api/v1/content") class ContentPublicController( private val contentService: ContentService, private val assetStorage: AssetStorage, + private val accessService: CourseAccessService, ) { @GetMapping("/tree") fun tree() = contentService.tree() @@ -43,7 +60,10 @@ class ContentPublicController( @GetMapping("/assets/{name}") fun asset( @PathVariable name: String, + @AuthenticationPrincipal jwt: Jwt?, ): ResponseEntity { + val accesses = contentService.assetAccesses(name) + if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt) val (path, mime) = assetStorage.read(name) ?: return ResponseEntity.notFound().build() @@ -63,6 +83,98 @@ class ContentPublicController( builder.header("X-Content-Type-Options", "nosniff") return builder.body(FileSystemResource(path)) } + + /** + * Аноним видит только глобальный опубликованный актив аудитории ALL. + * Аутентифицированный — если видима хотя бы одна связанная статья. + * Скрытые материалы отдают 404, чтобы не раскрывать их существование. + */ + private fun requireAssetAccess( + accesses: List, + jwt: Jwt?, + ) { + if (jwt == null) { + requireAnonymousAssetAccess(accesses) + } else { + requireAuthenticatedAssetAccess(accesses, jwt) + } + } + + private fun requireAnonymousAssetAccess(accesses: List) { + if (accesses.any { it.isPublic }) return + throw NotFoundException("Файл не найден") + } + + private fun requireAuthenticatedAssetAccess( + accesses: List, + 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 = diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt index 6c7393f..c14a251 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/domain/Content.kt @@ -5,6 +5,7 @@ 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 @@ -41,3 +42,19 @@ class ContentArticle( @Column(nullable = false) var position: Int = 0, @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(), +) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt index 1ce4849..8ce7a25 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/ContentRepositories.kt @@ -1,6 +1,7 @@ package ru.ysu.lms.academic.repo 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.ContentChapter import ru.ysu.lms.academic.domain.ContentSection @@ -56,3 +57,13 @@ interface ContentArticleRepository : JpaRepository { fun findAllByOrderByPositionAscIdAsc(): List } + +interface ArticleAssetRepository : JpaRepository { + fun findByStoredPathEndingWith(suffix: String): List + + fun findByFilename(filename: String): List + + fun findByArticleId(articleId: UUID): List + + fun deleteByArticleId(articleId: UUID) +} diff --git a/services/academic-service/src/main/resources/db/migration/V12__asset_links_backfill.sql b/services/academic-service/src/main/resources/db/migration/V12__asset_links_backfill.sql new file mode 100644 index 0000000..d8c0d66 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V12__asset_links_backfill.sql @@ -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 + ) + ); diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentAssetAccessIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentAssetAccessIntegrationTest.kt new file mode 100644 index 0000000..fb4d242 --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentAssetAccessIntegrationTest.kt @@ -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(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, + ) +} diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImportIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImportIntegrationTest.kt index 675ca6a..249c2ef 100644 --- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImportIntegrationTest.kt +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImportIntegrationTest.kt @@ -10,6 +10,7 @@ import org.springframework.test.context.DynamicPropertySource import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers 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.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentSectionRepository @@ -42,9 +43,15 @@ class ContentImportIntegrationTest { ZipOutputStream(out).use { zos -> zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md")) zos.write( - "---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\nТело статьи".toByteArray(), + ( + "---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\n" + + "![Картинка](assets/pic.png)\n\nТело статьи" + ).toByteArray(), ) zos.closeEntry() + zos.putNextEntry(ZipEntry("assets/pic.png")) + zos.write(byteArrayOf(1, 2, 3)) + zos.closeEntry() } return out.toByteArray() } @@ -62,12 +69,16 @@ class ContentImportIntegrationTest { @Autowired private lateinit var articles: ContentArticleRepository + @Autowired + private lateinit var articleAssets: ArticleAssetRepository + @Test fun `re-import does not duplicate chapter, section or article`() { val zip = articleZip() val first = importer.importZip("content.zip", zip, sync = false) assertThat(first.chapters).isEqualTo(1) + assertThat(first.assets).isEqualTo(1) val chapter = chapters.findByTitleIgnoreCase("Тестовая") assertThat(chapter).isNotNull @@ -76,6 +87,12 @@ class ContentImportIntegrationTest { val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья") assertThat(article).isNotNull 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) @@ -83,5 +100,8 @@ class ContentImportIntegrationTest { assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id) assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.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) } } diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt index 1b97732..1f4b19d 100644 --- a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/content/ContentImporterTest.kt @@ -1,7 +1,9 @@ package ru.ysu.lms.academic.content import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.slot import org.assertj.core.api.Assertions.assertThat 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.ContentChapter 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.ContentChapterRepository import ru.ysu.lms.academic.repo.ContentSectionRepository @@ -25,6 +28,7 @@ class ContentImporterTest { private val chapters = mockk() private val sections = mockk() private val articles = mockk() + private val assetLinks = mockk() private fun importer(): ContentImporter { every { chapters.findByTitleIgnoreCase(any()) } returns null @@ -39,9 +43,11 @@ class ContentImporterTest { every { articles.findBySectionIdAndTitleIgnoreCase(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( @@ -63,7 +69,7 @@ class ContentImporterTest { val savedChapter = slot() val savedArticle = slot() 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) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt index 4ff0538..b22bffd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt @@ -131,12 +131,14 @@ fun ApiClient.deleteContentArticle( } } -fun ApiClient.contentAsset(name: String): DownloadDto { +fun ApiClient.contentAsset( + name: String, + token: String? = null, +): DownloadDto { val entity = call { - client - .get() - .uri("/api/v1/content/assets/{name}", name) + val spec = client.get().uri("/api/v1/content/assets/{name}", name) + (if (token != null) spec.header(HttpHeaders.AUTHORIZATION, bearer(token)) else spec) .retrieve() .toEntity(ByteArray::class.java) } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt index fefd3b3..5ae129e 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/learn/ContentAssetProxy.kt @@ -1,5 +1,6 @@ package ru.ysu.lms.web.learn +import jakarta.servlet.http.HttpServletRequest import org.springframework.http.MediaType import org.springframework.http.ResponseEntity 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 ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.contentAsset +import ru.ysu.lms.web.session.SessionKeys /** - * Проксирует картинки статей (public path) через BFF: страницы /learn/... - * отдаются web, а src картинок указывает на этот же хост. + * Проксирует картинки статей через BFF: страницы /learn/... отдаются web, + * а src картинок указывает на этот же хост. Токен из web-сессии нужен, + * чтобы academic-service проверил доступ к активам курсовых статей; + * для публичных страниц токена нет — прокси остаётся анонимным. */ @RestController class ContentAssetProxy( @@ -19,8 +23,10 @@ class ContentAssetProxy( @GetMapping("/api/v1/content/assets/{name}") fun asset( @PathVariable name: String, + request: HttpServletRequest, ): ResponseEntity { - val asset = api.contentAsset(name) + val token = request.getSession(false)?.getAttribute(SessionKeys.TOKEN) as? String + val asset = api.contentAsset(name, token) val media = runCatching { MediaType.parseMediaType(asset.contentType) } .getOrElse { MediaType.APPLICATION_OCTET_STREAM } diff --git a/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientContentAssetTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientContentAssetTest.kt new file mode 100644 index 0000000..06952ac --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientContentAssetTest.kt @@ -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() + 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() + 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, + 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) + } + } +}