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>? {
|
fun read(publicName: String): Pair<Path, String>? {
|
||||||
val clean = publicName.substringAfterLast('/')
|
val clean = publicName.substringAfterLast('/')
|
||||||
val target = rootDir.resolve(clean).normalize()
|
val target = rootDir.resolve(clean).normalize()
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class ContentService(
|
|||||||
private val articles: ContentArticleRepository,
|
private val articles: ContentArticleRepository,
|
||||||
private val articleAssets: ArticleAssetRepository,
|
private val articleAssets: ArticleAssetRepository,
|
||||||
private val importer: ContentImporter,
|
private val importer: ContentImporter,
|
||||||
|
private val assetStorage: AssetStorage,
|
||||||
) {
|
) {
|
||||||
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
/** Публичное дерево: только глобальные главы с аудиторией ALL. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -198,6 +199,19 @@ class ContentService(
|
|||||||
.filter { it.matchesAssetName(name) }
|
.filter { it.matchesAssetName(name) }
|
||||||
.mapNotNull { assetAccess(it) }
|
.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? {
|
private fun assetAccess(asset: ArticleAsset): ArticleAssetAccess? {
|
||||||
val articleId = asset.articleId ?: return null
|
val articleId = asset.articleId ?: return null
|
||||||
val article = articles.findById(articleId).orElse(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(
|
private fun articleView(
|
||||||
ch: ContentChapter,
|
ch: ContentChapter,
|
||||||
sectionSlug: String,
|
sectionSlug: String,
|
||||||
@@ -362,3 +373,10 @@ class ContentService(
|
|||||||
sync: Boolean = true,
|
sync: Boolean = true,
|
||||||
): ContentImportResult = importer.importZip(filename, bytes, sync)
|
): 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")
|
@GetMapping("/tree/all")
|
||||||
fun treeAll() = contentService.treeAll()
|
fun treeAll() = contentService.treeAll()
|
||||||
|
|
||||||
|
/** Файлы хранилища без связи со статьями — для очистки legacy-активов. */
|
||||||
|
@GetMapping("/legacy-assets")
|
||||||
|
fun legacyAssets(): List<String> = contentService.unlinkedAssetNames()
|
||||||
|
|
||||||
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
|
/** Просмотр статьи без учёта публикации — для предпросмотра админом. */
|
||||||
@GetMapping("/all/{chapter}/{section}/{article}")
|
@GetMapping("/all/{chapter}/{section}/{article}")
|
||||||
fun articleAll(
|
fun articleAll(
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import java.util.UUID
|
|||||||
* Публичное чтение учебных материалов.
|
* Публичное чтение учебных материалов.
|
||||||
*
|
*
|
||||||
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
|
* `tree` и статьи глобальных глав с аудиторией ALL доступны без аутентификации.
|
||||||
* Активы без записи в `article_assets` исторически публичны (legacy-ссылки);
|
* Активы без записи в `article_assets` по умолчанию не публичны (secure-by-default):
|
||||||
* привязанные активы наследуют видимость статьи, раздела и главы.
|
* их может скачать только админ для очистки. Привязанные активы наследуют
|
||||||
|
* видимость статьи, раздела и главы.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/content")
|
@RequestMapping("/api/v1/content")
|
||||||
@@ -63,7 +64,11 @@ class ContentPublicController(
|
|||||||
@AuthenticationPrincipal jwt: Jwt?,
|
@AuthenticationPrincipal jwt: Jwt?,
|
||||||
): ResponseEntity<FileSystemResource> {
|
): ResponseEntity<FileSystemResource> {
|
||||||
val accesses = contentService.assetAccesses(name)
|
val accesses = contentService.assetAccesses(name)
|
||||||
if (accesses.isNotEmpty()) requireAssetAccess(accesses, jwt)
|
if (accesses.isEmpty()) {
|
||||||
|
requireLegacyAssetAccess(jwt)
|
||||||
|
} else {
|
||||||
|
requireAssetAccess(accesses, jwt)
|
||||||
|
}
|
||||||
val (path, mime) =
|
val (path, mime) =
|
||||||
assetStorage.read(name)
|
assetStorage.read(name)
|
||||||
?: return ResponseEntity.notFound().build()
|
?: return ResponseEntity.notFound().build()
|
||||||
@@ -84,6 +89,17 @@ class ContentPublicController(
|
|||||||
return builder.body(FileSystemResource(path))
|
return builder.body(FileSystemResource(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy-файл без связи со статьёй не публичен: аноним и обычный пользователь
|
||||||
|
* получают 404, чтобы не раскрывать существование файла. Админ может скачать
|
||||||
|
* его для очистки хранилища.
|
||||||
|
*/
|
||||||
|
private fun requireLegacyAssetAccess(jwt: Jwt?) {
|
||||||
|
if (jwt?.getClaimAsString("role") != CourseAccessService.ROLE_ADMIN) {
|
||||||
|
throw NotFoundException("Файл не найден")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Аноним видит только глобальный опубликованный актив аудитории ALL.
|
* Аноним видит только глобальный опубликованный актив аудитории ALL.
|
||||||
* Аутентифицированный — если видима хотя бы одна связанная статья.
|
* Аутентифицированный — если видима хотя бы одна связанная статья.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import java.util.UUID
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Наследуемая видимость активов: курсовой актив требует доступ к курсу,
|
* Наследуемая видимость активов: курсовой актив требует доступ к курсу,
|
||||||
* глобальный ALL-актив публичен, а старые несвязанные файлы остаются публичными.
|
* глобальный ALL-актив публичен, а старые несвязанные файлы доступны только админу.
|
||||||
*/
|
*/
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@@ -81,6 +81,9 @@ class ContentAssetAccessIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var articleAssets: ArticleAssetRepository
|
private lateinit var articleAssets: ArticleAssetRepository
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var contentService: ContentService
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private lateinit var identity: IdentityClient
|
private lateinit var identity: IdentityClient
|
||||||
|
|
||||||
@@ -199,12 +202,47 @@ class ContentAssetAccessIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `legacy asset without article association stays public`() {
|
fun `legacy asset without article association is not public`() {
|
||||||
val name = storeAsset()
|
val name = storeAsset()
|
||||||
|
|
||||||
mockMvc
|
mockMvc
|
||||||
.perform(get("/api/v1/content/assets/{name}", name))
|
.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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user