Skip to content

Conversation

@JanCizmar
Copy link
Contributor

@JanCizmar JanCizmar commented Aug 13, 2025

Summary by CodeRabbit

  • New Features

    • Export endpoints now honor HTTP conditional requests (Last-Modified / If-Modified-Since) and return 304/412 when unchanged to save bandwidth.
    • WebSocket authentication expanded to accept API keys and personal access tokens in addition to JWT; client options updated to send proper auth headers.
  • Tests

    • Added tests covering export caching behavior and WebSocket authentication scenarios across auth schemes.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 13, 2025

Walkthrough

Adds HTTP conditional-request handling via a new ProjectLastModifiedManager and applies it to export and translation endpoints; refactors WebSocket auth to a new WebsocketAuthenticationResolver that unifies JWT, PAK, and PAT handling and updates test and client code to the new interfaces.

Changes

Cohort / File(s) Summary
ProjectLastModifiedManager & export gating
backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt, backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt, backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt, backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt
New component centralizes Last-Modified/If-Modified-Since handling; controllers inject it and wrap export/translation flows to return 304/412 when project data unchanged and set Last-Modified / Cache-Control when returning data.
WebSocket authentication refactor
backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt, backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt
Introduces WebsocketAuthenticationResolver to resolve CONNECT headers (Authorization Bearer, X-API-Key PAT/PAK, legacy jwtToken) into TolgeeAuthentication; WebSocketConfig now uses resolver and adjusts permission checks and auth extraction.
Export caching tests & assertions
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt, backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt, backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
New/updated tests verify Last-Modified behavior, 304/412 responses, and adjust assertions/imports to AssertJ; adds caching-focused test class for V2 exports.
WebSocket tests & test helper updates
backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt, backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt, backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt
Tests added for multi-scheme WebSocket auth; WebsocketTestHelper now takes an Auth wrapper (jwtToken or apiKey), exposes waitForForbidden/Unauthenticated methods and tracks authentication status; AbstractWebsocketTest updated call sites.
Client & supporting model/test changes
webapp/src/websocket-client/WebsocketClient.ts, backend/data/src/main/kotlin/io/tolgee/model/Pat.kt, backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt
Websocket client options/type renamed and header construction adjusted; Pat model gains tokenWithPrefix; batch/test websocket helper call sites updated to use Auth wrapper.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant V2ExportController
    participant ProjectLastModifiedManager
    participant ProjectTranslationLastModifiedManager
    participant WebRequest

    Client->>V2ExportController: GET/POST /export (with optional If-Modified-Since)
    V2ExportController->>ProjectLastModifiedManager: onlyWhenProjectDataChanged(request, fn)
    ProjectLastModifiedManager->>ProjectTranslationLastModifiedManager: getLastModified(projectId)
    ProjectTranslationLastModifiedManager-->>ProjectLastModifiedManager: timestamp
    ProjectLastModifiedManager->>WebRequest: checkNotModified(timestamp)
    alt Not modified
        WebRequest-->>ProjectLastModifiedManager: true
        ProjectLastModifiedManager-->>V2ExportController: null
        V2ExportController-->>Client: 304 Not Modified (or 412 where applicable)
    else Modified
        WebRequest-->>ProjectLastModifiedManager: false
        ProjectLastModifiedManager->>ProjectLastModifiedManager: build headers (Last-Modified, Cache-Control) and invoke fn
        ProjectLastModifiedManager-->>V2ExportController: ResponseEntity with StreamingResponseBody
        V2ExportController-->>Client: 200 OK + body + headers
    end
