diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadController.kt new file mode 100644 index 00000000000..9e296550a8c --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadController.kt @@ -0,0 +1,46 @@ +package io.tolgee.api.v2.controllers + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.hateoas.uploadedImage.UploadedImageMcpModel +import io.tolgee.openApiDocs.OpenApiHideFromPublicDocs +import io.tolgee.service.ImageUploadService +import io.tolgee.service.mcp.McpImageUploadUrlService +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@CrossOrigin(origins = ["*"]) +@RequestMapping(value = ["/v2/public/image-upload"]) +@Tag(name = "Image upload") +@OpenApiHideFromPublicDocs +class PublicImageUploadController( + private val mcpImageUploadUrlService: McpImageUploadUrlService, + private val imageUploadService: ImageUploadService, +) { + @PostMapping("", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @Operation( + summary = "Upload an image via a short-lived MCP upload URL", + description = + "Unauthenticated. Authorization is the short-lived signed `token` issued by the " + + "`get_image_upload_url` MCP tool. Returns the `uploadedImageId` to use with create_keys / " + + "add_key_screenshots.", + ) + @ResponseStatus(HttpStatus.CREATED) + fun upload( + @RequestParam("token") token: String, + @RequestParam("image") image: MultipartFile, + ): UploadedImageMcpModel { + val userAccount = mcpImageUploadUrlService.resolveUserFromUploadToken(token) + imageUploadService.validateIsImage(image) + val uploaded = imageUploadService.store(image, userAccount, null) + return UploadedImageMcpModel(uploadedImageId = uploaded.id) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/hateoas/uploadedImage/UploadedImageMcpModel.kt b/backend/api/src/main/kotlin/io/tolgee/hateoas/uploadedImage/UploadedImageMcpModel.kt new file mode 100644 index 00000000000..a3b6e2d5703 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/hateoas/uploadedImage/UploadedImageMcpModel.kt @@ -0,0 +1,7 @@ +package io.tolgee.hateoas.uploadedImage + +import org.springframework.hateoas.RepresentationModel + +class UploadedImageMcpModel( + val uploadedImageId: Long, +) : RepresentationModel() diff --git a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/BigMetaMcpTools.kt b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/BigMetaMcpTools.kt index 8c060524581..ee6cb032c97 100644 --- a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/BigMetaMcpTools.kt +++ b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/BigMetaMcpTools.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.modelcontextprotocol.server.McpSyncServer import io.tolgee.api.v2.controllers.BigMetaController import io.tolgee.dtos.BigMetaDto -import io.tolgee.dtos.RelatedKeyDto import io.tolgee.mcp.McpRequestContext import io.tolgee.mcp.McpToolsProvider import io.tolgee.mcp.buildSpec @@ -41,16 +40,7 @@ class BigMetaMcpTools( ) { request -> mcpRequestContext.executeAs(storeBigMetaSpec, request.arguments.getProjectId()) { val branch = request.arguments.getString("branch") - val relatedKeys = - request.arguments - .requireList("relatedKeysInOrder") - .map { k -> - RelatedKeyDto( - keyName = k.requireString("keyName"), - namespace = k.getString("namespace"), - branch = branch, - ) - }.toMutableList() + val relatedKeys = parseRelatedKeysInOrder(request.arguments.requireList("relatedKeysInOrder"), branch) val dto = BigMetaDto() dto.relatedKeysInOrder = relatedKeys diff --git a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/KeyMcpTools.kt b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/KeyMcpTools.kt index f91dc8192ff..6f26d70b82b 100644 --- a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/KeyMcpTools.kt +++ b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/KeyMcpTools.kt @@ -3,15 +3,21 @@ package io.tolgee.mcp.tools import com.fasterxml.jackson.databind.ObjectMapper import io.modelcontextprotocol.server.McpSyncServer import io.tolgee.api.v2.controllers.keys.KeyController +import io.tolgee.dtos.BigMetaDto import io.tolgee.dtos.request.key.EditKeyDto import io.tolgee.dtos.request.translation.ImportKeysDto import io.tolgee.dtos.request.translation.ImportKeysItemDto +import io.tolgee.dtos.request.translation.KeyCodeReferenceRequest import io.tolgee.mcp.McpRequestContext import io.tolgee.mcp.McpToolsProvider import io.tolgee.mcp.buildSpec import io.tolgee.security.ProjectHolder +import io.tolgee.service.bigMeta.BigMetaService import io.tolgee.service.key.KeyService +import io.tolgee.service.key.ScreenshotService +import io.tolgee.service.security.SecurityService import io.tolgee.util.executeInNewTransaction +import io.tolgee.util.getSafeNamespace import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Component import org.springframework.transaction.PlatformTransactionManager @@ -20,6 +26,9 @@ import org.springframework.transaction.PlatformTransactionManager class KeyMcpTools( private val mcpRequestContext: McpRequestContext, private val keyService: KeyService, + private val screenshotService: ScreenshotService, + private val securityService: SecurityService, + private val bigMetaService: BigMetaService, private val projectHolder: ProjectHolder, private val objectMapper: ObjectMapper, private val transactionManager: PlatformTransactionManager, @@ -114,9 +123,14 @@ class KeyMcpTools( server.addTool( "create_keys", - "Create translation keys in a Tolgee project with optional translations and tags. " + - "Keys that already exist are silently skipped — their translations and tags are not updated. " + - "Use update_key and set_translation to modify existing keys.", + "Create translation keys in a Tolgee project with optional translations, tags, metadata and screenshots. " + + "Provide as much context (description, code references, related keys) as you can in this call: " + + "auto-translation (if configured) runs right after creation and won't be redone if you add context later. " + + "Keys that already exist are silently skipped — their translations, tags, description, custom metadata, " + + "comments, and code references are not updated, but any screenshots passed for them are still attached. " + + "Use update_key and set_translation to modify existing keys. " + + "To attach screenshots, first obtain an uploadedImageId via get_image_upload_url (recommended) " + + "or upload_image, then reference it in the screenshots field here.", toolSchema { number("projectId", "ID of the project (required for PAT, auto-resolved for PAK)") objectArray("keys", "List of keys to create", required = true) { @@ -125,6 +139,23 @@ class KeyMcpTools( stringMap("translations", "Optional: translations as {languageTag: text} map") stringArray("tags", "Optional: tags to assign to the key") string("description", "Optional: description / developer context for the key") + objectField("custom", "Optional: arbitrary structured metadata stored on the key") + stringArray("comments", "Optional: comments to attach to the key") + objectArray("codeReferences", "Optional: where the key is used in source code") { + string("path", "File path (e.g. 'src/components/Header.tsx')", required = true) + number("line", "Optional: line number") + } + screenshotsField( + "Optional: screenshots to associate (get an uploadedImageId via get_image_upload_url first)", + ) + } + objectArray( + "relatedKeysInOrder", + "Optional: keys that appear together (in order, e.g. on the same screen) so auto-translation " + + "uses their translations as context. Reference keys created in this call or existing ones.", + ) { + string("keyName", "Key name", required = true) + string("namespace", "Optional: key namespace") } string("namespace", "Optional: default namespace for all keys (individual keys can override)") string("branch", "Optional: branch name") @@ -133,19 +164,62 @@ class KeyMcpTools( mcpRequestContext.executeAs(createKeysSpec, request.arguments.getProjectId()) { val branch = request.arguments.getString("branch") val defaultNamespace = request.arguments.getString("namespace") + val rawKeys = request.arguments.requireList("keys") val keys = - request.arguments.requireList("keys").map { k -> + rawKeys.map { k -> ImportKeysItemDto( name = k.requireString("name"), namespace = k.getString("namespace") ?: defaultNamespace, translations = k.getStringMap("translations") ?: emptyMap(), tags = k.getStringList("tags"), description = k.getString("description"), + custom = k.getObjectMap("custom"), + comments = k.getStringList("comments"), + codeReferences = + k.getList("codeReferences")?.map { ref -> + KeyCodeReferenceRequest(path = ref.requireString("path"), line = ref.getLong("line")) + }, ) } - keyService.importKeys(keys, projectHolder.projectEntity, branch) - textResult(objectMapper.writeValueAsString(mapOf("created" to true, "keyCount" to keys.size))) + val keysWithScreenshots = rawKeys.filter { it.getList("screenshots") != null } + if (keysWithScreenshots.isNotEmpty()) { + securityService.checkScreenshotsUploadPermission(projectHolder.project.id) + } + + val relatedKeysInOrder = request.arguments.getList("relatedKeysInOrder") + if (!relatedKeysInOrder.isNullOrEmpty()) { + securityService.checkBigMetaUploadPermission(projectHolder.project.id) + } + + executeInNewTransaction(transactionManager) { ts -> + keyService.importKeys(keys, projectHolder.projectEntity, branch) + + if (keysWithScreenshots.isNotEmpty()) { + val keyScreenshotPairs = + keysWithScreenshots.map { rawKey -> + val keyName = rawKey.requireString("name") + val keyNamespace = getSafeNamespace(rawKey.getString("namespace") ?: defaultNamespace) + val keyEntity = keyService.find(projectHolder.project.id, keyName, keyNamespace, branch) + if (keyEntity == null) { + ts.setRollbackOnly() + return@executeInNewTransaction errorResult("Key not found after creation: $keyName") + } + securityService.checkBranchModify(keyEntity) + keyEntity to parseScreenshotDtos(rawKey.requireList("screenshots")) + } + screenshotService.saveUploadedImagesForKeys(keyScreenshotPairs) + } + + if (!relatedKeysInOrder.isNullOrEmpty()) { + // Stored in this transaction so it lands before the post-commit auto-translate batch job. + val bigMeta = BigMetaDto() + bigMeta.relatedKeysInOrder = parseRelatedKeysInOrder(relatedKeysInOrder, branch, defaultNamespace) + bigMetaService.store(bigMeta, projectHolder.projectEntity) + } + + textResult(objectMapper.writeValueAsString(mapOf("created" to true, "keyCount" to keys.size))) + } } } diff --git a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/McpToolUtils.kt b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/McpToolUtils.kt index d3ef209a605..7087cfe754c 100644 --- a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/McpToolUtils.kt +++ b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/McpToolUtils.kt @@ -7,6 +7,9 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult import io.modelcontextprotocol.spec.McpSchema.JsonSchema import io.modelcontextprotocol.spec.McpSchema.TextContent import io.tolgee.constants.Message +import io.tolgee.dtos.RelatedKeyDto +import io.tolgee.dtos.request.KeyInScreenshotPositionDto +import io.tolgee.dtos.request.key.KeyScreenshotDto import io.tolgee.exceptions.BadRequestException import org.springframework.data.domain.Page @@ -44,7 +47,7 @@ fun McpSyncServer.addTool( McpServerFeatures.SyncToolSpecification( tool, null, - ) { exchange, request -> + ) { _, request -> handler(request) }, ) @@ -76,6 +79,8 @@ fun Map.requireString(key: String): String = getString(key) ?: mis fun Map.getInt(key: String): Int? = (this[key] as? Number)?.toInt() +fun Map.requireInt(key: String): Int = getInt(key) ?: missingParam(key) + @Suppress("UNCHECKED_CAST") fun Map.getStringList(key: String): List? = this[key] as? List @@ -84,6 +89,9 @@ fun Map.requireStringList(key: String): List = getStringLi @Suppress("UNCHECKED_CAST") fun Map.getStringMap(key: String): Map? = this[key] as? Map +@Suppress("UNCHECKED_CAST") +fun Map.getObjectMap(key: String): Map? = this[key] as? Map + fun Map.requireStringMap(key: String): Map = getStringMap(key) ?: missingParam(key) @Suppress("UNCHECKED_CAST") @@ -94,3 +102,48 @@ fun Map.requireList(key: String): List> = getLis @Suppress("UNCHECKED_CAST") fun Map.getLongList(key: String): List? = (this[key] as? List<*>)?.mapNotNull { (it as? Number)?.toLong() } + +fun SchemaBuilder.screenshotsField( + description: String, + required: Boolean = false, +) { + objectArray("screenshots", description, required) { + number("uploadedImageId", "Image ID from get_image_upload_url (recommended) or upload_image", required = true) + objectArray("positions", "Optional: positions of this key's text in the screenshot") { + number("x", "X coordinate in pixels", required = true) + number("y", "Y coordinate in pixels", required = true) + number("width", "Width in pixels", required = true) + number("height", "Height in pixels", required = true) + } + } +} + +fun parseRelatedKeysInOrder( + items: List>, + branch: String?, + defaultNamespace: String? = null, +): MutableList = + items + .map { item -> + RelatedKeyDto( + keyName = item.requireString("keyName"), + namespace = item.getString("namespace") ?: defaultNamespace, + branch = branch, + ) + }.toMutableList() + +fun parseScreenshotDtos(screenshots: List>): List = + screenshots.map { s -> + KeyScreenshotDto().apply { + uploadedImageId = s.requireLong("uploadedImageId") + positions = + s.getList("positions")?.map { p -> + KeyInScreenshotPositionDto( + x = p.requireInt("x"), + y = p.requireInt("y"), + width = p.requireInt("width"), + height = p.requireInt("height"), + ) + } + } + } diff --git a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/SchemaBuilder.kt b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/SchemaBuilder.kt index a2de6846089..0a6a69b6ca3 100644 --- a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/SchemaBuilder.kt +++ b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/SchemaBuilder.kt @@ -47,6 +47,15 @@ class SchemaBuilder { if (required) requiredFields += name } + fun objectField( + name: String, + description: String, + required: Boolean = false, + ) { + properties[name] = mapOf("type" to "object", "description" to description) + if (required) requiredFields += name + } + fun stringArray( name: String, description: String, diff --git a/backend/app/src/main/kotlin/io/tolgee/mcp/tools/ScreenshotMcpTools.kt b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/ScreenshotMcpTools.kt new file mode 100644 index 00000000000..5bdd568d28f --- /dev/null +++ b/backend/app/src/main/kotlin/io/tolgee/mcp/tools/ScreenshotMcpTools.kt @@ -0,0 +1,170 @@ +package io.tolgee.mcp.tools + +import com.fasterxml.jackson.databind.ObjectMapper +import io.modelcontextprotocol.server.McpSyncServer +import io.tolgee.api.v2.controllers.KeyScreenshotController +import io.tolgee.mcp.McpRequestContext +import io.tolgee.mcp.McpToolsProvider +import io.tolgee.mcp.ToolEndpointSpec +import io.tolgee.mcp.buildSpec +import io.tolgee.security.ProjectHolder +import io.tolgee.security.authentication.AuthTokenType +import io.tolgee.security.authentication.AuthenticationFacade +import io.tolgee.service.ImageUploadService +import io.tolgee.service.key.KeyService +import io.tolgee.service.key.ScreenshotService +import io.tolgee.service.mcp.McpImageUploadUrlService +import io.tolgee.service.security.SecurityService +import io.tolgee.util.executeInNewTransaction +import io.tolgee.util.getSafeNamespace +import org.springframework.core.io.ByteArrayResource +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import java.util.Base64 + +@Component +class ScreenshotMcpTools( + private val mcpRequestContext: McpRequestContext, + private val imageUploadService: ImageUploadService, + private val screenshotService: ScreenshotService, + private val keyService: KeyService, + private val projectHolder: ProjectHolder, + private val authenticationFacade: AuthenticationFacade, + private val objectMapper: ObjectMapper, + private val transactionManager: PlatformTransactionManager, + private val mcpImageUploadUrlService: McpImageUploadUrlService, + private val securityService: SecurityService, +) : McpToolsProvider { + companion object { + private const val MAX_BASE64_LENGTH = 15_000_000 // ~10MB image + } + + private val uploadImageSpec = + ToolEndpointSpec( + mcpOperation = "upload_image", + requiredScopes = null, + allowedTokenType = AuthTokenType.ANY, + isWriteOperation = true, + isGlobalRoute = true, + ) + + private val getImageUploadUrlSpec = + ToolEndpointSpec( + mcpOperation = "get_image_upload_url", + requiredScopes = null, + allowedTokenType = AuthTokenType.ANY, + isWriteOperation = true, + isGlobalRoute = true, + ) + + private val addKeyScreenshotsSpec = + buildSpec(KeyScreenshotController::uploadScreenshot, "add_key_screenshots") + + override fun register(server: McpSyncServer) { + server.addTool( + "get_image_upload_url", + "(Recommended) Get a short-lived URL for uploading a screenshot image out-of-band, instead of " + + "sending the image bytes through the model with upload_image. " + + "Returns { uploadUrl, expiresInSeconds }. Upload the image file to that URL as multipart field " + + "'image' (e.g. `curl -F image=@ \"\"`); the response contains an " + + "'uploadedImageId' to reference in the screenshots field of create_keys or add_key_screenshots.", + toolSchema { }, + ) { _ -> + mcpRequestContext.executeAs(getImageUploadUrlSpec) { + val issued = mcpImageUploadUrlService.issueUploadUrl(authenticationFacade.authenticatedUser.id) + textResult( + objectMapper.writeValueAsString( + mapOf( + "uploadUrl" to issued.uploadUrl, + "expiresInSeconds" to issued.expiresInSeconds, + ), + ), + ) + } + } + + server.addTool( + "upload_image", + "(Not recommended — prefer get_image_upload_url, which avoids sending image bytes through the " + + "model.) Upload a screenshot image for later use with create_keys or add_key_screenshots. " + + "Returns an uploadedImageId that can be referenced in the screenshots field.", + toolSchema { + string("image", "Base64-encoded PNG or JPEG image data", required = true) + }, + ) { request -> + mcpRequestContext.executeAs(uploadImageSpec) { + val base64 = request.arguments.requireString("image") + if (base64.length > MAX_BASE64_LENGTH) { + return@executeAs errorResult("Image too large (max ~10MB)") + } + + val imageBytes = + try { + Base64.getDecoder().decode(base64) + } catch (e: IllegalArgumentException) { + return@executeAs errorResult("Invalid base64 encoding") + } + + val resource = ByteArrayResource(imageBytes) + val imageEntity = + imageUploadService.store( + resource, + authenticationFacade.authenticatedUserEntity, + null, + ) + textResult( + objectMapper.writeValueAsString( + mapOf( + "uploadedImageId" to imageEntity.id, + ), + ), + ) + } + } + + server.addTool( + "add_key_screenshots", + "Add screenshots to existing translation keys. " + + "First obtain an uploadedImageId via get_image_upload_url (recommended) or upload_image (legacy " + + "base64), then use this tool to associate the image(s) with keys. " + + "Use this when you need to add screenshots to keys that already exist.", + toolSchema { + number("projectId", "ID of the project (required for PAT, auto-resolved for PAK)") + objectArray("keyScreenshots", "List of keys and their screenshots", required = true) { + string("keyName", "Translation key name", required = true) + string("namespace", "Optional: namespace of the key") + screenshotsField("Screenshots to associate with this key", required = true) + } + string("branch", "Optional: branch name") + }, + ) { request -> + mcpRequestContext.executeAs(addKeyScreenshotsSpec, request.arguments.getProjectId()) { + val branch = request.arguments.getString("branch") + val keyScreenshots = request.arguments.requireList("keyScreenshots") + + executeInNewTransaction(transactionManager) { + val keyScreenshotPairs = + keyScreenshots.map { entry -> + val keyName = entry.requireString("keyName") + val keyEntity = + keyService.find( + projectHolder.project.id, + keyName, + getSafeNamespace(entry.getString("namespace")), + branch, + ) + ?: return@executeInNewTransaction errorResult("Key not found: $keyName") + securityService.checkBranchModify(keyEntity) + keyEntity to parseScreenshotDtos(entry.requireList("screenshots")) + } + screenshotService.saveUploadedImagesForKeys(keyScreenshotPairs) + textResult( + objectMapper.writeValueAsString( + mapOf("success" to true, "keyCount" to keyScreenshotPairs.size), + ), + ) + } + } + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadControllerTest.kt new file mode 100644 index 00000000000..095f56ecf9f --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/PublicImageUploadControllerTest.kt @@ -0,0 +1,216 @@ +package io.tolgee.api.v2.controllers + +import io.modelcontextprotocol.client.McpSyncClient +import io.tolgee.AbstractMcpTest +import io.tolgee.component.MaxUploadedFilesByUserProvider +import io.tolgee.fixtures.andAssertThatJson +import io.tolgee.fixtures.andIsBadRequest +import io.tolgee.fixtures.andIsCreated +import io.tolgee.fixtures.andIsUnauthorized +import io.tolgee.fixtures.generateImage +import io.tolgee.fixtures.undecodableImageBytes +import io.tolgee.security.authentication.JwtService +import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.util.executeInNewTransaction +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.mock.web.MockMultipartFile +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart +import org.springframework.transaction.PlatformTransactionManager +import java.net.URI +import java.time.Duration + +@AutoConfigureMockMvc +class PublicImageUploadControllerTest : AbstractMcpTest() { + lateinit var data: McpPakTestData + + @Autowired lateinit var mockMvc: MockMvc + + @Autowired lateinit var jwtService: JwtService + + @Autowired lateinit var transactionManager: PlatformTransactionManager + + @MockitoBean + @Autowired + lateinit var maxUploadedFilesByUserProvider: MaxUploadedFilesByUserProvider + + @BeforeEach + fun setup() { + data = createTestDataWithPak() + whenever(maxUploadedFilesByUserProvider.invoke()).thenAnswer { 100L } + } + + @Test + fun `get_image_upload_url returns a well-formed URL with a valid IMG_UPLOAD token`() { + val client = mcpClient() + val json = callToolAndGetJson(client, "get_image_upload_url") + val uri = URI(json["uploadUrl"].asText()) + assertThat(uri.path).isEqualTo("/v2/public/image-upload") + assertThat(json["expiresInSeconds"].asLong()).isEqualTo(1800) + val auth = jwtService.validateTicket(tokenOf(uri), JwtService.TicketType.IMG_UPLOAD) + assertThat(auth.userAccount.id).isEqualTo(data.userAccountId) + } + + @Test + fun `end-to-end - tool URL, upload, then use the id`() { + val client = mcpClient() + val uri = URI(callToolAndGetJson(client, "get_image_upload_url")["uploadUrl"].asText()) + + val result = upload(tokenOf(uri), imageFile()).andIsCreated.andReturn() + val id = objectMapper.readTree(result.response.contentAsString)["uploadedImageId"].asLong() + assertThat(id).isGreaterThan(0) + + executeInNewTransaction(transactionManager) { + val image = imageUploadService.find(listOf(id)).first() + assertThat(image.userAccount.id).isEqualTo(data.userAccountId) + assertThat(fileStorage.fileExists("uploadedImages/" + image.filenameWithExtension)).isTrue() + } + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "e2e.key", "translations" to mapOf("en" to "x"))), + ), + ) + val assoc = + callToolAndGetJson( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf(mapOf("keyName" to "e2e.key", "screenshots" to listOf(mapOf("uploadedImageId" to id)))), + ), + ) + assertThat(assoc["success"].asBoolean()).isTrue() + } + + @Test + fun `rejects an expired token`() { + val token = uploadToken() + moveCurrentDate(Duration.ofMinutes(6)) + upload(token, imageFile()).andIsUnauthorized + } + + @Test + fun `rejects a garbage token`() { + upload("not-a-real-token", imageFile()).andIsUnauthorized + } + + @Test + fun `rejects a missing token`() { + mockMvc.perform(multipart("/v2/public/image-upload").file(imageFile())).andIsBadRequest + } + + @Test + fun `rejects a token of the wrong ticket type`() { + val token = jwtService.emitTicket(data.userAccountId, JwtService.TicketType.IMG_ACCESS) + upload(token, imageFile()).andIsUnauthorized + } + + @Test + fun `rejects a token for a non-existent user (fail-closed)`() { + val token = jwtService.emitTicket(999_999_999L, JwtService.TicketType.IMG_UPLOAD) + upload(token, imageFile()).andIsUnauthorized + } + + @Test + fun `rejects a token after the user is deactivated (fail-closed)`() { + val token = uploadToken() + userAccountService.disable(data.userAccountId) + upload(token, imageFile()).andIsUnauthorized + } + + @Test + fun `rejects a part with a missing content-type as FILE_NOT_IMAGE`() { + val file = MockMultipartFile("image", "shot.jpg", null, generateImage(80, 80).inputStream.readBytes()) + upload(uploadToken(), file).andIsBadRequest.andAssertThatJson { + node("CUSTOM_VALIDATION.file_not_image").isArray + } + } + + @Test + fun `rejects a non-image payload with FILE_NOT_IMAGE`() { + val file = MockMultipartFile("image", "note.txt", "text/plain", "hello".toByteArray()) + upload(uploadToken(), file).andIsBadRequest.andAssertThatJson { + node("CUSTOM_VALIDATION.file_not_image").isArray + } + } + + @Test + fun `rejects undecodable bytes with a valid content-type as FILE_NOT_IMAGE, not 500`() { + val file = MockMultipartFile("image", "broken.png", "image/png", undecodableImageBytes) + upload(uploadToken(), file).andIsBadRequest.andAssertThatJson { + node("CUSTOM_VALIDATION.file_not_image").isArray + } + } + + @Test + fun `accepts a degenerate aspect-ratio image (no 500 from a zero dimension)`() { + upload(uploadToken(), imageFile(201, 1)).andIsCreated + upload(uploadToken(), imageFile(1, 201)).andIsCreated + } + + @Test + fun `rejects when the per-user upload quota is exceeded`() { + whenever(maxUploadedFilesByUserProvider.invoke()).thenAnswer { 0L } + upload(uploadToken(), imageFile()).andIsCreated + upload(uploadToken(), imageFile()).andIsBadRequest.andAssertThatJson { + node("code").isEqualTo("too_many_uploaded_images") + } + } + + @Test + fun `the URL is reusable within its expiry window`() { + val token = uploadToken() + val first = + objectMapper.readTree( + upload(token, imageFile()) + .andIsCreated + .andReturn() + .response.contentAsString, + ) + val second = + objectMapper.readTree( + upload(token, imageFile()) + .andIsCreated + .andReturn() + .response.contentAsString, + ) + val firstId = first["uploadedImageId"].asLong() + val secondId = second["uploadedImageId"].asLong() + assertThat(firstId).isGreaterThan(0) + assertThat(secondId).isGreaterThan(0).isNotEqualTo(firstId) + + executeInNewTransaction(transactionManager) { + imageUploadService.find(listOf(firstId, secondId)).forEach { + assertThat(it.userAccount.id).isEqualTo(data.userAccountId) + } + } + } + + private fun mcpClient(): McpSyncClient = createMcpClientWithPak(data.apiKey.encodedKey!!) + + private fun uploadToken(userId: Long = data.userAccountId): String = + jwtService.emitTicket(userId, JwtService.TicketType.IMG_UPLOAD) + + private fun tokenOf(uri: URI): String = uri.query.substringAfter("token=") + + private fun imageFile( + width: Int = 80, + height: Int = 80, + ): MockMultipartFile = MockMultipartFile("image", "shot.jpg", "image/jpeg", generateImage(width, height).inputStream) + + private fun upload( + token: String, + file: MockMultipartFile, + ): ResultActions = mockMvc.perform(multipart("/v2/public/image-upload").file(file).param("token", token)) +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt index 343295bdff6..fec71aec857 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2KeyController/KeyControllerTest.kt @@ -282,6 +282,9 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") { "translations" to mapOf("en" to "hello"), "tags" to listOf("tag1", "tag2", "test"), + "custom" to mapOf("reactComponent" to "SignUpButton"), + "comments" to listOf("Imported comment"), + "codeReferences" to listOf(mapOf("path" to "src/App.tsx", "line" to 10)), ), mapOf( "name" to "key_without_tags", @@ -317,6 +320,27 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") { .description.assert .isEqualTo("description") + key.keyMeta!! + .custom.assert + .isEqualTo(mapOf("reactComponent" to "SignUpButton")) + + val comments = key.keyMeta!!.comments + comments.assert.hasSize(1) + comments + .first() + .text.assert + .isEqualTo("Imported comment") + val codeRefs = key.keyMeta!!.codeReferences + codeRefs.assert.hasSize(1) + codeRefs + .first() + .path.assert + .isEqualTo("src/App.tsx") + codeRefs + .first() + .line.assert + .isEqualTo(10L) + key.assert.isNotNull() key.keyMeta!! .tags.assert @@ -332,6 +356,27 @@ class KeyControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } + @ProjectJWTAuthTestMethod + @Test + fun `import keys rejects oversized custom`() { + saveTestDataAndPrepare() + + projectSupplier = { testData.project } + performProjectAuthPost( + "keys/import", + mapOf( + "keys" to + listOf( + mapOf( + "name" to "toobig_key", + "translations" to mapOf("en" to "hello"), + "custom" to mapOf("blob" to "x".repeat(6000)), + ), + ), + ), + ).andIsBadRequest.andAssertError.hasCode("custom_values_json_too_long") + } + @ProjectJWTAuthTestMethod @Test fun `imports single key with no tags`() { diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/McpWithoutEeTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/McpWithoutEeTest.kt index 6c763025bc7..5c0269dc756 100644 --- a/backend/app/src/test/kotlin/io/tolgee/mcp/McpWithoutEeTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/McpWithoutEeTest.kt @@ -40,6 +40,9 @@ class McpWithoutEeTest : AbstractMcpTest() { "get_batch_job_status", "machine_translate", "store_big_meta", + "get_image_upload_url", + "upload_image", + "add_key_screenshots", ) private val eeBranchTools = diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlBackendUrlTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlBackendUrlTest.kt new file mode 100644 index 00000000000..2a681e551d1 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlBackendUrlTest.kt @@ -0,0 +1,17 @@ +package io.tolgee.mcp.tools + +import io.tolgee.AbstractMcpTest +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.test.context.TestPropertySource + +@TestPropertySource(properties = ["tolgee.back-end-url=https://api.example.test"]) +class McpImageUploadUrlBackendUrlTest : AbstractMcpTest() { + @Test + fun `issued upload URL uses the configured back-end-url`() { + val data = createTestDataWithPak() + val client = createMcpClientWithPak(data.apiKey.encodedKey!!) + val url = callToolAndGetJson(client, "get_image_upload_url")["uploadUrl"].asText() + assertThat(url).startsWith("https://api.example.test/v2/public/image-upload?token=") + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlExpirationTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlExpirationTest.kt new file mode 100644 index 00000000000..1858d149374 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpImageUploadUrlExpirationTest.kt @@ -0,0 +1,32 @@ +package io.tolgee.mcp.tools + +import io.tolgee.AbstractMcpTest +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.security.authentication.JwtService +import io.tolgee.testing.assertions.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.TestPropertySource +import java.net.URI +import java.time.Duration + +@TestPropertySource(properties = ["tolgee.mcp.image-upload-url-expiration-ms=1500"]) +class McpImageUploadUrlExpirationTest : AbstractMcpTest() { + @Autowired lateinit var jwtService: JwtService + + @Test + fun `sub-second lifetime truncates expiresInSeconds and bounds the token's real expiry`() { + val data = createTestDataWithPak() + val client = createMcpClientWithPak(data.apiKey.encodedKey!!) + val json = callToolAndGetJson(client, "get_image_upload_url") + + assertThat(json["expiresInSeconds"].asLong()).isEqualTo(1L) + + val token = URI(json["uploadUrl"].asText()).query.substringAfter("token=") + assertDoesNotThrow { jwtService.validateTicket(token, JwtService.TicketType.IMG_UPLOAD) } + moveCurrentDate(Duration.ofSeconds(2)) + assertThrows { jwtService.validateTicket(token, JwtService.TicketType.IMG_UPLOAD) } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpKeyToolsTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpKeyToolsTest.kt index 8537bfca9d0..85a1b04fe7e 100644 --- a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpKeyToolsTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpKeyToolsTest.kt @@ -2,14 +2,26 @@ package io.tolgee.mcp.tools import io.modelcontextprotocol.client.McpSyncClient import io.tolgee.AbstractMcpTest +import io.tolgee.fixtures.waitForNotThrowing +import io.tolgee.model.enums.Scope +import io.tolgee.repository.KeysDistanceRepository import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.util.executeInNewTransaction import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.PlatformTransactionManager class McpKeyToolsTest : AbstractMcpTest() { lateinit var data: McpPakTestData lateinit var client: McpSyncClient + @Autowired + lateinit var transactionManager: PlatformTransactionManager + + @Autowired + lateinit var keysDistanceRepository: KeysDistanceRepository + @BeforeEach fun setup() { data = createTestDataWithPak() @@ -187,4 +199,325 @@ class McpKeyToolsTest : AbstractMcpTest() { ) assertThat(result.isError).isTrue() } + + @Test + fun `create_keys stores structured custom metadata on the key`() { + val custom = + mapOf( + "reactComponent" to "SignUpButton", + "primary" to true, + "order" to 3, + "aliases" to listOf("signup", "register"), + "props" to mapOf("variant" to "filled"), + ) + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "custom.key", "custom" to custom)), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "custom.key", null) + assertThat(key).isNotNull() + // Full structured payload (bool, number, array, nested object) must survive the JSONB round-trip. + assertThat(key!!.keyMeta?.custom).isEqualTo(custom) + } + } + + @Test + fun `create_keys rejects oversized custom values`() { + val oversized = "x".repeat(6000) + + assertToolFails( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "toobig.key", "custom" to mapOf("blob" to oversized))), + ), + expectedError = "custom_values_json_too_long", + ) + + assertThat(keyService.find(data.projectId, "toobig.key", null)).isNull() + } + + @Test + fun `create_keys does not overwrite custom on an already-existing key`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "keep.custom", "custom" to mapOf("owner" to "first"))), + ), + ) + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "keep.custom", "custom" to mapOf("owner" to "second"))), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "keep.custom", null) + assertThat(key).isNotNull() + assertThat(key!!.keyMeta?.custom).isEqualTo(mapOf("owner" to "first")) + } + } + + @Test + fun `create_keys stores comments and code references on the key`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "meta.key", + "comments" to listOf("Shown in the hero", "Reviewed by design"), + "codeReferences" to + listOf( + mapOf("path" to "src/Header.tsx", "line" to 42), + mapOf("path" to "src/Footer.tsx"), + ), + ), + ), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "meta.key", null) + assertThat(key).isNotNull() + val meta = key!!.keyMeta + assertThat(meta).isNotNull() + assertThat(meta!!.comments.map { it.text }) + .containsExactlyInAnyOrder("Shown in the hero", "Reviewed by design") + val refs = meta.codeReferences.associate { it.path to it.line } + assertThat(refs).containsEntry("src/Header.tsx", 42L) + assertThat(refs).containsEntry("src/Footer.tsx", null) + } + } + + @Test + fun `create_keys stores related-key big meta for translation context`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "rel.one"), mapOf("name" to "rel.two")), + "relatedKeysInOrder" to + listOf( + mapOf("keyName" to "rel.one"), + mapOf("keyName" to "rel.two"), + ), + ), + ) + + val key1 = keyService.find(data.projectId, "rel.one", null)!! + waitForNotThrowing(timeout = 5000, pollTime = 100) { + assertThat(keysDistanceRepository.getCloseKeys(key1.id)).isNotEmpty() + } + } + + @Test + fun `create_keys without TRANSLATIONS_EDIT scope rejects relatedKeysInOrder and creates no keys`() { + val restricted = + createTestDataWithPak( + scopes = setOf(Scope.KEYS_CREATE), + userName = "no_translations_edit_user", + projectName = "no_translations_edit_project", + pakKey = "no_translations_edit_pak_key", + ) + val restrictedClient = createMcpClientWithPak(restricted.apiKey.encodedKey!!) + + assertToolFails( + restrictedClient, + "create_keys", + mapOf( + "projectId" to restricted.projectId, + "keys" to listOf(mapOf("name" to "rel.key")), + "relatedKeysInOrder" to listOf(mapOf("keyName" to "rel.key")), + ), + expectedError = "operation_not_permitted", + ) + + assertThat(keyService.find(restricted.projectId, "rel.key", null)).isNull() + } + + @Test + fun `create_keys does not update comments or code references on an existing key`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "keep.meta", + "comments" to listOf("first comment"), + "codeReferences" to listOf(mapOf("path" to "src/First.tsx", "line" to 1)), + ), + ), + ), + ) + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "keep.meta", + "comments" to listOf("second comment"), + "codeReferences" to listOf(mapOf("path" to "src/Second.tsx", "line" to 2)), + ), + ), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "keep.meta", null)!! + assertThat(key.keyMeta!!.comments.map { it.text }).containsExactly("first comment") + assertThat(key.keyMeta!!.codeReferences.map { it.path }).containsExactly("src/First.tsx") + } + } + + @Test + fun `create_keys stores big meta using the default namespace for related keys`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "namespace" to "ui", + "keys" to listOf(mapOf("name" to "ns.rel.one"), mapOf("name" to "ns.rel.two")), + "relatedKeysInOrder" to + listOf( + mapOf("keyName" to "ns.rel.one"), + mapOf("keyName" to "ns.rel.two"), + ), + ), + ) + + val key1 = keyService.find(data.projectId, "ns.rel.one", "ui")!! + waitForNotThrowing(timeout = 5000, pollTime = 100) { + assertThat(keysDistanceRepository.getCloseKeys(key1.id)).isNotEmpty() + } + } + + @Test + fun `create_keys rejects an over-long code reference path and creates no keys`() { + val longPath = "src/" + "x".repeat(400) + + assertToolFails( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "longpath.key", "codeReferences" to listOf(mapOf("path" to longPath)))), + ), + expectedError = "key_code_reference_path_too_long", + ) + + assertThat(keyService.find(data.projectId, "longpath.key", null)).isNull() + } + + @Test + fun `create_keys rejects an over-long comment and creates no keys`() { + assertToolFails( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "longcomment.key", "comments" to listOf("x".repeat(2001)))), + ), + expectedError = "key_comment_too_long", + ) + + assertThat(keyService.find(data.projectId, "longcomment.key", null)).isNull() + } + + @Test + fun `create_keys skips blank comments and code references`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "blank.meta.key", + "comments" to listOf(" "), + "codeReferences" to listOf(mapOf("path" to "")), + ), + ), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "blank.meta.key", null)!! + assertThat(key.keyMeta?.comments ?: emptyList()).isEmpty() + assertThat(key.keyMeta?.codeReferences ?: emptyList()).isEmpty() + } + } + + @Test + fun `create_keys rolls back the whole batch when one key is invalid`() { + assertToolFails( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf("name" to "good.key"), + mapOf("name" to "bad.key", "comments" to listOf("x".repeat(2001))), + ), + ), + expectedError = "key_comment_too_long", + ) + + assertThat(keyService.find(data.projectId, "good.key", null)).isNull() + assertThat(keyService.find(data.projectId, "bad.key", null)).isNull() + } + + @Test + fun `create_keys stores big meta for related keys with an explicit namespace`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf("name" to "comp.one", "namespace" to "comp"), + mapOf("name" to "comp.two", "namespace" to "comp"), + ), + "relatedKeysInOrder" to + listOf( + mapOf("keyName" to "comp.one", "namespace" to "comp"), + mapOf("keyName" to "comp.two", "namespace" to "comp"), + ), + ), + ) + + val key1 = keyService.find(data.projectId, "comp.one", "comp")!! + waitForNotThrowing(timeout = 5000, pollTime = 100) { + assertThat(keysDistanceRepository.getCloseKeys(key1.id)).isNotEmpty() + } + } } diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotBranchProtectionTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotBranchProtectionTest.kt new file mode 100644 index 00000000000..5b6f358b025 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotBranchProtectionTest.kt @@ -0,0 +1,151 @@ +package io.tolgee.mcp.tools + +import io.modelcontextprotocol.client.McpSyncClient +import io.modelcontextprotocol.spec.McpSchema +import io.tolgee.AbstractMcpTest +import io.tolgee.constants.Feature +import io.tolgee.development.testDataBuilder.data.McpScreenshotBranchTestData +import io.tolgee.ee.component.PublicEnabledFeaturesProvider +import io.tolgee.model.key.Key +import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.util.executeInNewTransaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.transaction.PlatformTransactionManager + +class McpScreenshotBranchProtectionTest : AbstractMcpTest() { + lateinit var testData: McpScreenshotBranchTestData + lateinit var clientWithoutProtect: McpSyncClient + lateinit var clientWithProtect: McpSyncClient + + @Autowired + lateinit var transactionManager: PlatformTransactionManager + + @Autowired + lateinit var enabledFeaturesProvider: PublicEnabledFeaturesProvider + + @BeforeEach + fun setup() { + enabledFeaturesProvider.forceEnabled = setOf(Feature.BRANCHING) + testData = McpScreenshotBranchTestData() + testDataService.saveTestData(testData.root) + clientWithoutProtect = createMcpClientWithPak(testData.pakWithoutProtectScope.encodedKey!!) + clientWithProtect = createMcpClientWithPak(testData.pakWithProtectScope.encodedKey!!) + } + + @AfterEach + fun resetFeatures() { + enabledFeaturesProvider.forceEnabled = null + } + + @Test + fun `add_key_screenshots on protected branch without protected scope is rejected and attaches nothing`() { + val imageId = uploadImage(clientWithoutProtect) + + expectToolFailure { + addScreenshot(clientWithoutProtect, "protected.existing", "main", imageId) + } + + assertThat(screenshotsOf("protected.existing", "main")).isEmpty() + } + + @Test + fun `add_key_screenshots on protected branch with protected scope succeeds`() { + val imageId = uploadImage(clientWithProtect) + + val result = addScreenshot(clientWithProtect, "protected.existing", "main", imageId) + assertThat(result.isError).isFalse() + + assertThat(screenshotsOf("protected.existing", "main")).isNotEmpty() + } + + @Test + fun `add_key_screenshots on non-protected branch succeeds without protected scope`() { + val imageId = uploadImage(clientWithoutProtect) + + val result = addScreenshot(clientWithoutProtect, "feature.existing", "feature", imageId) + assertThat(result.isError).isFalse() + + assertThat(screenshotsOf("feature.existing", "feature")).isNotEmpty() + } + + // create_keys silently skips keys that already exist but still attaches their screenshots, so the + // branch check guards that pass independently of key creation — hence an already-existing key here. + @Test + fun `create_keys re-targeting an existing protected-branch key without protected scope attaches nothing`() { + val imageId = uploadImage(clientWithoutProtect) + + expectToolFailure { + createKeyWithScreenshot(clientWithoutProtect, "protected.existing", "main", imageId) + } + + assertThat(screenshotsOf("protected.existing", "main")).isEmpty() + } + + @Test + fun `create_keys re-targeting an existing protected-branch key with protected scope attaches the screenshot`() { + val imageId = uploadImage(clientWithProtect) + + val result = createKeyWithScreenshot(clientWithProtect, "protected.existing", "main", imageId) + assertThat(result.isError).isFalse() + + assertThat(screenshotsOf("protected.existing", "main")).isNotEmpty() + } + + private fun addScreenshot( + client: McpSyncClient, + keyName: String, + branch: String, + imageId: Long, + ): McpSchema.CallToolResult = + callTool( + client, + "add_key_screenshots", + mapOf( + "projectId" to testData.project.id, + "branch" to branch, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to keyName, + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + + private fun createKeyWithScreenshot( + client: McpSyncClient, + keyName: String, + branch: String, + imageId: Long, + ): McpSchema.CallToolResult = + callTool( + client, + "create_keys", + mapOf( + "projectId" to testData.project.id, + "branch" to branch, + "keys" to + listOf( + mapOf( + "name" to keyName, + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + + private fun screenshotsOf( + keyName: String, + branch: String, + ): List<*> = + executeInNewTransaction(transactionManager) { + val key: Key = + keyService.find(testData.project.id, keyName, null, branch) + ?: return@executeInNewTransaction emptyList() + screenshotService.findAll(key) + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotToolsTest.kt b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotToolsTest.kt new file mode 100644 index 00000000000..0e60ed2309c --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/mcp/tools/McpScreenshotToolsTest.kt @@ -0,0 +1,483 @@ +package io.tolgee.mcp.tools + +import io.modelcontextprotocol.client.McpSyncClient +import io.tolgee.AbstractMcpTest +import io.tolgee.model.enums.Scope +import io.tolgee.testing.assertions.Assertions.assertThat +import io.tolgee.util.executeInNewTransaction +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager + +@TestPropertySource(properties = ["tolgee.max-screenshots-per-key=2"]) +class McpScreenshotToolsTest : AbstractMcpTest() { + lateinit var data: McpPakTestData + lateinit var client: McpSyncClient + + @Autowired + lateinit var transactionManager: PlatformTransactionManager + + @BeforeEach + fun setup() { + data = createTestDataWithPak() + client = createMcpClientWithPak(data.apiKey.encodedKey!!) + } + + @Test + fun `upload_image returns image ID`() { + val json = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + assertThat(json["uploadedImageId"]).isNotNull() + assertThat(json["uploadedImageId"].asLong()).isGreaterThan(0) + } + + @Test + fun `upload_image fails with invalid base64`() { + val result = + callTool( + client, + "upload_image", + mapOf("image" to "not-valid-base64!!!"), + ) + assertThat(result.isError).isTrue() + } + + @Test + fun `create_keys with screenshots associates uploaded image`() { + val uploadJson = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + val imageId = uploadJson["uploadedImageId"].asLong() + + val createJson = + callToolAndGetJson( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "screenshot.test.key", + "translations" to mapOf("en" to "Test text"), + "screenshots" to + listOf( + mapOf("uploadedImageId" to imageId), + ), + ), + ), + ), + ) + assertThat(createJson["created"].asBoolean()).isTrue() + + val key = keyService.find(data.projectId, "screenshot.test.key", null) + assertThat(key).isNotNull() + val screenshots = screenshotService.findAll(key!!) + assertThat(screenshots).isNotEmpty() + } + + @Test + fun `create_keys with screenshots and positions stores position data`() { + val uploadJson = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + val imageId = uploadJson["uploadedImageId"].asLong() + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "positioned.key", + "translations" to mapOf("en" to "Positioned text"), + "screenshots" to + listOf( + mapOf( + "uploadedImageId" to imageId, + "positions" to + listOf( + mapOf("x" to 10, "y" to 20, "width" to 100, "height" to 30), + ), + ), + ), + ), + ), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "positioned.key", null) + assertThat(key).isNotNull() + val screenshots = screenshotService.findAll(key!!) + assertThat(screenshots).isNotEmpty() + val positions = key.keyScreenshotReferences.flatMap { it.positions ?: emptyList() } + assertThat(positions).isNotEmpty() + val pos = positions.first() + // The 1x1 source PNG yields a 1.0 scaling ratio, so coordinates round-trip unchanged. + assertThat(pos.x).isEqualTo(10) + assertThat(pos.y).isEqualTo(20) + assertThat(pos.width).isEqualTo(100) + assertThat(pos.height).isEqualTo(30) + } + } + + @Test + fun `create_keys with same image for multiple keys`() { + val uploadJson = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + val imageId = uploadJson["uploadedImageId"].asLong() + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "multi.key.one", + "translations" to mapOf("en" to "First"), + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + mapOf( + "name" to "multi.key.two", + "translations" to mapOf("en" to "Second"), + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + + val key1 = keyService.find(data.projectId, "multi.key.one", null) + val key2 = keyService.find(data.projectId, "multi.key.two", null) + assertThat(key1).isNotNull() + assertThat(key2).isNotNull() + val screenshots1 = screenshotService.findAll(key1!!) + val screenshots2 = screenshotService.findAll(key2!!) + assertThat(screenshots1).hasSize(1) + assertThat(screenshots2).hasSize(1) + assertThat(screenshots1.first().id).isEqualTo(screenshots2.first().id) + } + + @Test + fun `create_keys merges positions when the same image is referenced twice for one key`() { + val imageId = uploadImage() + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "dup.key", + "screenshots" to + listOf( + mapOf( + "uploadedImageId" to imageId, + "positions" to listOf(mapOf("x" to 1, "y" to 2, "width" to 3, "height" to 4)), + ), + mapOf( + "uploadedImageId" to imageId, + "positions" to listOf(mapOf("x" to 5, "y" to 6, "width" to 7, "height" to 8)), + ), + ), + ), + ), + ), + ) + + executeInNewTransaction(transactionManager) { + val key = keyService.find(data.projectId, "dup.key", null) + assertThat(key).isNotNull() + // One reference (the image is deduplicated) but both DTOs' positions are preserved. + assertThat(screenshotService.findAll(key!!)).hasSize(1) + val positions = key!!.keyScreenshotReferences.flatMap { it.positions ?: emptyList() } + assertThat(positions).hasSize(2) + } + } + + @Test + fun `create_keys attaches a screenshot to a key in a namespace`() { + val imageId = uploadImage() + + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "ns.key", + "namespace" to "my-ns", + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + + val key = keyService.find(data.projectId, "ns.key", "my-ns") + assertThat(key).isNotNull() + assertThat(screenshotService.findAll(key!!)).hasSize(1) + } + + @Test + fun `create_keys with a blank namespace still attaches the screenshot`() { + val imageId = uploadImage() + + val result = + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to + listOf( + mapOf( + "name" to "blank.ns.key", + "namespace" to "", + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + + assertThat(result.isError).isFalse() + val key = keyService.find(data.projectId, "blank.ns.key", null) + assertThat(key).isNotNull() + assertThat(screenshotService.findAll(key!!)).hasSize(1) + } + + @Test + fun `add_key_screenshots rejects exceeding the per-key screenshot limit`() { + callTool( + client, + "create_keys", + mapOf("projectId" to data.projectId, "keys" to listOf(mapOf("name" to "limited.key"))), + ) + val images = (1..3).map { uploadImage() } + + expectToolFailure { + callTool( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to "limited.key", + "screenshots" to images.map { mapOf("uploadedImageId" to it) }, + ), + ), + ), + ) + } + + // The over-limit batch is rejected before any image is converted, so nothing is attached. + val key = keyService.find(data.projectId, "limited.key", null) + assertThat(screenshotService.findAll(key!!)).isEmpty() + } + + @Test + fun `add_key_screenshots rejects an image owned by another user`() { + callTool( + client, + "create_keys", + mapOf("projectId" to data.projectId, "keys" to listOf(mapOf("name" to "idor.key"))), + ) + + val otherData = createTestDataWithPat() + val otherClient = createMcpClientWithPat(otherData.pat.token!!) + val foreignImageId = + callToolAndGetJson(otherClient, "upload_image", mapOf("image" to MINIMAL_PNG_BASE64))["uploadedImageId"].asLong() + + expectToolFailure { + callTool( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to "idor.key", + "screenshots" to listOf(mapOf("uploadedImageId" to foreignImageId)), + ), + ), + ), + ) + } + + val key = keyService.find(data.projectId, "idor.key", null) + assertThat(screenshotService.findAll(key!!)).isEmpty() + } + + @Test + fun `add_key_screenshots rejects a non-existent image id`() { + callTool( + client, + "create_keys", + mapOf("projectId" to data.projectId, "keys" to listOf(mapOf("name" to "noimg.key"))), + ) + + expectToolFailure { + callTool( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to "noimg.key", + "screenshots" to listOf(mapOf("uploadedImageId" to 9_999_999)), + ), + ), + ), + ) + } + + val key = keyService.find(data.projectId, "noimg.key", null) + assertThat(screenshotService.findAll(key!!)).isEmpty() + } + + @Test + fun `create_keys without SCREENSHOTS_UPLOAD scope creates no keys`() { + val restricted = + createTestDataWithPak( + // KEYS_CREATE alone permits create_keys but not the screenshot upload it tries to do. + // (Subtracting SCREENSHOTS_UPLOAD from all scopes would not work: ADMIN re-grants it on expansion.) + scopes = setOf(Scope.KEYS_CREATE), + userName = "no_screenshot_scope_user", + projectName = "no_screenshot_scope_project", + pakKey = "no_screenshot_scope_pak_key", + ) + val restrictedClient = createMcpClientWithPak(restricted.apiKey.encodedKey!!) + val imageId = + callToolAndGetJson( + restrictedClient, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + )["uploadedImageId"].asLong() + + expectToolFailure { + callTool( + restrictedClient, + "create_keys", + mapOf( + "projectId" to restricted.projectId, + "keys" to + listOf( + mapOf( + "name" to "scoped.key", + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + } + + assertThat(keyService.find(restricted.projectId, "scoped.key", null)).isNull() + } + + @Test + fun `upload_image rejects oversized base64`() { + val oversized = "A".repeat(15_000_001) + val result = callTool(client, "upload_image", mapOf("image" to oversized)) + assertThat(result.isError).isTrue() + } + + @Test + fun `add_key_screenshots associates screenshot with existing key`() { + callTool( + client, + "create_keys", + mapOf( + "projectId" to data.projectId, + "keys" to listOf(mapOf("name" to "existing.key", "translations" to mapOf("en" to "Existing"))), + ), + ) + + val uploadJson = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + val imageId = uploadJson["uploadedImageId"].asLong() + + val json = + callToolAndGetJson( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to "existing.key", + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + assertThat(json["success"].asBoolean()).isTrue() + assertThat(json["keyCount"].asInt()).isEqualTo(1) + + val key = keyService.find(data.projectId, "existing.key", null) + assertThat(key).isNotNull() + assertThat(screenshotService.findAll(key!!)).isNotEmpty() + } + + @Test + fun `add_key_screenshots fails for non-existent key`() { + val uploadJson = + callToolAndGetJson( + client, + "upload_image", + mapOf("image" to MINIMAL_PNG_BASE64), + ) + val imageId = uploadJson["uploadedImageId"].asLong() + + val result = + callTool( + client, + "add_key_screenshots", + mapOf( + "projectId" to data.projectId, + "keyScreenshots" to + listOf( + mapOf( + "keyName" to "nonexistent.key", + "screenshots" to listOf(mapOf("uploadedImageId" to imageId)), + ), + ), + ), + ) + assertThat(result.isError).isTrue() + } + + private fun uploadImage(): Long = uploadImage(client) +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/BackendUrlProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/BackendUrlProvider.kt new file mode 100644 index 00000000000..6584fee5fe2 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/BackendUrlProvider.kt @@ -0,0 +1,22 @@ +package io.tolgee.component + +import io.tolgee.configuration.tolgee.TolgeeProperties +import org.springframework.stereotype.Component + +@Component +class BackendUrlProvider( + private val tolgeeProperties: TolgeeProperties, +) { + val url: String + get() { + val backEndUrl = tolgeeProperties.backEndUrl + if (!backEndUrl.isNullOrBlank()) { + return backEndUrl + } + return currentRequestOriginOrNull() + ?: throw IllegalStateException( + "Trying to find backend url, but there is no current request. " + + "You will have to specify tolgee.back-end-url in application properties.", + ) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt b/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt index 8402b906c15..0ccfb089d63 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/FrontendUrlProvider.kt @@ -2,7 +2,6 @@ package io.tolgee.component import io.tolgee.configuration.tolgee.TolgeeProperties import org.springframework.stereotype.Component -import org.springframework.web.servlet.support.ServletUriComponentsBuilder @Component class FrontendUrlProvider( @@ -15,25 +14,12 @@ class FrontendUrlProvider( return frontEndUrlFromProperties } - return getFromServerRequest() - } - - private fun getFromServerRequest(): String { - try { - val builder = ServletUriComponentsBuilder.fromCurrentRequestUri() - builder.replacePath("") - builder.replaceQuery("") - return builder.build().toUriString() - } catch (e: IllegalStateException) { - if (e.message?.contains("No current ServletRequestAttributes") == true) { - throw IllegalStateException( + return currentRequestOriginOrNull() + ?: throw IllegalStateException( "Trying to find frontend url, but there is no current request. " + "You will have to specify frontend url in application properties.", ) - } - throw e } - } fun getSubscriptionsUrl(organizationSlug: String): String = "${this.url}/organizations/$organizationSlug/subscriptions" diff --git a/backend/data/src/main/kotlin/io/tolgee/component/RequestOrigin.kt b/backend/data/src/main/kotlin/io/tolgee/component/RequestOrigin.kt new file mode 100644 index 00000000000..ccf97e1fa95 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/component/RequestOrigin.kt @@ -0,0 +1,17 @@ +package io.tolgee.component + +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +internal fun currentRequestOriginOrNull(): String? { + try { + val builder = ServletUriComponentsBuilder.fromCurrentRequestUri() + builder.replacePath("") + builder.replaceQuery("") + return builder.build().toUriString() + } catch (e: IllegalStateException) { + if (e.message?.contains("No current ServletRequestAttributes") == true) { + return null + } + throw e + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/McpProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/McpProperties.kt new file mode 100644 index 00000000000..5fd29d840af --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/McpProperties.kt @@ -0,0 +1,18 @@ +package io.tolgee.configuration.tolgee + +import io.tolgee.configuration.annotations.DocProperty +import jakarta.validation.constraints.Positive +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +@ConfigurationProperties(prefix = "tolgee.mcp") +@DocProperty(description = "Configuration of Tolgee MCP server.", displayName = "MCP") +@Validated +class McpProperties( + @field:Positive + @DocProperty( + description = "Lifetime of MCP image-upload URLs, in milliseconds.", + defaultExplanation = "= 30 minutes", + ) + var imageUploadUrlExpirationMs: Long = 30 * 60 * 1000L, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt index c0ea5a55633..169a953a90c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt @@ -138,4 +138,5 @@ class TolgeeProperties( "\n\n", ) var backEndUrl: String? = null, + var mcp: McpProperties = McpProperties(), ) diff --git a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt index 336bca8934d..ae0249fa30c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt +++ b/backend/data/src/main/kotlin/io/tolgee/constants/Message.kt @@ -328,6 +328,8 @@ enum class Message { EXPORT_KEY_PLURAL_SUFFIX_COLLISION, TRANSLATION_EXCEEDS_CHAR_LIMIT, URL_NOT_VALID, + KEY_COMMENT_TOO_LONG, + KEY_CODE_REFERENCE_PATH_TOO_LONG, ; val code: String diff --git a/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/McpScreenshotBranchTestData.kt b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/McpScreenshotBranchTestData.kt new file mode 100644 index 00000000000..09ace596c7b --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/development/testDataBuilder/data/McpScreenshotBranchTestData.kt @@ -0,0 +1,67 @@ +package io.tolgee.development.testDataBuilder.data + +import io.tolgee.model.ApiKey +import io.tolgee.model.branching.Branch +import io.tolgee.model.enums.Scope + +class McpScreenshotBranchTestData : BaseTestData("mcp_branch_user", "mcp_branch_project") { + lateinit var mainBranch: Branch + lateinit var featureBranch: Branch + lateinit var pakWithProtectScope: ApiKey + lateinit var pakWithoutProtectScope: ApiKey + + private val baseScopes = + setOf( + Scope.KEYS_CREATE, + Scope.KEYS_EDIT, + Scope.KEYS_VIEW, + Scope.SCREENSHOTS_UPLOAD, + Scope.SCREENSHOTS_VIEW, + Scope.TRANSLATIONS_EDIT, + ) + + init { + projectBuilder.apply { + self.useBranching = true + + mainBranch = + addBranch { + name = "main" + project = projectBuilder.self + isProtected = true + isDefault = true + }.self + + featureBranch = + addBranch { + name = "feature" + project = projectBuilder.self + isProtected = false + isDefault = false + originBranch = mainBranch + }.self + + addKey { + name = "protected.existing" + branch = mainBranch + } + addKey { + name = "feature.existing" + branch = featureBranch + } + + addApiKey { + key = "mcp_branch_without_protect_pak" + scopesEnum = baseScopes.toMutableSet() + userAccount = userAccountBuilder.self + pakWithoutProtectScope = this + } + addApiKey { + key = "mcp_branch_with_protect_pak" + scopesEnum = (baseScopes + Scope.BRANCH_PROTECTED_MODIFY).toMutableSet() + userAccount = userAccountBuilder.self + pakWithProtectScope = this + } + } + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/ImportKeysItemDto.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/ImportKeysItemDto.kt index bfef6253130..eed9bcb8bfe 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/ImportKeysItemDto.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/ImportKeysItemDto.kt @@ -35,4 +35,16 @@ class ImportKeysItemDto( example = "[\"homepage\", \"user-profile\"]", ) val tags: List? = null, + @Schema( + description = "Arbitrary structured metadata (custom values) stored on the key", + example = "{\"reactComponent\": \"SignUpButton\"}", + ) + val custom: Map? = null, + @Schema( + description = "Comments to attach to the key", + example = "[\"Shown in the homepage hero section\"]", + ) + val comments: List? = null, + @Schema(description = "Code references — where the key is used in the source code") + val codeReferences: List? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/KeyCodeReferenceRequest.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/KeyCodeReferenceRequest.kt new file mode 100644 index 00000000000..fbfc60d9212 --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/request/translation/KeyCodeReferenceRequest.kt @@ -0,0 +1,10 @@ +package io.tolgee.dtos.request.translation + +import io.swagger.v3.oas.annotations.media.Schema + +class KeyCodeReferenceRequest( + @Schema(description = "Path to the file where the key is used", example = "src/components/Header.tsx") + val path: String = "", + @Schema(description = "Line number in the file where the key is used", example = "42") + val line: Long? = null, +) diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyCodeReference.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyCodeReference.kt index e2faa5bde80..fb864d55f6a 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyCodeReference.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyCodeReference.kt @@ -27,7 +27,7 @@ class KeyCodeReference( ) : StandardAuditModel(), WithKeyMetaReference { @field:NotBlank - @Column(length = 300) + @Column(length = PATH_MAX_LENGTH) var path: String = "" var line: Long? = null @@ -37,4 +37,8 @@ class KeyCodeReference( override fun toString(): String { return "KeyCodeReference(path='$path', line=$line)" } + + companion object { + const val PATH_MAX_LENGTH = 300 + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyComment.kt b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyComment.kt index fd431f22dd7..e0f92f24221 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/key/KeyComment.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/key/KeyComment.kt @@ -31,7 +31,7 @@ class KeyComment( var fromImport: Boolean = false @field:NotBlank - @Column(columnDefinition = "text", length = 2000) + @Column(columnDefinition = "text", length = TEXT_MAX_LENGTH) var text: String = "" override fun toString(): String { @@ -41,4 +41,8 @@ class KeyComment( override fun resolveKey(): Key? { return keyMeta.key } + + companion object { + const val TEXT_MAX_LENGTH = 2000 + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt index 3ca53f1b9de..a04e4ced939 100644 --- a/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/security/authentication/JwtService.kt @@ -251,7 +251,6 @@ class JwtService( ): TicketAuthentication { val jws = parseJwt(token) if (jws.body.audience != JWT_TICKET_AUDIENCE) { - // This is not a token - possibly a token or something else. throw AuthenticationException(Message.INVALID_JWT_TOKEN) } @@ -259,7 +258,12 @@ class JwtService( jws.body[JWT_TICKET_TYPE_CLAIM] as? String ?: throw AuthenticationException(Message.INVALID_JWT_TOKEN) - val jwsType = TicketType.valueOf(rawJwsType) + val jwsType = + try { + TicketType.valueOf(rawJwsType) + } catch (e: IllegalArgumentException) { + throw AuthenticationException(Message.INVALID_JWT_TOKEN) + } if (jwsType != expectedType) { throw AuthenticationException(Message.INVALID_JWT_TOKEN) } @@ -325,5 +329,6 @@ class JwtService( enum class TicketType { AUTH_MFA, IMG_ACCESS, + IMG_UPLOAD, } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/ImageUploadService.kt b/backend/data/src/main/kotlin/io/tolgee/service/ImageUploadService.kt index df8abca312b..4c1acf6212e 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/ImageUploadService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/ImageUploadService.kt @@ -35,6 +35,7 @@ class ImageUploadService( companion object { const val UPLOADED_IMAGES_STORAGE_FOLDER_NAME = "uploadedImages" + private val IMAGE_CONTENT_TYPES = setOf("image/png", "image/jpeg", "image/gif") } @Transactional @@ -108,8 +109,7 @@ class ImageUploadService( } fun validateIsImage(image: MultipartFile) { - val contentTypes = listOf("image/png", "image/jpeg", "image/gif") - if (!contentTypes.contains(image.contentType!!)) { + if (image.contentType !in IMAGE_CONTENT_TYPES) { throw ValidationException(Message.FILE_NOT_IMAGE) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/ScreenshotService.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/ScreenshotService.kt index 24b730c56c4..bfe0906c76b 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/ScreenshotService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/ScreenshotService.kt @@ -119,17 +119,17 @@ class ScreenshotService( positions = positions ?: mutableListOf() positions!!.add( KeyInScreenshotPosition( - positionDto.x.adjustByRation(xRatio), - positionDto.y.adjustByRation(yRatio), - positionDto.width.adjustByRation(xRatio), - positionDto.height.adjustByRation(yRatio), + positionDto.x.adjustByRatio(xRatio), + positionDto.y.adjustByRatio(yRatio), + positionDto.width.adjustByRatio(xRatio), + positionDto.height.adjustByRatio(yRatio), ), ) } } } - fun Int.adjustByRation(ratio: Double): Int { + fun Int.adjustByRatio(ratio: Double): Int { return (this * ratio).roundToInt() } @@ -164,10 +164,7 @@ class ScreenshotService( throw PermissionException(Message.CURRENT_USER_DOES_NOT_OWN_IMAGE) } - val info = - screenshotInfo.let { - ScreenshotInfoDto(it.text, it.positions) - } + val info = ScreenshotInfoDto(screenshotInfo.text, screenshotInfo.positions) val (screenshot, originalDimension, targetDimension) = saveScreenshot(image) @@ -236,6 +233,79 @@ class ScreenshotService( image?.let { fileStorage.storeFile(screenshot.getFilePath(), it) } } + @Transactional + fun saveUploadedImagesForKeys(keyScreenshots: List>>) { + val allImageIds = keyScreenshots.flatMap { (_, screenshots) -> screenshots.map { it.uploadedImageId } }.distinct() + val images = imageUploadService.find(allImageIds).associateBy { it.id } + + val missingIds = allImageIds.filter { it !in images } + if (missingIds.isNotEmpty()) { + throw NotFoundException(Message.ONE_OR_MORE_IMAGES_NOT_FOUND) + } + + images.values.forEach { image -> + if (authenticationFacade.authenticatedUser.id != image.userAccount.id) { + throw PermissionException(Message.CURRENT_USER_DOES_NOT_OWN_IMAGE) + } + } + + checkScreenshotsPerKeyLimit(keyScreenshots) + + val createdScreenshots = + images + .map { (id, image) -> + id to saveScreenshot(image) + }.toMap() + + // One reference per (key, image). DTOs that repeat the same image for a key are merged rather + // than added as duplicate references. + val grouped = LinkedHashMap, Pair>>() + for ((key, screenshots) in keyScreenshots) { + for (screenshotDto in screenshots) { + grouped + .getOrPut(key.id to screenshotDto.uploadedImageId) { key to mutableListOf() } + .second + .add(screenshotDto) + } + } + + grouped.forEach { (groupKey, entry) -> + val (key, dtos) = entry + val result = createdScreenshots.getValue(groupKey.second) + val mergedPositions = dtos.flatMap { it.positions ?: emptyList() }.ifEmpty { null } + val text = dtos.firstNotNullOfOrNull { it.text } + val info = ScreenshotInfoDto(text, mergedPositions) + addReference(key, result.screenshot, info, result.originalDimension, result.targetDimension) + } + } + + /** + * Must run before any image is converted: saveScreenshot writes files and deletes the uploaded + * source, which a transaction rollback cannot undo, so an over-limit batch would leave orphans + * if checked later. Counts distinct images per key — the same image twice for one key is one + * reference. + */ + private fun checkScreenshotsPerKeyLimit(keyScreenshots: List>>) { + val max = tolgeeProperties.maxScreenshotsPerKey + keyScreenshots + .groupBy { it.first.id } + .forEach { (_, group) -> + val key = group.first().first + val newDistinctCount = + group + .flatMap { it.second } + .map { it.uploadedImageId } + .distinct() + .size + if (getScreenshotsCountForKey(key) + newDistinctCount > max) { + throw BadRequestException( + Message.MAX_SCREENSHOTS_EXCEEDED, + listOf(max), + ) + } + } + } + @Transactional fun findAll(key: Key): List { return screenshotRepository.findAllByKey(key) @@ -243,9 +313,7 @@ class ScreenshotService( @Transactional fun delete(screenshots: Collection) { - screenshots.forEach { - delete(it) - } + screenshots.forEach(::delete) } @Transactional @@ -401,12 +469,11 @@ class ScreenshotService( fun getScreenshotsForKeys(keyIds: Collection): Map> { return this .getKeysWithScreenshots(keyIds) - .associate { - it.id to - it.keyScreenshotReferences + .associate { key -> + key.id to + key.keyScreenshotReferences .map { it.screenshot } - .toSet() - .toList() + .distinctBy { it.id } } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt b/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt index fb36938abc6..03f79c4d08c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/key/utils/KeysImporter.kt @@ -1,11 +1,17 @@ package io.tolgee.service.key.utils +import io.tolgee.component.KeyCustomValuesValidator +import io.tolgee.constants.Message import io.tolgee.dtos.request.translation.ImportKeysItemDto +import io.tolgee.exceptions.BadRequestException import io.tolgee.formats.convertToPluralIfAnyIsPlural import io.tolgee.model.Project import io.tolgee.model.key.Key +import io.tolgee.model.key.KeyCodeReference +import io.tolgee.model.key.KeyComment import io.tolgee.model.key.KeyMeta import io.tolgee.model.key.Namespace +import io.tolgee.security.authentication.AuthenticationFacade import io.tolgee.service.branching.BranchService import io.tolgee.service.key.KeyMetaService import io.tolgee.service.key.KeyService @@ -31,6 +37,10 @@ class KeysImporter( private val securityService: SecurityService = applicationContext.getBean(SecurityService::class.java) private val keyMetaService: KeyMetaService = applicationContext.getBean(KeyMetaService::class.java) private val branchService: BranchService = applicationContext.getBean(BranchService::class.java) + private val keyCustomValuesValidator: KeyCustomValuesValidator = + applicationContext.getBean(KeyCustomValuesValidator::class.java) + private val authenticationFacade: AuthenticationFacade = + applicationContext.getBean(AuthenticationFacade::class.java) fun import() { val languageTags = keys.flatMap { it.translations.keys }.toSet() @@ -48,6 +58,8 @@ class KeysImporter( val toTag = mutableMapOf>() val keyMetasToSave = mutableListOf() + val commentsToSave = mutableListOf() + val codeReferencesToSave = mutableListOf() keys.forEach { keyDto -> val safeNamespace = getSafeNamespace(keyDto.namespace) @@ -84,16 +96,50 @@ class KeysImporter( toTag[key] = keyDto.tags } } - if (keyDto.description != key.keyMeta?.description) { + val hasMeta = + keyDto.description != null || + keyDto.custom != null || + !keyDto.comments.isNullOrEmpty() || + !keyDto.codeReferences.isNullOrEmpty() + if (hasMeta) { val keyMeta = key.keyMeta ?: KeyMeta(key = key) key.keyMeta = keyMeta keyMeta.description = keyDto.description + keyDto.custom?.let { + keyCustomValuesValidator.validate(it) + keyMeta.custom = it.toMutableMap() + } + val author = authenticationFacade.authenticatedUserEntity + keyDto.comments?.forEach { commentText -> + if (commentText.isBlank()) return@forEach + if (commentText.length > KeyComment.TEXT_MAX_LENGTH) { + throw BadRequestException(Message.KEY_COMMENT_TOO_LONG, listOf(KeyComment.TEXT_MAX_LENGTH)) + } + keyMeta.addComment(author) { text = commentText } + } + keyDto.codeReferences?.forEach { ref -> + if (ref.path.isBlank()) return@forEach + if (ref.path.length > KeyCodeReference.PATH_MAX_LENGTH) { + throw BadRequestException( + Message.KEY_CODE_REFERENCE_PATH_TOO_LONG, + listOf(KeyCodeReference.PATH_MAX_LENGTH), + ) + } + keyMeta.addCodeReference(author) { + path = ref.path + line = ref.line + } + } keyMetasToSave.add(keyMeta) + commentsToSave.addAll(keyMeta.comments) + codeReferencesToSave.addAll(keyMeta.codeReferences) } } } tagService.tagKeys(toTag) keyMetaService.saveAll(keyMetasToSave) + keyMetaService.saveAllComments(commentsToSave) + keyMetaService.saveAllCodeReferences(codeReferencesToSave) } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/mcp/McpImageUploadUrlService.kt b/backend/data/src/main/kotlin/io/tolgee/service/mcp/McpImageUploadUrlService.kt new file mode 100644 index 00000000000..30ddb6ca56d --- /dev/null +++ b/backend/data/src/main/kotlin/io/tolgee/service/mcp/McpImageUploadUrlService.kt @@ -0,0 +1,40 @@ +package io.tolgee.service.mcp + +import io.tolgee.component.BackendUrlProvider +import io.tolgee.configuration.tolgee.TolgeeProperties +import io.tolgee.model.UserAccount +import io.tolgee.security.authentication.JwtService +import io.tolgee.service.security.UserAccountService +import org.springframework.stereotype.Service + +data class IssuedUploadUrl( + val uploadUrl: String, + val expiresInSeconds: Long, +) + +@Service +class McpImageUploadUrlService( + private val jwtService: JwtService, + private val userAccountService: UserAccountService, + private val backendUrlProvider: BackendUrlProvider, + private val tolgeeProperties: TolgeeProperties, +) { + fun issueUploadUrl(userAccountId: Long): IssuedUploadUrl { + val lifetimeMs = tolgeeProperties.mcp.imageUploadUrlExpirationMs + val token = + jwtService.emitTicket( + userAccountId, + JwtService.TicketType.IMG_UPLOAD, + expiresAfter = lifetimeMs, + ) + return IssuedUploadUrl( + uploadUrl = "${backendUrlProvider.url}/v2/public/image-upload?token=$token", + expiresInSeconds = lifetimeMs / 1000, + ) + } + + fun resolveUserFromUploadToken(token: String): UserAccount { + val auth = jwtService.validateTicket(token, JwtService.TicketType.IMG_UPLOAD) + return userAccountService.get(auth.userAccount.id) + } +} diff --git a/backend/data/src/main/kotlin/io/tolgee/util/ImageConverter.kt b/backend/data/src/main/kotlin/io/tolgee/util/ImageConverter.kt index 0fd85a264ac..a6441b314a6 100644 --- a/backend/data/src/main/kotlin/io/tolgee/util/ImageConverter.kt +++ b/backend/data/src/main/kotlin/io/tolgee/util/ImageConverter.kt @@ -46,7 +46,11 @@ class ImageConverter( } private fun getScaledImage(targetDimension: Dimension): BufferedImage { - val resized = BufferedImage(targetDimension.width, targetDimension.height, sourceBufferedImage.type) + // Callers' integer-division ratios can floor an extreme-aspect side to 0; BufferedImage rejects a + // 0 dimension, so clamp. + val width = targetDimension.width.coerceAtLeast(1) + val height = targetDimension.height.coerceAtLeast(1) + val resized = BufferedImage(width, height, sourceBufferedImage.type) val g = resized.createGraphics() g.setRenderingHint( RenderingHints.KEY_INTERPOLATION, @@ -56,8 +60,8 @@ class ImageConverter( sourceBufferedImage, 0, 0, - targetDimension.width, - targetDimension.height, + width, + height, 0, 0, sourceBufferedImage.width, diff --git a/backend/data/src/main/kotlin/io/tolgee/util/ImageProcessor.kt b/backend/data/src/main/kotlin/io/tolgee/util/ImageProcessor.kt index 345c03381e0..2bc6fdf9876 100644 --- a/backend/data/src/main/kotlin/io/tolgee/util/ImageProcessor.kt +++ b/backend/data/src/main/kotlin/io/tolgee/util/ImageProcessor.kt @@ -1,7 +1,10 @@ package io.tolgee.util +import io.tolgee.constants.Message +import io.tolgee.dtos.request.validators.exceptions.ValidationException import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream +import java.io.IOException import java.io.InputStream import javax.imageio.IIOImage import javax.imageio.ImageIO @@ -12,7 +15,21 @@ abstract class ImageProcessor( protected val imageStream: InputStream, ) { protected val sourceBufferedImage: BufferedImage by lazy { - ImageIO.read(imageStream) + // ImageIO.read can return null (no reader) OR throw unchecked AIOOBE / NegativeArraySizeException / + // IllegalArgumentException on malformed bytes — all catches are intentional. + val decoded = + try { + ImageIO.read(imageStream) + } catch (e: IOException) { + null + } catch (e: IllegalArgumentException) { + null + } catch (e: ArrayIndexOutOfBoundsException) { + null + } catch (e: NegativeArraySizeException) { + null + } + decoded ?: throw ValidationException(Message.FILE_NOT_IMAGE) } protected fun writeImage( diff --git a/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt b/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt index 64969008e49..b466788e528 100644 --- a/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/security/authentication/JwtServiceTest.kt @@ -16,6 +16,7 @@ package io.tolgee.security.authentication +import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.security.Keys import io.tolgee.component.CurrentDateProvider @@ -152,6 +153,23 @@ class JwtServiceTest { assertThrows { jwtService.validateTicket(ticket, JwtService.TicketType.IMG_ACCESS) } } + @Test + fun `it rejects a validly-signed ticket with an unknown type as a clean AuthenticationException`() { + val now = currentDateProvider.date + val token = + Jwts + .builder() + .signWith(testSigningKey) + .setIssuedAt(now) + .setAudience(JwtService.JWT_TICKET_AUDIENCE) + .setSubject(TEST_USER_ID.toString()) + .setExpiration(Date(now.time + 60_000)) + .claim(JwtService.JWT_TICKET_TYPE_CLAIM, "NOT_A_REAL_TYPE") + .compact() + + assertThrows { jwtService.validateTicket(token, JwtService.TicketType.IMG_UPLOAD) } + } + @Test fun `it stores arbitrary data in tickets`() { val data = mapOf("owo" to "uwu", "meow" to "nya", "testing" to "yes yes") diff --git a/backend/data/src/test/kotlin/io/tolgee/unit/ImageConverterValidationTest.kt b/backend/data/src/test/kotlin/io/tolgee/unit/ImageConverterValidationTest.kt new file mode 100644 index 00000000000..f1ce64675ef --- /dev/null +++ b/backend/data/src/test/kotlin/io/tolgee/unit/ImageConverterValidationTest.kt @@ -0,0 +1,55 @@ +package io.tolgee.unit + +import io.tolgee.dtos.request.validators.exceptions.ValidationException +import io.tolgee.fixtures.undecodableImageBytes +import io.tolgee.util.ImageConverter +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import javax.imageio.ImageIO + +class ImageConverterValidationTest { + @Test + fun `throws FILE_NOT_IMAGE when bytes are undecodable (ImageIO returns null)`() { + val converter = ImageConverter(ByteArrayInputStream(undecodableImageBytes)) + assertThrows { converter.originalDimension } + } + + @Test + fun `throws FILE_NOT_IMAGE when the image stream throws IOException`() { + val throwing = + object : InputStream() { + override fun read(): Int = throw IOException("boom") + } + val converter = ImageConverter(throwing) + assertThrows { converter.originalDimension } + } + + @Test + fun `degenerate thumbnail aspect does not produce a zero dimension`() { + val converter = ImageConverter(ByteArrayInputStream(pngBytes(201, 1))) + assertDoesNotThrow { converter.getThumbnail(200) } + assertDoesNotThrow { ImageConverter(ByteArrayInputStream(pngBytes(1, 201))).getThumbnail(200) } + } + + @Test + fun `degenerate over-3M-px aspect does not produce a zero dimension`() { + val converter = ImageConverter(ByteArrayInputStream(pngBytes(3_000_001, 1))) + assertDoesNotThrow { converter.getImage() } + } + + private fun pngBytes( + width: Int, + height: Int, + ): ByteArray { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + val outputStream = ByteArrayOutputStream() + ImageIO.write(image, "png", outputStream) + return outputStream.toByteArray() + } +} diff --git a/backend/testing/src/main/kotlin/io/tolgee/AbstractMcpTest.kt b/backend/testing/src/main/kotlin/io/tolgee/AbstractMcpTest.kt index 73c18b39a64..c544a1d00bd 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/AbstractMcpTest.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/AbstractMcpTest.kt @@ -28,6 +28,11 @@ abstract class AbstractMcpTest : AbstractSpringTest() { private val clients = mutableListOf() + companion object { + const val MINIMAL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" + } + fun createMcpClientWithPat(pat: String): McpSyncClient = createMcpClientWithHeader("tgpat_$pat") fun createMcpClientWithPak(pak: String): McpSyncClient = createMcpClientWithHeader("tgpak_$pak") @@ -64,6 +69,11 @@ abstract class AbstractMcpTest : AbstractSpringTest() { clients.clear() } + @AfterEach + fun clearForcedDateAfterTest() { + clearForcedDate() + } + fun createTestDataWithPat(): McpPatTestData { var pat: Pat? = null val base = BaseTestData() @@ -86,12 +96,17 @@ abstract class AbstractMcpTest : AbstractSpringTest() { ) } - fun createTestDataWithPak(scopes: Set = Scope.entries.toSet()): McpPakTestData { + fun createTestDataWithPak( + scopes: Set = Scope.entries.toSet(), + userName: String = "pak_test_user", + projectName: String = "pak_test_project", + pakKey: String = "test_pak_key", + ): McpPakTestData { var apiKey: ApiKey? = null - val base = BaseTestData(userName = "pak_test_user", projectName = "pak_test_project") + val base = BaseTestData(userName = userName, projectName = projectName) base.projectBuilder.build { addApiKey { - key = "test_pak_key" + key = pakKey scopesEnum = scopes.toMutableSet() userAccount = base.userAccountBuilder.self apiKey = this @@ -107,6 +122,7 @@ abstract class AbstractMcpTest : AbstractSpringTest() { apiKey = apiKey!!, projectId = base.project.id, organizationId = base.projectBuilder.self.organizationOwner.id, + userAccountId = base.userAccountBuilder.self.id, ) } @@ -148,6 +164,20 @@ abstract class AbstractMcpTest : AbstractSpringTest() { assertThat(exception!!.toString()).contains(expectedError) } + fun uploadImage(client: McpSyncClient): Long = + callToolAndGetJson(client, "upload_image", mapOf("image" to MINIMAL_PNG_BASE64))["uploadedImageId"].asLong() + + /** + * Accepts a tool failure surfacing EITHER as a thrown JSON-RPC error OR as an error + * [McpSchema.CallToolResult]. + */ + fun expectToolFailure(block: () -> McpSchema.CallToolResult) { + runCatching(block).fold( + onSuccess = { assertThat(it.isError).isTrue() }, + onFailure = { }, + ) + } + data class McpPatTestData( val testData: BaseTestData, val pat: Pat, @@ -160,5 +190,6 @@ abstract class AbstractMcpTest : AbstractSpringTest() { val apiKey: ApiKey, val projectId: Long, val organizationId: Long, + val userAccountId: Long, ) } diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/undecodableImageBytes.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/undecodableImageBytes.kt new file mode 100644 index 00000000000..26e8698e27f --- /dev/null +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/undecodableImageBytes.kt @@ -0,0 +1,10 @@ +package io.tolgee.fixtures + +/** + * Bytes with no recognized image magic header, so **no** registered `ImageIO` reader claims them and + * `ImageIO.read` returns `null` (verified — a recognized-but-truncated header like BMP's `0x42 0x4D` + * instead makes a reader throw, exercising a different branch). Shared between the `:data` + * `ImageProcessor` unit test and the `:server-app` public-upload HTTP test so both exercise the same + * proven null-returning payload. + */ +val undecodableImageBytes: ByteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) diff --git a/webapp/CLAUDE.md b/webapp/CLAUDE.md index b17d8329dac..cad635c306e 100644 --- a/webapp/CLAUDE.md +++ b/webapp/CLAUDE.md @@ -1,26 +1,24 @@ # Webapp Translation Guidelines -## Adding New Translation Keys via API +## Adding New Translation Keys -When adding new translation keys, use the Tolgee REST API at `https://app.tolgee.io`. The API key is stored in -`.env.development.local` as `VITE_APP_TOLGEE_API_KEY`. +When adding new translation keys, use the Tolgee MCP tools. The project ID is in `webapp/.tolgeerc.json` (`projectId` +field). The API key is in `.env.development.local` as `VITE_APP_TOLGEE_API_KEY`. **Important:** Do NOT edit local translation files (`public/i18n/en.json`, etc.). Tolgee serves translations at runtime via its API/CDN, so local files are only fallbacks shipped with the repo. New keys only need to be created via the -Tolgee REST API — the running app will pick them up automatically. +Tolgee MCP tools — the running app will pick them up automatically. -**Important:** Before making any Tolgee API calls, present a summary of ALL planned actions upfront (key creation, -context upload, tagging) with the full details (key names, translations, tags, etc.). Ask the user for confirmation +**Important:** Translations MUST always be uploaded to Tolgee — never skip this step. Before making any Tolgee calls, +present a summary of ALL planned keys (names, English translations, tags) for user confirmation. Ask for confirmation once, then execute all calls together. -**Important:** Always upload screenshots for each key. Upload BigMeta context when at least 2 related keys are present. - -**Important:** Always provide `defaultValue` when using the `T` component or `t()` function. This ensures the UI -displays meaningful text before translations are uploaded to Tolgee, which is needed for screenshots to show correct -initial values. +**Important:** Screenshots are mandatory for every new key, unless the user explicitly asks to skip them. **Important:** Never concatenate translated text with other strings. Instead, use ICU message format parameters. +## Code Patterns + ```tsx // T component @@ -41,92 +39,75 @@ t('my_key', 'Hello {name}', { name: userName }) // {t('greeting', 'Hello — {name}', { name })} ``` -### 1. Take Screenshot and Get Key Positions +### defaultValue Is Temporary -Before creating keys, take a screenshot of the running app so the screenshot shows the UI with `defaultValue` text. -Use Playwright MCP to navigate to the page, take a screenshot, and call `window.__tolgee.getVisibleKeys()` to get -all visible translation keys with their positions. +Add `defaultValue` **only to the new keys you are adding** — it makes those keys visible in the UI for screenshots. +Do NOT add `defaultValue` to existing keys. After screenshots are taken, **remove all `defaultValue` props you added**. +The actual translations live in Tolgee, not in the source. -**Important:** Resize the Playwright viewport to a fixed size (e.g. 1280x720) **before** navigating to the page. -The positions from `getVisibleKeys()` are in CSS pixels relative to the viewport, so the viewport dimensions must -match the screenshot dimensions. Without this, highlights will be offset in Tolgee. +## Workflow -```js -// Set a fixed viewport size first -await page.setViewportSize({ width: 1280, height: 720 }); +### 1. Add defaultValue to New Keys -// Navigate and get visible keys with positions -const keys = await page.evaluate(() => window.__tolgee.getVisibleKeys()); -// Returns: [{ keyName, keyNamespace, position: { x, y, width, height } }, ...] +Add `defaultValue` to the new `T` components and `t()` calls you are creating, so the UI renders visible text for +screenshots: -// Take screenshot -await page.screenshot({ path: 'screenshot.png' }); +```tsx + ``` -### 2. Upload the Image +Only add `defaultValue` to the keys you are creating — not to existing ones. -**Endpoint:** `POST https://app.tolgee.io/v2/image-upload` +### 2. Take Screenshots (Mandatory) -```bash -curl -X POST "https://app.tolgee.io/v2/image-upload" \ - -H "X-API-Key: ${VITE_APP_TOLGEE_API_KEY}" \ - -F "image=@screenshot.png" -``` +Screenshots are mandatory unless the user explicitly opts out. + +Use the `/doc-screenshot` skill to take polished screenshots. If the skill is not available, make sure the app is +running and you have navigated to the relevant page, then follow the basic instructions below. + +#### Get Key Positions for Tolgee + +After navigating to the page, get the bounding boxes of new translation keys: -Response includes `"id": 123456` — this is the `uploadedImageId` for the next step. - -### 3. Create Keys with Translations, Tags, and Screenshots - -Use `single-step-import-resolvable` to create all keys at once with their translations, tags, and screenshot -references in a single API call. This replaces the need for separate key creation, tagging, and screenshot -association steps. - -**Endpoint:** `POST https://app.tolgee.io/v2/projects/single-step-import-resolvable` - -```bash -curl -X POST "https://app.tolgee.io/v2/projects/single-step-import-resolvable" \ - -H "X-API-Key: ${VITE_APP_TOLGEE_API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{ - "keys": [ - { - "name": "my_key", - "translations": { - "en": "English text" - }, - "tags": ["draft: my-feature-branch"], - "screenshots": [{ - "uploadedImageId": 123456, - "positions": [{"x": 100, "y": 200, "width": 150, "height": 40}] - }] - } - ] - }' +```js +const keys = await page.evaluate(() => window.__tolgee.getVisibleKeys()); +// Returns: [{ keyName, keyNamespace, position: { x, y, width, height } }, ...] ``` -Map each entry from `getVisibleKeys()` to a key in the `keys` array, using the `position` values for `positions`. -A key appearing multiple times (e.g. repeated buttons) should have multiple entries in `positions`. -Only provide translations for the base language (English [en]). -Tag each key using the branch tagging convention (see below): `"tags": ["draft: "]`. +`window.__tolgee` is available in development mode (provided by the `DevTools()` plugin). The app must be fully loaded +and Tolgee initialized before calling this. Filter the results to only include the new keys you added. -### 4. Upload Context (Related Keys) +#### Upload Screenshots -Store which keys appear together on the same page/component for better MT suggestions. Requires at least 2 keys. +1. Call the `get_image_upload_url` MCP tool — it returns a short-lived `uploadUrl`. +2. Upload the screenshot file to that URL (the bytes go out-of-band, not through the model): + ```bash + curl -F image=@ "" + ``` + The response contains an `uploadedImageId` to use in the `screenshots` field below. -**Endpoint:** `POST https://app.tolgee.io/v2/projects/big-meta` +### 3. Remove defaultValue from Code -```bash -curl -X POST "https://app.tolgee.io/v2/projects/big-meta" \ - -H "X-API-Key: ${VITE_APP_TOLGEE_API_KEY}" \ - -H "Content-Type: application/json" \ - -d '{ - "relatedKeysInOrder": [ - { "keyName": "key_appearing_on_same_page_1" }, - { "keyName": "key_appearing_on_same_page_2" }, - { "keyName": "key_appearing_on_same_page_3" } - ] - }' -``` +`defaultValue` was only needed for screenshots. Remove it from all `T` components and `t()` calls you added it to. + +### 4. Create Keys with Translations and Screenshots + +Use the `create_keys` MCP tool. For each key provide: +- `name` — the key name +- `translations` — `{ "en": "English text" }` (base language only) +- `tags` — `["draft: "]` (see branch tagging convention below) +- `description` — optional developer context +- `screenshots` — `[{ "uploadedImageId": , "positions": [{ "x": ..., "y": ..., "width": ..., "height": ... }] }]` + +Map `getVisibleKeys()` entries to the `positions` array. If a key appears multiple times on screen (e.g. repeated +buttons), include multiple position entries. Only include positions for the new keys you are creating. + +To add screenshots to **existing** keys (without creating new ones), use the `add_key_screenshots` MCP tool instead. + +### 5. Upload Context (Related Keys) + +Use the `store_big_meta` MCP tool when at least 2 related keys are present. This tells Tolgee which keys appear +together, improving machine translation consistency. ## Branch Tagging Convention diff --git a/webapp/src/service/apiSchema.generated.ts b/webapp/src/service/apiSchema.generated.ts index 06b57a36cef..f3371607ce6 100644 --- a/webapp/src/service/apiSchema.generated.ts +++ b/webapp/src/service/apiSchema.generated.ts @@ -145,7 +145,7 @@ export interface paths { put: operations["setLicenseKey"]; }; "/v2/image-upload": { - post: operations["upload"]; + post: operations["upload_1"]; }; "/v2/image-upload/{ids}": { delete: operations["delete_17"]; @@ -1060,6 +1060,10 @@ export interface paths { "/v2/public/export-info/formats": { get: operations["get_18"]; }; + "/v2/public/image-upload": { + /** Unauthenticated. Authorization is the short-lived signed `token` issued by the `get_image_upload_url` MCP tool. Returns the `uploadedImageId` to use with create_keys / add_key_screenshots. */ + post: operations["upload"]; + }; "/v2/public/initial-data": { /** Returns initial data required by the UI to load */ get: operations["get_17"]; @@ -3329,6 +3333,22 @@ export interface components { keys: components["schemas"]["ImportKeysItemDto"][]; }; ImportKeysItemDto: { + /** @description Code references — where the key is used in the source code */ + codeReferences?: components["schemas"]["KeyCodeReferenceRequest"][]; + /** + * @description Comments to attach to the key + * @example [ + * "Shown in the homepage hero section" + * ] + */ + comments?: string[]; + /** + * @description Arbitrary structured metadata (custom values) stored on the key + * @example { + * "reactComponent": "SignUpButton" + * } + */ + custom?: { [key: string]: unknown }; /** * @description Description of key * @example This key is used on homepage. It's a label of sign up button. @@ -3526,6 +3546,19 @@ export interface components { accessToken?: string; tokenType?: string; }; + KeyCodeReferenceRequest: { + /** + * Format: int64 + * @description Line number in the file where the key is used + * @example 42 + */ + line?: number; + /** + * @description Path to the file where the key is used + * @example src/components/Header.tsx + */ + path: string; + }; KeyDefinitionDto: { name: string; namespace?: string; @@ -6839,6 +6872,10 @@ export interface components { dueDate?: number; name?: string; }; + UploadedImageMcpModel: { + /** Format: int64 */ + uploadedImageId: number; + }; UploadedImageModel: { /** Format: date-time */ createdAt: string; @@ -8591,7 +8628,7 @@ export interface operations { }; }; }; - upload: { + upload_1: { responses: { /** Created */ 201: { @@ -21753,6 +21790,54 @@ export interface operations { }; }; }; + /** Unauthenticated. Authorization is the short-lived signed `token` issued by the `get_image_upload_url` MCP tool. Returns the `uploadedImageId` to use with create_keys / add_key_screenshots. */ + upload: { + parameters: { + query: { + token: string; + }; + }; + responses: { + /** Created */ + 201: { + content: { + "*/*": components["schemas"]["UploadedImageMcpModel"]; + }; + }; + /** Bad Request */ + 400: { + content: { + "application/json": string; + }; + }; + /** Unauthorized */ + 401: { + content: { + "application/json": string; + }; + }; + /** Forbidden */ + 403: { + content: { + "application/json": string; + }; + }; + /** Not Found */ + 404: { + content: { + "application/json": string; + }; + }; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + image: string; + }; + }; + }; + }; /** Returns initial data required by the UI to load */ get_17: { responses: {