diff --git a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurging.kt b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurging.kt new file mode 100644 index 0000000000..9ede3b7779 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurging.kt @@ -0,0 +1,58 @@ +package io.tolgee.component.contentDelivery.cachePurging.bunny + +import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurging +import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties +import io.tolgee.model.contentDelivery.ContentDeliveryConfig +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.web.client.RestTemplate +import java.net.URLEncoder + +class BunnyContentDeliveryCachePurging( + private val config: ContentDeliveryBunnyProperties, + private val restTemplate: RestTemplate, +) : ContentDeliveryCachePurging { + override fun purgeForPaths( + contentDeliveryConfig: ContentDeliveryConfig, + paths: Set, + ) { + executePurgeRequest(contentDeliveryConfig) + } + + val prefix by lazy { + config.urlPrefix?.removeSuffix("/") ?: "" + } + + private fun executePurgeRequest(contentDeliveryConfig: ContentDeliveryConfig) { + val entity: HttpEntity = getHttpEntity() + val encodedPath = URLEncoder.encode("$prefix/${contentDeliveryConfig.slug}/*", Charsets.UTF_8) + + val url = "https://api.bunny.net/purge?url=$encodedPath" + + val response = + restTemplate.exchange( + url, + HttpMethod.GET, + entity, + String::class.java, + ) + + if (!response.statusCode.is2xxSuccessful) { + throw IllegalStateException("Purging failed with status code ${response.statusCode}") + } + } + + private fun getHttpEntity(): HttpEntity { + val headers = getHeaders() + return HttpEntity(null, headers) + } + + private fun getHeaders(): HttpHeaders { + val headers = HttpHeaders() + headers.contentType = MediaType.APPLICATION_JSON + headers.set("AccessKey", "${config.apiKey}") + return headers + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurgingFactory.kt b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurgingFactory.kt new file mode 100644 index 0000000000..68eecc29c1 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/bunny/BunnyContentDeliveryCachePurgingFactory.kt @@ -0,0 +1,18 @@ +package io.tolgee.component.contentDelivery.cachePurging.bunny + +import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory +import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class BunnyContentDeliveryCachePurgingFactory( + private val restTemplate: RestTemplate, +) : ContentDeliveryCachePurgingFactory { + override fun create(config: Any): BunnyContentDeliveryCachePurging { + return BunnyContentDeliveryCachePurging( + config as ContentDeliveryBunnyProperties, + restTemplate, + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/cloudflare/CloudflareContentDeliveryCachePurging.kt b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/cloudflare/CloudflareContentDeliveryCachePurging.kt index 085f0df563..ee8ca02f9b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/cloudflare/CloudflareContentDeliveryCachePurging.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/cloudflare/CloudflareContentDeliveryCachePurging.kt @@ -22,24 +22,7 @@ class CloudflareContentDeliveryCachePurging( executePurgeRequest(bodies) } - private fun getChunkedBody( - paths: Set, - contentDeliveryConfig: ContentDeliveryConfig, - ): List>> { - return paths.map { - "$prefix/${contentDeliveryConfig.slug}/$it" - } - .chunked(config.maxFilesPerRequest) - .map { urls -> - mapOf("files" to urls) - } - } - - val prefix by lazy { - config.urlPrefix?.removeSuffix("/") ?: "" - } - - private fun executePurgeRequest(bodies: List>>) { + private fun executePurgeRequest(bodies: List>>>) { bodies.forEach { body -> val entity: HttpEntity = getHttpEntity(body) @@ -59,7 +42,49 @@ class CloudflareContentDeliveryCachePurging( } } - private fun getHttpEntity(body: Map>): HttpEntity { + private fun getChunkedBody( + paths: Set, + contentDeliveryConfig: ContentDeliveryConfig, + ): List>>> { + return paths.flatMap { + getFileItems(contentDeliveryConfig, it) + } + .chunked(config.maxFilesPerRequest) + .map { fileItems -> + mapOf("files" to fileItems) + } + } + + private fun getFileItems( + contentDeliveryConfig: ContentDeliveryConfig, + path: String, + ): List> { + return origins.map { origin -> + val map = + mutableMapOf( + "url" to "$prefix/${contentDeliveryConfig.slug}/$path", + ) + + if (origin != null) { + map["headers"] = + mapOf( + "Origin" to origin, + ) + } + + map + } + } + + val origins by lazy { + config.origins?.split(",") ?: listOf(null) + } + + val prefix by lazy { + config.urlPrefix?.removeSuffix("/") ?: "" + } + + private fun getHttpEntity(body: Any): HttpEntity { val headers = getHeaders() val jsonBody = jacksonObjectMapper().writeValueAsString(body) return HttpEntity(jsonBody, headers) diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryBunnyProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryBunnyProperties.kt new file mode 100644 index 0000000000..1062538c4d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryBunnyProperties.kt @@ -0,0 +1,17 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.model.contentDelivery.ContentDeliveryCachePurgingType +import io.tolgee.model.contentDelivery.ContentDeliveryPurgingConfig +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "tolgee.content-delivery.cache-purging.bunny") +class ContentDeliveryBunnyProperties( + var apiKey: String? = null, + var urlPrefix: String? = null, +) : ContentDeliveryPurgingConfig { + override val enabled: Boolean + get() = !apiKey.isNullOrEmpty() && !urlPrefix.isNullOrEmpty() + + override val contentDeliveryCachePurgingType: ContentDeliveryCachePurgingType + get() = ContentDeliveryCachePurgingType.BUNNY +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryCloudflareProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryCloudflareProperties.kt index a404821836..1c74648931 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryCloudflareProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryCloudflareProperties.kt @@ -10,6 +10,16 @@ class ContentDeliveryCloudflareProperties( var apiKey: String? = null, var urlPrefix: String? = null, var zoneId: String? = null, + @DocProperty( + "If cache is filled with specific Origin header, it can be purged only if the purge request " + + "specifies the same Origin header. Here you can specify comma separated list of origins." + + "\n" + + "e.g. `https://example.com,https://example2.com`" + + "\n\n" + + "Read more in the Cloudflare " + + "[docs](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/).", + ) + var origins: String? = null, @DocProperty( "Number of paths to purge in one request. " + "(Cloudflare limit is 30 now, but it might be subject to change)", diff --git a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryCachePurgingType.kt b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryCachePurgingType.kt index 5943653eca..eea645f4a8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryCachePurgingType.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryCachePurgingType.kt @@ -2,10 +2,12 @@ package io.tolgee.model.contentDelivery import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureContentDeliveryCachePurgingFactory +import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurgingFactory import io.tolgee.component.contentDelivery.cachePurging.cloudflare.CloudflareContentDeliveryCachePurgingFactory import kotlin.reflect.KClass enum class ContentDeliveryCachePurgingType(val factory: KClass) { AZURE_FRONT_DOOR(AzureContentDeliveryCachePurgingFactory::class), CLOUDFLARE(CloudflareContentDeliveryCachePurgingFactory::class), + BUNNY(BunnyContentDeliveryCachePurgingFactory::class), } diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/BunnyContentStorageConfigCachePurgingTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/BunnyContentStorageConfigCachePurgingTest.kt new file mode 100644 index 0000000000..b3f8803bd6 --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/BunnyContentStorageConfigCachePurgingTest.kt @@ -0,0 +1,64 @@ +package io.tolgee.unit.cachePurging + +import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurging +import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties +import io.tolgee.model.contentDelivery.ContentDeliveryConfig +import io.tolgee.testing.assert +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.mockito.invocation.Invocation +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatusCode +import org.springframework.http.ResponseEntity +import org.springframework.web.client.RestTemplate + +class BunnyContentStorageConfigCachePurgingTest() { + @Test + fun `correctly purges`() { + val config = + ContentDeliveryBunnyProperties( + urlPrefix = "fake-url-prefix", + apiKey = "token", + ) + val restTemplateMock: RestTemplate = mock() + val purging = BunnyContentDeliveryCachePurging(config, restTemplateMock) + val responseMock: ResponseEntity<*> = Mockito.mock(ResponseEntity::class.java) + whenever(restTemplateMock.exchange(any(), any(), any(), eq(String::class.java))).doAnswer { + responseMock as ResponseEntity + } + whenever(responseMock.statusCode).thenReturn(HttpStatusCode.valueOf(200)) + val contentDeliveryConfig = mock() + whenever(contentDeliveryConfig.slug).thenReturn("fake-slug") + + purging.purgeForPaths( + contentDeliveryConfig = contentDeliveryConfig, + paths = (1..15).map { "fake-path-$it" }.toSet(), + ) + + val invocations = Mockito.mockingDetails(restTemplateMock).invocations + val invocation = invocations.single() + assertUrl(invocation) + assertAuthorizationHeader(invocation) + } + + private fun assertAuthorizationHeader(invocation: Invocation) { + val httpEntity = getHttpEntity(invocation) + val headers = httpEntity.headers + headers["AccessKey"].assert.isEqualTo(listOf("token")) + } + + private fun getHttpEntity(invocation: Invocation) = invocation.arguments[2] as HttpEntity<*> + + private fun assertUrl(invocation: Invocation) { + val url = invocation.arguments[0] + url.assert.isEqualTo( + "https://api.bunny.net/purge?url=fake-url-prefix%2Ffake-slug%2F*", + ) + } +} diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/CloudflareContentStorageConfigCachePurgingTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/CloudflareContentStorageConfigCachePurgingTest.kt index 55a83e29f4..ab3ec24e13 100644 --- a/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/CloudflareContentStorageConfigCachePurgingTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/CloudflareContentStorageConfigCachePurgingTest.kt @@ -30,6 +30,7 @@ class CloudflareContentStorageConfigCachePurgingTest() { urlPrefix = "fake-url-prefix", maxFilesPerRequest = 10, apiKey = "token", + origins = "fake-origin,fake-origin2", ) val restTemplateMock: RestTemplate = mock() val purging = CloudflareContentDeliveryCachePurging(config, restTemplateMock) @@ -51,14 +52,33 @@ class CloudflareContentStorageConfigCachePurgingTest() { assertFiles(firstInvocation) { isArray .hasSize(10) - .contains("fake-url-prefix/fake-slug/fake-path-1") + node("[0]") { + node("headers").isEqualTo( + mapOf( + "Origin" to "fake-origin", + ), + ) + node("url").isEqualTo("fake-url-prefix/fake-slug/fake-path-1") + } + node("[1]") { + node("headers").isEqualTo( + mapOf( + "Origin" to "fake-origin2", + ), + ) + } } assertUrl(firstInvocation) assertAuthorizationHeader(firstInvocation) - assertFiles(invocations.toList()[1]) { - isArray.hasSize(5) + val invocationList = invocations.toList() + assertFiles(invocationList[1]) { + isArray.hasSize(10) + } + assertFiles(invocationList[2]) { + isArray.hasSize(10) } + invocationList.assert.hasSize(3) } private fun assertFiles(