Loading
sequenceDiagram
    participant WSClient
    participant WebSocketConfig
    participant WebsocketAuthenticationResolver
    participant JwtService
    participant ApiKeyService
    participant SecurityService

    WSClient->>WebSocketConfig: CONNECT (Authorization / X-API-Key / legacy headers)
    WebSocketConfig->>WebsocketAuthenticationResolver: resolve(authHeader, xApiKeyHeader, legacyJwtHeader)
    alt Bearer JWT
        WebsocketAuthenticationResolver->>JwtService: validateToken
        JwtService-->>WebsocketAuthenticationResolver: TolgeeAuthentication
    else X-API-Key (PAK/PAT)
        WebsocketAuthenticationResolver->>ApiKeyService: validateKey (distinguish PAK/PAT)
        ApiKeyService-->>WebsocketAuthenticationResolver: TolgeeAuthentication (and async last-used update)
    else Legacy jwtToken
        WebsocketAuthenticationResolver->>JwtService: validateToken
        JwtService-->>WebsocketAuthenticationResolver: TolgeeAuthentication
    else invalid
        WebsocketAuthenticationResolver-->>WebSocketConfig: null
    end
    WebsocketAuthenticationResolver-->>WebSocketConfig: TolgeeAuthentication / null
    WebSocketConfig->>SecurityService: check subscribe permissions for authentication
    SecurityService-->>WebSocketConfig: permitted / forbidden
    WebSocketConfig-->>WSClient: CONNECTED or ERROR (403/401)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

  • Areas needing focused review:
    • ProjectLastModifiedManager: correctness of timestamp retrieval, WebRequest.checkNotModified usage, and header construction (thread-safety and semantics).
    • WebsocketAuthenticationResolver: error handling paths, expiry checks, async last-used updates, and fallback ordering.
    • Controller constructor signature changes and call-site updates in tests and client to ensure no missing wiring.
    • Tests that assert caching behavior (time-forcing and header comparisons) for flakiness.

Possibly related PRs

  • feat: supporter user role #3248: Modifies same V2ExportController export endpoints (adds @ReadOnlyOperation) and may intersect with export endpoint signature/behavior changes in this PR.

Poem

🐰
I nibble headers, sniff the date,
If nothing's new, I sit and wait.
JWT, PAT, and PAK in line,
I hop through auth — all works fine.
Hooray for caching — carrot time! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.11% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature: enabling websocket subscription with PAK (Project API Key) and PAT (Personal Access Token) authentication methods.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jancizmar/ws-with-api-key

Comment @coderabbitai help to get the list of available commands and usage tips.

@JanCizmar JanCizmar force-pushed the jancizmar/ws-with-api-key branch from 159086b to cb93702 Compare August 23, 2025 11:21
@github-actions github-actions bot added the stale label Sep 23, 2025
@tolgee tolgee deleted a comment from github-actions bot Sep 23, 2025
@JanCizmar JanCizmar removed the stale label Sep 23, 2025
@JanCizmar
Copy link
Contributor Author

I am working on it!

@github-actions
Copy link
Contributor

This PR is stale because it has been open for 30 days with no activity.

@github-actions github-actions bot added the stale label Oct 24, 2025
@JanCizmar JanCizmar added enhancement New feature or request and removed stale labels Oct 24, 2025
@JanCizmar JanCizmar marked this pull request as ready for review October 24, 2025 13:10
@JanCizmar JanCizmar marked this pull request as draft October 24, 2025 13:10
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt (1)

61-64: Stop all WebSockets to avoid cross-test leakage.

anotherUserWebsocket and spyingUserWebsocket aren’t closed; this can cause flakiness and port/socket leaks.

Apply teardown and try/finally:

@@
   @AfterEach
   fun after() {
-    currentUserWebsocket.stop()
+    if (this::currentUserWebsocket.isInitialized) currentUserWebsocket.stop()
+    if (this::anotherUserWebsocket.isInitialized) anotherUserWebsocket.stop()
   }
@@
-    val spyingUserWebsocket =
+    val spyingUserWebsocket =
       WebsocketTestHelper(
         port,
         WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(anotherUser.id)),
         testData.projectBuilder.self.id,
         // anotherUser trying to spy on other user's websocket
         testData.user.id,
       )
