Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers.batch

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import io.tolgee.component.ProjectLastModifiedManager
import io.tolgee.constants.Message
import io.tolgee.dtos.request.export.ExportParams
import io.tolgee.exceptions.BadRequestException
Expand All @@ -28,6 +29,7 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.WebRequest
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
import java.io.InputStream
import java.io.OutputStream
Expand All @@ -46,39 +48,89 @@ class V2ExportController(
private val languageService: LanguageService,
private val authenticationFacade: AuthenticationFacade,
private val streamingResponseBodyProvider: StreamingResponseBodyProvider,
private val projectLastModifiedManager: ProjectLastModifiedManager,
) {
@GetMapping(value = [""])
@Operation(summary = "Export data")
@Operation(
summary = "Export data",
description = """
Exports project data in various formats (JSON, properties, YAML, etc.).

## HTTP Conditional Requests Support

This endpoint supports HTTP conditional requests using both If-Modified-Since and If-None-Match headers:

- **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date
- **If-None-Match header provided**: The server checks if the project data has changed by comparing the eTag value
- **Data not modified**: Returns HTTP 304 Not Modified with empty body
- **Data modified or no header**: Returns HTTP 200 OK with the exported data, Last-Modified header, and ETag header

The Last-Modified header in the response contains the timestamp of the last project modification,
and the ETag header contains a unique identifier for the current project state. Both can be used
for subsequent conditional requests to avoid unnecessary data transfer when the project hasn't changed.

Cache-Control header is set to max-age=0 to ensure validation on each request.
""",
)
@RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW])
@AllowApiAccess
@ExportApiResponse
fun exportData(
@ParameterObject params: ExportParams,
): ResponseEntity<StreamingResponseBody> {
params.languages =
languageService
.getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id)
.toList()
.map { language -> language.tag }
.toSet()
val exported = exportService.export(projectHolder.project.id, params)
checkExportNotEmpty(exported)
return getExportResponse(params, exported)
request: WebRequest,
): ResponseEntity<StreamingResponseBody>? {
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder ->
params.languages =
languageService
.getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id)
.toList()
.map { language -> language.tag }
.toSet()
val exported = exportService.export(projectHolder.project.id, params)
checkExportNotEmpty(exported)
val preparedResponse = getExportResponse(params, exported)
headersBuilder.headers(preparedResponse.headers)
preparedResponse.body
}
}

@PostMapping(value = [""])
@Operation(
summary = "Export data (post)",
description = """Exports data (post). Useful when exceeding allowed URL size.""",
description = """
Exports project data in various formats (JSON, properties, YAML, etc.).
Useful when exceeding allowed URL size with GET requests.

## HTTP Conditional Requests Support

This endpoint supports HTTP conditional requests using both If-Modified-Since and If-None-Match headers:

- **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date
- **If-None-Match header provided**: The server checks if the project data has changed by comparing the eTag value
- **Data not modified**: Returns HTTP 304 Not Modified with empty body
- **Data modified or no header**: Returns HTTP 200 OK with the exported data, Last-Modified header, and ETag header

Note: This endpoint uses a custom implementation that returns 304 Not Modified for all HTTP methods
(including POST) when conditional headers indicate the data hasn't changed. This differs from Spring's
default behavior which returns 412 for POST requests, but is appropriate here since POST is used only
to accommodate large request parameters, not to modify data.

The Last-Modified header in the response contains the timestamp of the last project modification,
and the ETag header contains a unique identifier for the current project state. Both can be used
for subsequent conditional requests to avoid unnecessary data transfer when the project hasn't changed.

Cache-Control header is set to max-age=0 to ensure validation on each request.
""",
)
@ReadOnlyOperation
@RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW])
@AllowApiAccess
@ExportApiResponse
fun exportPost(
@RequestBody params: ExportParams,
): ResponseEntity<StreamingResponseBody> {
return exportData(params)
request: WebRequest,
): ResponseEntity<StreamingResponseBody>? {
return exportData(params, request)
}

