feat(security): закрыть legacy-активы от публичного доступа

This commit is contained in:
2026-10-28 13:24:38 +03:00
parent 97149fa59d
commit 08e5a62c0e
5 changed files with 95 additions and 9 deletions

View File

@@ -65,6 +65,16 @@ class AssetStorage(
}
}
/** Имена обычных файлов непосредственно в корне хранилища (без подкаталогов). */
fun listNames(): List<String> =
Files.list(rootDir).use { stream ->
stream
.filter { Files.isRegularFile(it) }
.map { it.fileName.toString() }
.sorted()
.toList()
}
fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize()

View File

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

View File

@@ -71,6 +71,10 @@ class ContentAdminController(
@GetMapping("/tree/all")
fun treeAll() = contentService.treeAll()
/** Файлы хранилища без связи со статьями — для очистки legacy-активов. */
@GetMapping("/legacy-assets")
fun legacyAssets(): List<String> = contentService.unlinkedAssetNames()
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
@GetMapping("/all/{chapter}/{section}/{article}")
fun articleAll(

View File

@@ -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<FileSystemResource> {
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.
* Аутентифицированный — если видима хотя бы одна связанная статья.

View File

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