-    spyingUserWebsocket.listenForNotificationsChanged()
-    spyingUserWebsocket.waitForForbidden()
-    saveNotificationForCurrentUser()
-
-    assertCurrentUserReceivedMessage()
-    spyingUserWebsocket.receivedMessages.assert.isEmpty()
+    try {
+      spyingUserWebsocket.listenForNotificationsChanged()
+      spyingUserWebsocket.waitForForbidden()
+      saveNotificationForCurrentUser()
+      assertCurrentUserReceivedMessage()
+      spyingUserWebsocket.receivedMessages.assert.isEmpty()
+    } finally {
+      spyingUserWebsocket.stop()
+    }

Also applies to: 52-59, 235-253

🧹 Nitpick comments (11)
backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt (3)

48-51: Add coverage for PAK/PAT auth paths.

Good refactor to Auth wrapper. Please add tests that subscribe via PAK and PAT (success and forbidden cases), not just JWT, to validate the new resolver end-to-end.

Also applies to: 55-58, 242-246


220-233: Assert the forbidden subscribe explicitly.

This negative test should also await the 403 to avoid a false pass due to timing.

   fun `doesn't subscribe without permissions`() {
     currentUserWebsocket.listenForTranslationDataModified()
     anotherUserWebsocket.listenForTranslationDataModified()
+    anotherUserWebsocket.waitForForbidden()

215-217: Typo in doc comment.

“shell” → “shall”.

backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt (1)

49-84: Stream safety and header hygiene.

  • Use use {} to always close streams on error.
  • Consider adding Vary: Authorization, X-API-KEY to prevent shared-cache mix-ups when auth differs.
-      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
-        val zipOutputStream = ZipOutputStream(out)
+      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
+        val mapper = jacksonObjectMapper()
+        ZipOutputStream(out).use { zipOutputStream ->
           val translations =
             translationService.getTranslations(
               allLanguages.map { it.tag }.toSet(),
               null,
               projectHolder.project.id,
               '.',
             )
           for ((key, value) in translations) {
             zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
-          val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
-          val byteArrayInputStream = ByteArrayInputStream(data)
-          IOUtils.copy(byteArrayInputStream, zipOutputStream)
-          byteArrayInputStream.close()
+            val data = mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
+            ByteArrayInputStream(data).use { byteArrayInputStream ->
+              IOUtils.copy(byteArrayInputStream, zipOutputStream)
+            }
             zipOutputStream.closeEntry()
           }
-        zipOutputStream.close()
-      }
+        }
+      }

Also please confirm authenticationFacade.authenticatedUser.id is always present for API key/PAT flows on this endpoint. If not, derive permissions from the token principal instead.

backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt (1)

10-17: Clarify semantics and add optional cache headers.

  • Update KDoc to mention 304 (GET/HEAD) and 412 (POST/PUT/DELETE) outcomes from checkNotModified.
  • Add Vary: Authorization, X-API-KEY by default to avoid cache poisoning across auth contexts.
  • Consider allowing a custom CacheControl to be passed in when needed.
@@
- * This manager implements the HTTP conditional request mechanism (If-Modified-Since/Last-Modified headers)
+ * This manager implements HTTP conditional requests (If-Modified-Since/Last-Modified).
+ * For GET/HEAD it results in 304 Not Modified; for modifying methods (POST/PUT/DELETE) Spring returns 412.
@@
-    val headersBuilder = ResponseEntity
+    val headersBuilder = ResponseEntity
       .ok()
       .lastModified(lastModified)
       .cacheControl(DEFAULT_CACHE_CONTROL_HEADER)
+      .varyBy("Authorization", "X-API-KEY")

Also applies to: 41-67, 69-71

backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt (3)

48-73: Fix test name and safe header retrieval (GET path).

  • Name says POST but the test hits GET.
  • Avoid as String on a nullable header; it can throw before the assert.