private fun getZipHeaders(projectName: String): HttpHeaders {
Expand All @@ -104,7 +156,7 @@ class V2ExportController(
private fun getExportResponse(
params: ExportParams,
exported: Map<String, InputStream>,
): ResponseEntity<StreamingResponseBody> {
): PreparedResponse {
if (exported.entries.size == 1 && !params.zip) {
return exportSingleFile(exported, params)
}
Expand All @@ -120,30 +172,39 @@ class V2ExportController(
private fun exportSingleFile(
exported: Map<String, InputStream>,
params: ExportParams,
): ResponseEntity<StreamingResponseBody> {
): PreparedResponse {
val (fileName, stream) = exported.entries.first()
val fileNameWithoutSlash = fileName.replace("^/(.*)".toRegex(), "$1")
val headers = getHeaders(fileNameWithoutSlash, params.format.mediaType)

return ResponseEntity.ok().headers(headers).body(
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
IOUtils.copy(stream, out)
stream.close()
out.close()
},
return PreparedResponse(
headers = headers,
body =
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
IOUtils.copy(stream, out)
stream.close()
out.close()
},
)
}

private fun getZipResponseEntity(exported: Map<String, InputStream>): ResponseEntity<StreamingResponseBody> {
private fun getZipResponseEntity(exported: Map<String, InputStream>): PreparedResponse {
val httpHeaders = getZipHeaders(projectHolder.project.name)

return ResponseEntity.ok().headers(httpHeaders).body(
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
streamZipResponse(out, exported)
},
return PreparedResponse(
headers = httpHeaders,
body =
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
streamZipResponse(out, exported)
},
)
}

data class PreparedResponse(
val headers: HttpHeaders,
val body: StreamingResponseBody?,
)

private fun streamZipResponse(
out: OutputStream,
exported: Map<String, InputStream>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import io.tolgee.activity.ActivityService
import io.tolgee.activity.RequestActivity
import io.tolgee.activity.data.ActivityType
import io.tolgee.api.v2.controllers.IController
import io.tolgee.component.ProjectTranslationLastModifiedManager
import io.tolgee.component.ProjectLastModifiedManager
import io.tolgee.constants.Message
import io.tolgee.dtos.queryResults.TranslationHistoryView
import io.tolgee.dtos.request.translation.GetTranslationsParams
Expand Down Expand Up @@ -57,7 +57,6 @@ import org.springframework.data.domain.Sort
import org.springframework.data.web.PagedResourcesAssembler
import org.springframework.data.web.SortDefault
import org.springframework.hateoas.PagedModel
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.WebDataBinder
Expand All @@ -73,7 +72,6 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.TimeUnit

@Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection")
@RestController
Expand Down Expand Up @@ -102,10 +100,10 @@ class TranslationsController(
private val authenticationFacade: AuthenticationFacade,
private val screenshotService: ScreenshotService,
private val activityService: ActivityService,
private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager,
private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade,
private val taskService: ITaskService,
private val translationSuggestionService: TranslationSuggestionService,
private val projectLastModifiedManager: ProjectLastModifiedManager,
) : IController {
@GetMapping(value = ["/{languages}"])
@Operation(
Expand Down Expand Up @@ -162,32 +160,19 @@ When null, resulting file will be a flat key-value object.
filterTag: List<String>? = null,
request: WebRequest,
): ResponseEntity<Map<String, Any>>? {
val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id)
return projectLastModifiedManager.onlyWhenProjectDataChanged(request) {
val permittedTags =
securityService
.filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages)

if (request.checkNotModified(lastModified)) {
return null
}

val permittedTags =
securityService
.filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages)

val response =
translationService.getTranslations(
languageTags = permittedTags,
namespace = ns,
projectId = projectHolder.project.id,
structureDelimiter = request.getStructureDelimiter(),
filterTag = filterTag,
)

return ResponseEntity
.ok()
.lastModified(lastModified)
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS))
.body(
response,
)
}
}

@PutMapping("")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package io.tolgee.component

