feat(security): закрыть legacy-активы от публичного доступа
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
* Аутентифицированный — если видима хотя бы одна связанная статья.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user