-  fun `returns 304 for POST export when data not modified`() {
+  fun `returns 304 for GET export when data not modified`() {
@@
-      val lastModifiedHeader = firstResponse.response.getHeaderValue("Last-Modified") as String
-      Assertions.assertThat(lastModifiedHeader).isNotNull()
+      val lastModifiedHeader = firstResponse.response.getHeader("Last-Modified")
+      Assertions.assertThat(lastModifiedHeader).isNotNull()
+      // Optional: also assert Cache-Control
+      Assertions.assertThat(firstResponse.response.getHeader("Cache-Control")).isEqualTo("max-age=0")

90-109: Fix misleading comment and assert cache header (POST path).

Comment says 304 in a test that asserts 412. Add cache header assertion for parity with GET.

-      // Second request with If-Modified-Since header - should return 304
+      // Second request with If-Modified-Since header - server returns 412 for POST
@@
       Assertions.assertThat(secondResponse.response.status).isEqualTo(412)
+      Assertions.assertThat(secondResponse.response.getHeader("Cache-Control")).isEqualTo("max-age=0")

111-137: Reduce blanket retries and cleanups.

The retry matcher swallows NPEs and data integrity issues that may hide real regressions. Consider narrowing exceptions or lowering retries once flakiness is addressed.

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (2)

171-181: Close streams via use{} to avoid leaks on error.

-    return ResponseEntity.ok().headers(headers).body(
-      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
-        IOUtils.copy(stream, out)
-        stream.close()
-        out.close()
-      },
-    )
+    return ResponseEntity.ok().headers(headers).body(
+      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
+        stream.use { input -> out.use { IOUtils.copy(input, it) } }
+      },
+    )

184-192: Use use{} for ZipOutputStream and input streams.