import io.tolgee.security.ProjectHolder
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.stereotype.Component
import org.springframework.web.context.request.WebRequest
import java.util.concurrent.TimeUnit

/**
* Component responsible for managing HTTP conditional requests based on project data modifications.
*
* This manager implements HTTP conditional request mechanisms (If-Modified-Since/Last-Modified headers
* and If-None-Match/ETag headers) to enable efficient caching of project-related data. It helps reduce
* unnecessary data transfer and processing by allowing clients to cache responses and only receive new
* data when the project has actually been modified.
*/
@Component
class ProjectLastModifiedManager(
private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager,
private val projectHolder: ProjectHolder,
) {
/**
* Executes a function only when the project data has been modified since the client's last request.
*
* This method implements HTTP conditional request handling by:
* 1. Retrieving the last modification timestamp and eTag of the current project
* 2. Checking if the client's If-Modified-Since or If-None-Match headers indicate the data is still current
* 3. If data hasn't changed, returning HTTP 304 Not Modified response with appropriate headers
* 4. If data has changed, executing the provided function and wrapping the result in a ResponseEntity
* with appropriate cache control headers
*
* The response includes:
* - Last-Modified header set to the project's modification timestamp
* - ETag header set to the project's unique identifier
* - Cache-Control header set to max-age=0 to ensure validation on each request
*
*/
fun <T> onlyWhenProjectDataChanged(
request: WebRequest,
fn: (
/**
* Enables setting of additional headers on the response.
*/
headersBuilder: ResponseEntity.HeadersBuilder<*>,
) -> T?,
): ResponseEntity<T>? {
val (lastModified, eTag) = projectTranslationLastModifiedManager.getLastModifiedInfo(projectHolder.project.id)

// Custom conditional request logic that works for all HTTP methods (not just GET/HEAD)
if (isNotModified(request, eTag, lastModified)) {
// Return 304 Not Modified response with proper headers for all HTTP methods
return ResponseEntity
.status(304)
.lastModified(lastModified)
.eTag(eTag)
.cacheControl(DEFAULT_CACHE_CONTROL_HEADER)
.build()
}

val headersBuilder =
ResponseEntity
.ok()
.lastModified(lastModified)
.eTag(eTag)
.cacheControl(DEFAULT_CACHE_CONTROL_HEADER)

val response = fn(headersBuilder)

return headersBuilder
.body(
response,
)
}

/**
* Custom implementation of conditional request checking that works for all HTTP methods.
*
* Unlike Spring's checkNotModified which only returns 304 for GET/HEAD methods,
* this implementation returns true (indicating not modified) for any HTTP method
* when the conditional headers indicate the client already has the current version.
*
* @param request The web request containing conditional headers
* @param eTag The current ETag value for the resource
* @param lastModified The last modification timestamp in milliseconds
* @return true if the resource has not been modified, false otherwise
*/
private fun isNotModified(
request: WebRequest,
eTag: String,
lastModified: Long,
): Boolean {
// Check If-None-Match header (ETag-based conditional)
val ifNoneMatch = request.getHeader("If-None-Match")
if (ifNoneMatch != null) {
// If the ETag matches, the resource hasn't been modified
return ifNoneMatch == eTag || ifNoneMatch == "\"$eTag\""
}

// Check If-Modified-Since header (timestamp-based conditional)
val ifModifiedSince = request.getHeader("If-Modified-Since")
if (ifModifiedSince != null) {
try {
val ifModifiedSinceDate =
java.time.Instant
.from(
java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME
.parse(ifModifiedSince),
).toEpochMilli()
// If the resource wasn't modified after the client's timestamp, it hasn't been modified
// There is the 1s precision, so we need to trim the milliseconds
return lastModified / 1000 <= ifModifiedSinceDate / 1000
} catch (e: Exception) {
// If we can't parse the date, assume it has been modified
return false
}
}

// No conditional headers present, assume modified
return false
}

companion object {
val DEFAULT_CACHE_CONTROL_HEADER = CacheControl.maxAge(0, TimeUnit.SECONDS)
}
}
Loading
Loading