From 08e5a62c0e1cc5e904f1a2836198c0705d451ab5 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 28 Oct 2026 13:24:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(security):=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20legacy-=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=20=D0=BE=D1=82=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ysu/lms/academic/content/AssetStorage.kt | 10 +++++ .../lms/academic/content/ContentService.kt | 24 ++++++++-- .../controller/ContentAdminController.kt | 4 ++ .../controller/ContentPublicController.kt | 22 ++++++++-- .../ContentAssetAccessIntegrationTest.kt | 44 +++++++++++++++++-- 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt index 1d5f0b1..8869ba2 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/AssetStorage.kt @@ -65,6 +65,16 @@ class AssetStorage( } } + /** Имена обычных файлов непосредственно в корне хранилища (без подкаталогов). */ + fun listNames(): List = + Files.list(rootDir).use { stream -> + stream + .filter { Files.isRegularFile(it) } + .map { it.fileName.toString() } + .sorted() + .toList() + } + fun read(publicName: String): Pair? { val clean = publicName.substringAfterLast('/') val target = rootDir.resolve(clean).normalize() 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 b5fc765..9485878 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 @@ -113,6 +113,7 @@ class ContentService( private val articles: ContentArticleRepository, private val articleAssets: ArticleAssetRepository, private val importer: ContentImporter, + private val assetStorage: AssetStorage, ) { /** Публичное дерево: только глобальные главы с аудиторией ALL. */ @Transactional(readOnly = true) @@ -198,6 +199,19 @@ class ContentService( .filter { it.matchesAssetName(name) } .mapNotNull { assetAccess(it) } + /** + * Файлы в хранилище, на которые не ссылается ни одна запись `article_assets`. + * Нужны админу для очистки legacy-файлов: по умолчанию они не публичны. + */ + @Transactional(readOnly = true) + fun unlinkedAssetNames(): List { + val assets = articleAssets.findAll() + return assetStorage + .listNames() + .filter { name -> assets.none { it.matchesAssetName(name) } } + .sorted() + } + private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? { val articleId = asset.articleId ?: return null val article = articles.findById(articleId).orElse(null) @@ -215,9 +229,6 @@ class ContentService( } } - private fun ArticleAsset.matchesAssetName(name: String): Boolean = - filename == name || storedPath == name || storedPath.substringAfterLast('/') == name - private fun articleView( ch: ContentChapter, sectionSlug: String, @@ -362,3 +373,10 @@ class ContentService( sync: Boolean = true, ): ContentImportResult = importer.importZip(filename, bytes, sync) } + +/** + * Единое правило связи файла с записью `article_assets`: совпадение имени файла, + * полного сохранённого пути или его basename. + */ +internal fun ArticleAsset.matchesAssetName(name: String): Boolean = + filename == name || storedPath == name || storedPath.substringAfterLast('/') == name diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index 5cd24ad..356e957 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -71,6 +71,10 @@ class ContentAdminController( @GetMapping("/tree/all") fun treeAll() = contentService.treeAll() + /** Файлы хранилища без связи со статьями — для очистки legacy-активов. */ + @GetMapping("/legacy-assets") + fun legacyAssets(): List = contentService.unlinkedAssetNames() + /** Просмотр статьи без учёта публикации — для предпросмотра админом. */ @GetMapping("/all/{chapter}/{section}/{article}") fun articleAll( 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 21ca561..db1b1f4 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 @@ -25,8 +25,9 @@ import java.util.UUID * Публичное чтение учебных материалов. * * `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации. - * Активы без записи в `article_assets` исторически публичны (legacy-ссылки); - * привязанные активы наследуют видимость статьи, раздела и главы. + * Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default): + * их может скачать только админ для очистки. Привязанные активы наследуют + * видимость статьи, раздела и главы. */ @RestController @RequestMapping("/api/v1/content") @@ -63,7 +64,11 @@ class ContentPublicController( @AuthenticationPrincipal jwt: Jwt?, ): ResponseEntity { val accesses = contentService.assetAccesses(name) - if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt) + if (accesses.isEmpty()) { + requireLegacyAssetAccess(jwt) + } else { + requireAssetAccess(accesses, jwt) + } val (path, mime) = assetStorage.read(name) ?: return ResponseEntity.notFound().build() @@ -84,6 +89,17 @@ class ContentPublicController( return builder.body(FileSystemResource(path)) } + /** + * Legacy-файл без связи со статьёй не публичен: аноним и обычный пользователь + * получают 404, чтобы не раскрывать существование файла. Админ может скачать + * его для очистки хранилища. + */ + private fun requireLegacyAssetAccess(jwt: Jwt?) { + if (jwt?.getClaimAsString("role") != CourseAccessService.ROLE_ADMIN) { + throw NotFoundException("Файл не найден") + } + } + /** * Аноним видит только глобальный опубликованный актив аудитории ALL. * Аутентифицированный — если видима хотя бы одна связанная статья. 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 index fb4d242..9558295 100644 --- 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 @@ -41,7 +41,7 @@ import java.util.UUID /** * Наследуемая видимость активов: курсовой актив требует доступ к курсу, - * глобальный ALL-актив публичен, а старые несвязанные файлы остаются публичными. + * глобальный ALL-актив публичен, а старые несвязанные файлы доступны только админу. */ @Testcontainers @SpringBootTest @@ -81,6 +81,9 @@ class ContentAssetAccessIntegrationTest { @Autowired private lateinit var articleAssets: ArticleAssetRepository + @Autowired + private lateinit var contentService: ContentService + @MockitoBean private lateinit var identity: IdentityClient @@ -199,12 +202,47 @@ class ContentAssetAccessIntegrationTest { } @Test - fun `legacy asset without article association stays public`() { + fun `legacy asset without article association is not public`() { val name = storeAsset() mockMvc .perform(get("/api/v1/content/assets/{name}", name)) - .andExpect(status().isOk) + .andExpect(status().isNotFound) + + 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(UUID.randomUUID(), "TEACHER")), + ).andExpect(status().isNotFound) + + val response = + mockMvc + .perform( + get("/api/v1/content/assets/{name}", name) + .with(jwtFor(UUID.randomUUID(), "ADMIN")), + ).andExpect(status().isOk) + .andReturn() + .response + + assertThat(response.contentAsByteArray).isEqualTo(ASSET_BYTES) + } + + @Test + fun `unlinkedAssetNames lists stored file until it is linked`() { + val name = storeAsset() + + assertThat(contentService.unlinkedAssetNames()).contains(name) + + val material = seedMaterial(seedChapter(courseId = null, audience = AUDIENCE_ALL)) + link(material.article, name) + + assertThat(contentService.unlinkedAssetNames()).doesNotContain(name) } @Test