-    return ResponseEntity.ok().headers(httpHeaders).body(
-      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
-        streamZipResponse(out, exported)
-      },
-    )
+    return ResponseEntity.ok().headers(httpHeaders).body(
+      streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
+        streamZipResponse(out, exported)
+      },
+    )
@@
-  private fun streamZipResponse(
+  private fun streamZipResponse(
     out: OutputStream,
     exported: Map<String, InputStream>,
   ) {
-    val zipOutputStream = ZipOutputStream(out)
-
-    exported.forEach { (fileAbsolutePath, stream) ->
-      zipOutputStream.putNextEntry(ZipEntry(fileAbsolutePath))
-      IOUtils.copy(stream, zipOutputStream)
-      stream.close()
-      zipOutputStream.closeEntry()
-    }
-
-    zipOutputStream.close()
+    ZipOutputStream(out).use { zipOutputStream ->
+      exported.forEach { (fileAbsolutePath, stream) ->
+        zipOutputStream.putNextEntry(ZipEntry(fileAbsolutePath))
+        stream.use { IOUtils.copy(it, zipOutputStream) }
+        zipOutputStream.closeEntry()
+      }
+    }
   }

Also applies to: 194-208

backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt (1)

79-145: Verify test annotation usage for PAK/PAT tests.

Several tests that validate PAK or PAT authentication use the @ProjectJWTAuthTestMethod annotation (lines 79, 88, 115, 127, 136, 149) rather than @ProjectApiKeyAuthTestMethod. While this might be intentional if the annotations only control REST API authentication setup (like the createKey() call), it could be confusing to readers.

Please verify that this annotation usage is correct and consider adding a comment explaining why JWT annotations are used for PAK/PAT websocket authentication tests if this is intentional.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d537b39 and 3656b23.

📒 Files selected for processing (16)
  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (3 hunks)
  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt (3 hunks)
  • backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt (1 hunks)
  • backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt (4 hunks)
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (6 hunks)
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt (2 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt (7 hunks)
  • backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt (2 hunks)
  • backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt (2 hunks)
  • backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt (7 hunks)
  • backend/data/src/main/kotlin/io/tolgee/model/Pat.kt (2 hunks)
  • webapp/src/websocket-client/WebsocketClient.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt (2)
backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt (2)
  • setForcedDate (272-274)
  • clearForcedDate (276-278)
backend/testing/src/main/kotlin/io/tolgee/fixtures/ProjectAuthRequestPerformer.kt (1)
  • performProjectAuthGet (36-36)
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt (1)
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt (1)
  • parseZip (227-237)
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt (1)
backend/testing/src/main/kotlin/io/tolgee/AbstractSpringTest.kt (2)
  • clearCaches (231-236)
  • clearForcedDate (276-278)
🪛 detekt (1.23.8)
backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt

[warning] 94-94: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🔇 Additional comments (11)
backend/data/src/main/kotlin/io/tolgee/model/Pat.kt (1)

3-3: LGTM!

The import is necessary for the new tokenWithPrefix property.

webapp/src/websocket-client/WebsocketClient.ts (1)

7-15: Fix type inconsistency: jwtToken is required but treated as optional in code.

The type definition declares jwtToken as a required string (line 7), but line 99 checks if (options.authentication.jwtToken), treating it as optional. This inconsistency should be resolved by either:

  • Making jwtToken optional in the type: jwtToken?: string;, or
  • Removing the truthiness check and assuming the token is always provided

Additionally, consider whether this field should accept PAK/PAT tokens. If the backend WebSocket authentication handles multiple token types, renaming to a generic token field or updating the field documentation would improve clarity.

backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt (1)

198-204: LGTM: Clear authentication wrapper with proper validation.

The Auth data class properly enforces the constraint that exactly one authentication method must be provided, preventing misconfiguration in tests.

backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (2)

67-97: Consider logging the caught exception for debugging.

The API key permission logic correctly validates project match and scope. However, the exception handling at lines 92-96 swallows the original exception, which could make debugging permission issues difficult.

While returning a generic "Forbidden" message to websocket clients is appropriate for security, consider logging the original exception for operators:

     val user = authentication.principal
     try {
       securityService.checkProjectPermissionNoApiKey(projectId = projectId, Scope.KEYS_VIEW, user)
     } catch (e: Exception) {
+      logger.debug("Project permission check failed for user ${user.id} on project $projectId", e)
       throw MessagingException("Forbidden")
     }

This addresses the static analysis warning about the swallowed exception while maintaining security.


99-123: Good security decision to block API keys from user topics.

The explicit rejection of API key authentication for user-specific topics (lines 114-117) is correct, as API keys are project-scoped and should not access user-specific resources like notifications.

Minor inconsistency: Line 110 throws "Forbidden" for null authentication, while line 78 (in checkProjectPathPermissionsAuth) throws "Unauthenticated". Consider using "Unauthenticated" here as well for consistency:

     if (authentication == null) {
-      throw MessagingException("Forbidden")
+      throw MessagingException("Unauthenticated")
     }
backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt (3)

34-40: Good refactoring: centralized test setup and proper cleanup.

The new setup/teardown methods eliminate duplication and ensure proper cleanup of both test data and forced dates after each test.

Also applies to: 114-118


96-112: Good test coverage for HTTP conditional requests.

The new tests properly verify Last-Modified header generation and 304 Not Modified responses. Using forced dates ensures deterministic test behavior.


120-139: Clean test helpers for conditional request testing.

The helper methods properly handle HTTP date header formatting and comparison at second-level precision, which is appropriate since HTTP date headers don't include milliseconds.

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt (1)

165-177: Conditional request handling is properly implemented.

Verification confirms ProjectLastModifiedManager.onlyWhenProjectDataChanged correctly:

  • Sets Last-Modified header via .lastModified(lastModified) on 200 responses
  • Returns null (HTTP 304 Not Modified) when request.checkNotModified() is true
  • Handles missing modification timestamps by caching the current time via ProjectTranslationLastModifiedManager.getLastModified()
  • Sets Cache-Control header to max-age=0 for request validation

The refactoring safely centralizes conditional request logic across controllers without losing functionality.

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (2)

76-93: Good: conditional export wrapped via ProjectLastModifiedManager.

The flow and header propagation look correct.

Please confirm all external callers/tests are updated for the now-nullable return type.


53-72: Docstrings: solid.

Nice API docs on conditional requests (304/412 + Last-Modified/Cache-Control).

Also applies to: 95-120

@JanCizmar JanCizmar force-pushed the jancizmar/ws-with-api-key branch from 3656b23 to f441448 Compare December 12, 2025 10:27
@JanCizmar JanCizmar marked this pull request as ready for review December 12, 2025 12:41
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (1)

67-101: Don’t swallow the underlying permission failure; attach the cause.
Detekt is right: rethrowing without cause loses diagnostics.

     try {
       securityService.checkProjectPermissionNoApiKey(projectId = projectId, Scope.KEYS_VIEW, user)
     } catch (e: Exception) {
-      throw MessagingException("Forbidden")
+      throw MessagingException("Forbidden", e)
     }
🧹 Nitpick comments (3)
backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (1)

96-131: (Optional) Stepdown Rule: exportPost calls exportData but appears below it
Consider moving exportPost above exportData (or extracting shared logic into a private helper below both) to follow the repo’s Stepdown guidance.

backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (2)

81-93: Align unauthenticated/forbidden semantics (project vs user).
Project topics throw "Unauthenticated" when authentication == null, while user topics throw "Forbidden". If this is intentional (avoid user-topic enumeration), consider applying the same policy consistently (or add a short comment why they differ).

Also applies to: 117-125


127-130: Redundant cast: authentication is already TolgeeAuthentication?.
This can be simplified without changing behavior.

-    val user = (authentication as? TolgeeAuthentication)?.principal
+    val user = authentication.principal
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3656b23 and 4270697.

📒 Files selected for processing (11)
  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (3 hunks)
  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt (3 hunks)
  • backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt (1 hunks)
  • backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt (4 hunks)
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (6 hunks)
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt (2 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt (5 hunks)
  • backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt (1 hunks)
  • backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt
  • backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt
  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt
  • backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt
  • backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt
🧰 Additional context used
📓 Path-based instructions (3)
backend/**/*.kt

📄 CodeRabbit inference engine (AGENTS.md)

backend/**/*.kt: After modifying JPA entities, run ./gradlew diffChangeLog to generate Liquibase changelog entries (add --no-daemon flag if docker command not found)
Run ./gradlew ktlintFormat before committing code

Files:

  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt
  • backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt
**/*.{ts,tsx,js,jsx,kt,kts}

⚙️ CodeRabbit configuration file

As part of review, please check if the file follows 'The Stepdown Rule': The most important, high-level concepts should be at the top (like a newspaper headline and opening paragraph). Details should increase as you read downward. Functions should be ordered so that a caller appears before the functions it calls. When working with JavaScript components, we allow the main component to live at the bottom of the file as an exception to the rule.

Files:

  • backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt
  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
  • backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt
  • backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt
backend/**/*Test.kt

📄 CodeRabbit inference engine (AGENTS.md)

backend/**/*Test.kt: Use TestData classes for test setup with TestDataService for saving and cleaning test data in unit tests
Use .andAssertThatJson for testing API JSON responses in tests

Files:

  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
🧠 Learnings (2)
📚 Learning: 2025-11-28T15:12:44.060Z
Learnt from: CR
Repo: tolgee/tolgee-platform PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T15:12:44.060Z
Learning: Applies to backend/**/*Test.kt : Use `.andAssertThatJson` for testing API JSON responses in tests

Applied to files:

  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
📚 Learning: 2025-11-28T15:12:44.060Z
Learnt from: CR
Repo: tolgee/tolgee-platform PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T15:12:44.060Z
Learning: Applies to backend/**/*Test.kt : Use TestData classes for test setup with TestDataService for saving and cleaning test data in unit tests

Applied to files:

  • backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt
🪛 detekt (1.23.8)
backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt

[warning] 98-98: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt

[warning] 65-65: String.format("attachment; filename="%s.zip"", projectHolder.project.name) uses implicitly default locale for string formatting.

(detekt.potential-bugs.ImplicitDefaultLocale)


[warning] 78-78: String.format("%s.json", key) uses implicitly default locale for string formatting.

(detekt.potential-bugs.ImplicitDefaultLocale)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Frontend static check 🪲
  • GitHub Check: Build backend 🏗️
🔇 Additional comments (4)
backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt (1)

1-1: Package + AssertJ import alignment looks good
Matches the v2 export test suite conventions; no behavior change here.

Also applies to: 10-10

backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt (1)

7-8: Conditional export gating looks consistent; please verify 304 behavior for this deprecated endpoint
Since doExportJsonZip now returns nullable ResponseEntity<StreamingResponseBody>?, double-check the manager produces the intended “Not Modified” response (status + headers) for /jsonZip.

Also applies to: 39-46, 52-88

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt (1)

5-6: Good adoption of ProjectLastModifiedManager; please verify POST conditional semantics (412) match docs
The wiring/header propagation looks right; just ensure the manager indeed returns 412 for POST when not modified (and 304 for GET), as documented in the OpenAPI description.

Also applies to: 45-52, 77-94

backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt (1)

24-29: Constructor injection change looks fine; verify @Lazy is still needed.
If @Lazy is only here to break a circular dependency, consider documenting the cycle (or removing @Lazy if it’s no longer required).

Comment on lines +63 to 66
headersBuilder.header(
"Content-Disposition",
String.format("attachment; filename=\"%s.zip\"", projectHolder.project.name),
).body(
streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream ->
val zipOutputStream = ZipOutputStream(out)
val translations =
translationService.getTranslations(
allLanguages.map { it.tag }.toSet(),
null,
projectHolder.project.id,
'.',
)
for ((key, value) in translations) {
zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
val byteArrayInputStream = ByteArrayInputStream(data)
IOUtils.copy(byteArrayInputStream, zipOutputStream)
byteArrayInputStream.close()
zipOutputStream.closeEntry()
}
zipOutputStream.close()
},
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid String.format(...) default-locale + avoid per-entry ObjectMapper creation
Use string templates (or Locale.ROOT) and instantiate the ObjectMapper once.

@@
-      headersBuilder.header(
-        "Content-Disposition",
-        String.format("attachment; filename=\"%s.zip\"", projectHolder.project.name),
-      )
+      headersBuilder.header("Content-Disposition", "attachment; filename=\"${projectHolder.project.name}.zip\"")
@@
-        for ((key, value) in translations) {
-          zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key)))
-          val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value)
+        val mapper = jacksonObjectMapper().writerWithDefaultPrettyPrinter()
+        for ((key, value) in translations) {
+          zipOutputStream.putNextEntry(ZipEntry("$key.json"))
+          val data = mapper.writeValueAsBytes(value)
           val byteArrayInputStream = ByteArrayInputStream(data)
           IOUtils.copy(byteArrayInputStream, zipOutputStream)
           byteArrayInputStream.close()
           zipOutputStream.closeEntry()
         }

