feat(academic): защита активов материалов

This commit is contained in:
2026-10-27 19:41:37 +03:00
parent 3fd0d41cf2
commit 9aef82e217
13 changed files with 719 additions and 23 deletions

View File

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

View File

@@ -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<ByteArray> {
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 }

View File

@@ -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<String?>()
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<String?>()
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<String?>,
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)
}
}
}