Also applies to: 78-79

🧰 Tools
🪛 detekt (1.23.8)

[warning] 65-65: String.format("attachment; filename="%s.zip"", projectHolder.project.name) uses implicitly default locale for string formatting.

(detekt.potential-bugs.ImplicitDefaultLocale)

🤖 Prompt for AI Agents
In backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt around
lines 63-66 (and similarly lines 78-79), replace the use of String.format(...)
for the Content-Disposition filename with a Kotlin string template (or
explicitly use Locale.ROOT if formatting is required) to avoid default-locale
dependence, and remove any per-entry ObjectMapper instantiation by creating a
single ObjectMapper instance (e.g., as a private val on the class or injected
bean) and reuse it for all serialization calls.

Comment on lines 47 to 55
if (accessor?.command == StompCommand.CONNECT) {
val tokenString = accessor.getNativeHeader("jwtToken")?.firstOrNull()
accessor.user = if (tokenString == null) null else jwtService.validateToken(tokenString)
val authorization = accessor.getNativeHeader("authorization")?.firstOrNull()
val xApiKey = accessor.getNativeHeader("x-api-key")?.firstOrNull()
val legacyJwt = accessor.getNativeHeader("jwtToken")?.firstOrNull()
accessor.user = websocketAuthenticationResolver.resolve(authorization, xApiKey, legacyJwt)
}

val user = (accessor?.user as? TolgeeAuthentication)?.principal
val authentication = accessor?.user as? TolgeeAuthentication

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential client break: header name casing/spelling might not match (Authorization vs authorization).
getNativeHeader("authorization") won’t see Authorization if native headers are case-sensitive in your setup; consider accepting both (and using getFirstNativeHeader).

-            val authorization = accessor.getNativeHeader("authorization")?.firstOrNull()
-            val xApiKey = accessor.getNativeHeader("x-api-key")?.firstOrNull()
-            val legacyJwt = accessor.getNativeHeader("jwtToken")?.firstOrNull()
+            val authorization =
+              accessor.getFirstNativeHeader("authorization")
+                ?: accessor.getFirstNativeHeader("Authorization")
+            val xApiKey =
+              accessor.getFirstNativeHeader("x-api-key")
+                ?: accessor.getFirstNativeHeader("X-API-KEY")
+                ?: accessor.getFirstNativeHeader("X-Api-Key")
+            val legacyJwt = accessor.getFirstNativeHeader("jwtToken")
             accessor.user = websocketAuthenticationResolver.resolve(authorization, xApiKey, legacyJwt)

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt around
lines 47 to 55, the code only reads the lowercase "authorization" header which
can miss "Authorization" or other casing; update the header retrieval to be
case-insensitive and use getFirstNativeHeader (or try both "authorization" and
"Authorization") for authorization, and similarly ensure "x-api-key" is fetched
with getFirstNativeHeader (and check "X-Api-Key" as a fallback) so you reliably
obtain the header values before calling websocketAuthenticationResolver.resolve;
keep the same variable names and fallbacks so behavior doesn’t change otherwise.

Comment on lines +90 to +92
val responseEntity = getExportResponse(params, exported)
headersBuilder.headers(responseEntity.headers)
responseEntity.body
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's somewhat messy that we create a response entity just to extract headers and body from it here. It ignores the status code, for example, which could be confusing.

Comment on lines +38 to +39
* This mechanism helps optimize performance by preventing export data computation and loading from the database when
* not modified.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to including a bit more comments, but the last paragraph feels like complete filler.


else ->
runCatching { pakAuth(xApiKey) }
.onFailure { logger.debug("PAT authentication failed", it) }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.onFailure { logger.debug("PAT authentication failed", it) }
.onFailure { logger.debug("PAK authentication failed", it) }

val hash = apiKeyService.hashKey(parsed)
val pak = apiKeyService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PROJECT_API_KEY)

if (pak.expiresAt?.before(java.util.Date()) == true) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this use CurrentDateProvider?

val hash = patService.hashToken(key.substring(PAT_PREFIX.length))
val pat = patService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PAT)

if (pat.expiresAt?.before(java.util.Date()) == true) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this use CurrentDateProvider?

Comment on lines +31 to +35
fun resolve(
authorizationHeader: String?,
xApiKeyHeader: String?,
legacyJwtHeader: String?,
): TolgeeAuthentication? {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a bad idea to pass StompHeaderAccessor instead and extract the headers here? I would do it this way, since this function is specific to WebSocket, and it would bring the header names here.

if (!matchesProject || !hasScope) {
throw MessagingException("Forbidden")
}
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we still verify users' permissions? I believe these API key scopes are not filtered based on what the user has access to. If that's the case, when user scopes change, the user might no longer have enough scopes to view the project, even though the API key says otherwise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants