Skip to content

feat: add custom HTTP metadata support for content delivery configs#3507

Open
dievri wants to merge 1 commit intotolgee:mainfrom
dievri:feat/content-delivery-custom-metadata
Open

feat: add custom HTTP metadata support for content delivery configs#3507
dievri wants to merge 1 commit intotolgee:mainfrom
dievri:feat/content-delivery-custom-metadata

Conversation

@dievri
Copy link
Copy Markdown

@dievri dievri commented Mar 8, 2026

Related: #2504, #3170

What this does

Adds support for configuring custom HTTP metadata (e.g. Cache-Control, Content-Type) per content delivery config. The metadata is set on every S3/GCS PutObject call when publishing translation files.

Motivation

Two practical problems this solves:

  1. CDN caching — there is currently no way to set Cache-Control on published objects, so CDNs cannot be instructed how to cache them per delivery config
  2. CDN compression — translation files are uploaded as application/octet-stream by default, so CDNs (e.g. Google Cloud CDN) skip gzip/brotli compression. Setting Content-Type: application/json via this feature fixes that

Changes

Backend

  • New customMetadata jsonb column on content_delivery_config (Liquibase migration included)
  • FileStorage.storeFile() interface extended with optional metadata parameter
  • S3FileStorage: well-known HTTP headers (Cache-Control, Content-Type, Content-Disposition, Content-Encoding, Content-Language) are routed to their proper PutObjectRequest builder fields — so they appear as actual HTTP response headers, not as x-amz-meta-*

Frontend

  • Key-value metadata editor in the Edit Content Delivery dialog

Test plan

  • Add Cache-Control: public, max-age=300 to a content delivery config, publish, verify the header appears on the object (not as x-amz-meta-cache-control)
  • Add Content-Type: application/json, verify CDN serves it with correct content type and compresses it
  • Verify Liquibase migration runs cleanly from a previous version

Summary by CodeRabbit

  • New Features
    • Added custom metadata configuration for content delivery files, allowing users to set object metadata (e.g., Cache-Control headers) on uploaded storage objects for S3-compatible storages.
    • Added UI controls for managing custom metadata key-value pairs in the content delivery configuration dialog.

Allows configuring custom metadata (e.g. Cache-Control, Content-Type)
per content delivery config, stored as jsonb. On S3/MinIO/GCS, well-known
HTTP headers (Cache-Control, Content-Type, Content-Disposition,
Content-Encoding, Content-Language) are mapped to proper PutObject fields
so they appear as actual HTTP response headers; other keys go to x-amz-meta-*.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 8, 2026

📝 Walkthrough

Walkthrough

This pull request adds custom metadata support for content delivery storage. The feature introduces an optional customMetadata field across the data model, database schema, service layer, file storage implementations, and frontend UI. S3 storage includes special handling to map metadata to standard headers and custom metadata fields.

Changes

Cohort / File(s) Summary
Database & Data Model
backend/data/src/main/resources/db/changelog/schema.xml, backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt
Added custom_metadata JSONB column to database schema and corresponding optional property to ContentDeliveryConfig entity with activity logging.
Request & Response DTOs
backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt, backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt
Added optional customMetadata field to both request and response models with schema descriptions for S3-compatible storage metadata.
Service Layer
backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModelAssembler.kt, backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt
Updated assembler to populate customMetadata in responses and service to propagate metadata from DTO to entity in create/update flows.
File Storage Interface & Base Implementations
backend/data/src/main/kotlin/io/tolgee/component/fileStorage/FileStorage.kt, backend/data/src/main/kotlin/io/tolgee/component/fileStorage/LocalFileStorage.kt, backend/data/src/main/kotlin/io/tolgee/component/fileStorage/AzureBlobFileStorage.kt, backend/development/src/main/kotlin/io/tolgee/util/InMemoryFileStorage.kt
Added optional metadata parameter to storeFile signature across all implementations (parameter accepted but unused in non-S3 storages).
S3 Storage Implementation
backend/data/src/main/kotlin/io/tolgee/component/fileStorage/S3FileStorage.kt
Extended storeFile to accept metadata and implemented applyMetadata helper that maps standard headers (cache-control, content-type, content-disposition, etc.) to PutObjectRequest properties and attaches remaining keys as custom metadata.
Content Delivery Upload Flow
backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt, backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryFileStorageProvider.kt
Updated storeToStorage calls to include metadata parameter from config and updated provider's anonymous FileStorage instances to accept metadata parameter.
Frontend API Schema
webapp/src/service/apiSchema.generated.ts
Added optional customMetadata field to ContentDeliveryConfigModel and ContentDeliveryConfigRequest types with descriptions for S3-compatible storages.
Frontend UI Component
webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx
New component implementing dynamic key-value form fields using Formik FieldArray with add and remove buttons for managing custom metadata entries.
Frontend Form Integration
webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx, webapp/src/views/projects/developer/contentDelivery/getCdEditInitialValues.ts, webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx
Integrated CdCustomMetadata component into dialog, updated initial values to convert metadata entries to key-value array format, and modified request body construction to serialize metadata back to object format.
Frontend Translations
webapp/src/i18n/en.json
Added four new i18n keys for custom metadata UI labels, key/value field names, and add button text.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A hop through metadata we go,
S3 headers in a proper flow,
Key-value pairs dance on the form,
Cache Control now wears custom form,
From database to storage, metadata's shown!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 'feat: add custom HTTP metadata support for content delivery configs' accurately summarizes the main change: introducing custom metadata configuration for content delivery.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
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: 8

🧹 Nitpick comments (3)
webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx (1)

39-39: Use a tg.views alias for the new import.

This new relative import goes against the webapp import convention.

♻️ Suggested change
-import { CdCustomMetadata } from './CdCustomMetadata';
+import { CdCustomMetadata } from 'tg.views/projects/developer/contentDelivery/CdCustomMetadata';

As per coding guidelines, "Use Tolgee custom TypeScript path aliases (tg.component, tg.service, tg.hooks, tg.views, tg.globalContext) instead of relative imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx` at line 39,
The import of CdCustomMetadata in CdDialog.tsx uses a relative path; update the
import to use the Tolgee views alias instead (use tg.views for CdCustomMetadata)
so it follows the project's TypeScript path-alias convention; locate the import
statement that references CdCustomMetadata and replace the relative import with
the equivalent tg.views alias import.
webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx (2)

2-2: Use a tg.views alias here.

Please avoid the relative import for getCdEditInitialValues and switch it to the Tolgee path alias so this new file stays consistent with the rest of the webapp import convention.

As per coding guidelines, webapp/**/*.{ts,tsx}: Use Tolgee custom TypeScript path aliases (tg.component, tg.service, tg.hooks, tg.views, tg.globalContext) instead of relative imports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx` at
line 2, The import uses a relative path for CdValues; replace the relative
import of getCdEditInitialValues with the Tolgee path alias (tg.views) so
CdValues is imported from "tg.views/getCdEditInitialValues" (or the project's
equivalent tg.views module path) to conform with the webapp alias convention and
keep CdCustomMetadata.tsx consistent with other imports.

27-51: Add stable data-cy hooks to these new controls.

The new inputs and add/remove actions currently expose no explicit E2E selectors, so tests will have to bind to translated labels or DOM structure.

As per coding guidelines, **/*.{tsx,ts}: STRICTLY use data-cy attributes for E2E selectors, never rely on text content; use typed helpers gcy() or cy.gcy().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx`
around lines 27 - 51, Add stable data-cy attributes to the new custom metadata
controls so E2E tests use selectors instead of labels or structure: add a
data-cy to the key TextField (referenced as the TextField with name
`customMetadata.${index}.key`), a data-cy to the value TextField (name
`customMetadata.${index}.value`), a data-cy to the remove IconButton (the
IconButton calling remove(index)), and a data-cy to the add Button (the Button
calling push({ key: '', value: '' })). Use consistent, descriptive names (e.g.
content-delivery-custom-metadata-key-<index>, -value-<index>, -remove-<index>,
and -add) so tests can use gcy()/cy.gcy() to select them reliably.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/data/src/main/kotlin/io/tolgee/component/fileStorage/FileStorage.kt`:
- Around line 10-14: The FileStorage.storeFile signature currently accepts
metadata but some implementations (e.g., LocalFileStorage, AzureFileStorage)
silently ignore it; update the contract so callers get immediate feedback: add a
capability indicator (e.g., supportsMetadata(): Boolean) to the FileStorage
interface or change storeFile to throw when metadata is non-null for backends
that don't support it, then update implementations (LocalFileStorage,
AzureFileStorage) to either return false from supportsMetadata() or to throw
UnsupportedOperationException when metadata is passed; adjust any callers to
check supportsMetadata() before passing metadata or to handle the exception
accordingly.

In
`@backend/data/src/main/kotlin/io/tolgee/component/fileStorage/S3FileStorage.kt`:
- Around line 65-72: The metadata key handling in S3FileStorage.kt's loop (for
((key, value) in metadata)) only lowercases keys but doesn't trim whitespace, so
keys like "Content-Type " are misclassified; update the loop to first trim the
key (e.g., val trimmed = key.trim()) and use trimmed.lowercase() in the when
matcher and use the trimmed key when adding to customMetadata (so
customMetadata[trimmed] = value) to ensure header mapping works for keys with
extra whitespace.

In
`@backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt`:
- Around line 53-58: The customMetadata map on ContentDeliveryConfigRequest
currently accepts arbitrary strings; add validation/normalization at the API
boundary by enforcing HTTP header token rules and disallowing surrounding
whitespace and CR/LF characters. Implement this by adding a validation step
(either a custom Jakarta Bean Validation constraint or a
validateCustomMetadata() method called from the controller/service) on
ContentDeliveryConfigRequest::customMetadata that: trims keys and values,
rejects empty keys/values, ensures keys match the HTTP token pattern (e.g.,
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/) and values do not contain CR/LF or control
characters, and throw a BadRequestException (or ConstraintViolation) with a
clear message when validation fails; apply the same normalization/validation
path in the publishing/service logic that persists or forwards customMetadata to
storage to avoid bypassing the check.

In
`@backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt`:
- Around line 147-150: The customMetadata property on ContentDeliveryConfig is
free-form and currently annotated with `@ActivityLoggedProp` which will persist
arbitrary values into audits; remove the `@ActivityLoggedProp` annotation from the
var customMetadata: Map<String, String>? declaration (or replace it with a
specific sanitizer wrapper) so arbitrary metadata values are not written to
activity logs, and if you still need visibility implement explicit sanitized
logging elsewhere (e.g., a method on ContentDeliveryConfig that returns a
redacted map of allowed keys) rather than annotating customMetadata directly.

In `@webapp/src/i18n/en.json`:
- Around line 780-783: Add the missing activity label for the
ContentDeliveryConfig.customMetadata field by adding the key
activity_entity_content_delivery_config.customMetadata to the en.json locale
with an appropriate human-readable value (e.g., "Custom metadata") so activity
logs use the translated label; locate the JSON entries near other
content_delivery_* keys (see ContentDeliveryConfig.customMetadata and
activity_entity_content_delivery_config.customMetadata) and insert the new
key/value following the project's localization style.

In `@webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx`:
- Around line 25-50: The custom metadata list allows duplicate keys which are
later collapsed by Object.fromEntries in useCdActions.tsx; add a validation step
in the CdCustomMetadata component (or the form submit handler that calls the
serializer) that checks values.customMetadata for duplicate keys by comparing
each entry's key.trim().toLowerCase(), and if any duplicates are found prevent
submit and surface a validation error on the offending rows (or a general form
error) so users must remove/rename duplicates before push/remove updates are
accepted; update the UI to show the error near the TextField inputs (the ones
using name={`customMetadata.${index}.key`}) so duplicates are fixed prior to
serialization.

In `@webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx`:
- Around line 212-214: The submit path in useCdActions (the function that builds
payload via Object.fromEntries) currently collapses duplicate customMetadata
keys; add a pre-submit validation that inspects the CdCustomMetadata array
(customMetadata[].key) for duplicates, and if any duplicate keys are found,
prevent submission and surface a validation error to the user (e.g. set
form/dialog error or reject the submit promise) indicating which key(s) are
duplicated so the user can fix them; implement the duplicate check inside the
submit handler in useCdActions before Object.fromEntries is called and ensure
the error blocks the existing conversion and network call.

In `@webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx`:
- Around line 60-67: The customMetadata payload builds entries from
values.customMetadata but uses untrimmed keys and can produce {} when all rows
are blank; update the logic that constructs customMetadata so it first filters
entries where e.key.trim() !== '' then maps to [e.key.trim(), e.value] (use the
trimmed key) and after Object.fromEntries check if the resulting object has any
keys—if not, set customMetadata to undefined; adjust code around
values.customMetadata and the customMetadata property to implement this change.

---

Nitpick comments:
In `@webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx`:
- Line 2: The import uses a relative path for CdValues; replace the relative
import of getCdEditInitialValues with the Tolgee path alias (tg.views) so
CdValues is imported from "tg.views/getCdEditInitialValues" (or the project's
equivalent tg.views module path) to conform with the webapp alias convention and
keep CdCustomMetadata.tsx consistent with other imports.
- Around line 27-51: Add stable data-cy attributes to the new custom metadata
controls so E2E tests use selectors instead of labels or structure: add a
data-cy to the key TextField (referenced as the TextField with name
`customMetadata.${index}.key`), a data-cy to the value TextField (name
`customMetadata.${index}.value`), a data-cy to the remove IconButton (the
IconButton calling remove(index)), and a data-cy to the add Button (the Button
calling push({ key: '', value: '' })). Use consistent, descriptive names (e.g.
content-delivery-custom-metadata-key-<index>, -value-<index>, -remove-<index>,
and -add) so tests can use gcy()/cy.gcy() to select them reliably.

In `@webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx`:
- Line 39: The import of CdCustomMetadata in CdDialog.tsx uses a relative path;
update the import to use the Tolgee views alias instead (use tg.views for
CdCustomMetadata) so it follows the project's TypeScript path-alias convention;
locate the import statement that references CdCustomMetadata and replace the
relative import with the equivalent tg.views alias import.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 507c8f17-4cb8-4108-b31d-ea6411757053

📥 Commits

Reviewing files that changed from the base of the PR and between d15161f and 37a4dfc.

📒 Files selected for processing (19)
  • backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModel.kt
  • backend/api/src/main/kotlin/io/tolgee/hateoas/contentDelivery/ContentDeliveryConfigModelAssembler.kt
  • backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryFileStorageProvider.kt
  • backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/ContentDeliveryUploader.kt
  • backend/data/src/main/kotlin/io/tolgee/component/fileStorage/AzureBlobFileStorage.kt
  • backend/data/src/main/kotlin/io/tolgee/component/fileStorage/FileStorage.kt
  • backend/data/src/main/kotlin/io/tolgee/component/fileStorage/LocalFileStorage.kt
  • backend/data/src/main/kotlin/io/tolgee/component/fileStorage/S3FileStorage.kt
  • backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt
  • backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt
  • backend/data/src/main/kotlin/io/tolgee/service/contentDelivery/ContentDeliveryConfigService.kt
  • backend/data/src/main/resources/db/changelog/schema.xml
  • backend/development/src/main/kotlin/io/tolgee/util/InMemoryFileStorage.kt
  • webapp/src/i18n/en.json
  • webapp/src/service/apiSchema.generated.ts
  • webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx
  • webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx
  • webapp/src/views/projects/developer/contentDelivery/getCdEditInitialValues.ts
  • webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx

Comment on lines 10 to 14
fun storeFile(
storageFilePath: String,
bytes: ByteArray,
metadata: Map<String, String>? = null,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Define behavior for backends that can't honor metadata.

The new contract makes metadata look universally supported, but the current Local/Azure implementations accept it and drop it. That means a config can save Cache-Control successfully and still publish objects without headers, with no feedback to the caller. Please make unsupported implementations fail fast or expose capability explicitly so callers can reject those configs up front.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/data/src/main/kotlin/io/tolgee/component/fileStorage/FileStorage.kt`
around lines 10 - 14, The FileStorage.storeFile signature currently accepts
metadata but some implementations (e.g., LocalFileStorage, AzureFileStorage)
silently ignore it; update the contract so callers get immediate feedback: add a
capability indicator (e.g., supportsMetadata(): Boolean) to the FileStorage
interface or change storeFile to throw when metadata is non-null for backends
that don't support it, then update implementations (LocalFileStorage,
AzureFileStorage) to either return false from supportsMetadata() or to throw
UnsupportedOperationException when metadata is passed; adjust any callers to
check supportsMetadata() before passing metadata or to handle the exception
accordingly.

Comment on lines +65 to +72
for ((key, value) in metadata) {
when (key.lowercase()) {
"cache-control" -> b.cacheControl(value)
"content-type" -> b.contentType(value)
"content-disposition" -> b.contentDisposition(value)
"content-encoding" -> b.contentEncoding(value)
"content-language" -> b.contentLanguage(value)
else -> customMetadata[key] = value
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Trim metadata keys before header mapping.

The frontend currently forwards the raw key text, and this matcher only lowercases it. A user-entered "Content-Type " or " Cache-Control" will miss the standard-header branch and be uploaded as custom metadata instead of an HTTP response header, which defeats the main feature.

Proposed fix
-    for ((key, value) in metadata) {
-      when (key.lowercase()) {
+    for ((rawKey, value) in metadata) {
+      val key = rawKey.trim()
+      if (key.isEmpty()) {
+        continue
+      }
+      when (key.lowercase()) {
         "cache-control" -> b.cacheControl(value)
         "content-type" -> b.contentType(value)
         "content-disposition" -> b.contentDisposition(value)
         "content-encoding" -> b.contentEncoding(value)
         "content-language" -> b.contentLanguage(value)
         else -> customMetadata[key] = value
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for ((key, value) in metadata) {
when (key.lowercase()) {
"cache-control" -> b.cacheControl(value)
"content-type" -> b.contentType(value)
"content-disposition" -> b.contentDisposition(value)
"content-encoding" -> b.contentEncoding(value)
"content-language" -> b.contentLanguage(value)
else -> customMetadata[key] = value
for ((rawKey, value) in metadata) {
val key = rawKey.trim()
if (key.isEmpty()) {
continue
}
when (key.lowercase()) {
"cache-control" -> b.cacheControl(value)
"content-type" -> b.contentType(value)
"content-disposition" -> b.contentDisposition(value)
"content-encoding" -> b.contentEncoding(value)
"content-language" -> b.contentLanguage(value)
else -> customMetadata[key] = value
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/data/src/main/kotlin/io/tolgee/component/fileStorage/S3FileStorage.kt`
around lines 65 - 72, The metadata key handling in S3FileStorage.kt's loop (for
((key, value) in metadata)) only lowercases keys but doesn't trim whitespace, so
keys like "Content-Type " are misclassified; update the loop to first trim the
key (e.g., val trimmed = key.trim()) and use trimmed.lowercase() in the when
matcher and use the trimmed key when adding to customMetadata (so
customMetadata[trimmed] = value) to ensure header mapping works for keys with
extra whitespace.

Comment on lines +53 to +58
@Schema(
description =
"Custom metadata to set on uploaded storage objects (e.g. {\"Cache-Control\": \"max-age=3600\"}). " +
"Currently supported for S3-compatible storages only.",
)
var customMetadata: Map<String, String>? = null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate metadata keys and values at the API boundary.

This accepts arbitrary header names and values today. Invalid tokens, surrounding whitespace, or CR/LF characters won't be caught until publish time, and some of these values can become real response headers on the CDN. Please add backend validation/normalization here (or in the service) so bad metadata is rejected before it is stored.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/data/src/main/kotlin/io/tolgee/dtos/request/ContentDeliveryConfigRequest.kt`
around lines 53 - 58, The customMetadata map on ContentDeliveryConfigRequest
currently accepts arbitrary strings; add validation/normalization at the API
boundary by enforcing HTTP header token rules and disallowing surrounding
whitespace and CR/LF characters. Implement this by adding a validation step
(either a custom Jakarta Bean Validation constraint or a
validateCustomMetadata() method called from the controller/service) on
ContentDeliveryConfigRequest::customMetadata that: trims keys and values,
rejects empty keys/values, ensures keys match the HTTP token pattern (e.g.,
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/) and values do not contain CR/LF or control
characters, and throw a BadRequestException (or ConstraintViolation) with a
clear message when validation fails; apply the same normalization/validation
path in the publishing/service logic that persists or forwards customMetadata to
storage to avoid bypassing the check.

Comment on lines +147 to +150
@Type(JsonBinaryType::class)
@Column(columnDefinition = "jsonb")
@ActivityLoggedProp
var customMetadata: Map<String, String>? = null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't activity-log arbitrary metadata values.

customMetadata is a free-form map, so @ActivityLoggedProp will persist every value into the audit trail. That can leak sensitive data if someone stores headers like Authorization, signed cookies, or vendor-specific tokens here. Please either ignore this field in activity logs or log only sanitized keys.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt`
around lines 147 - 150, The customMetadata property on ContentDeliveryConfig is
free-form and currently annotated with `@ActivityLoggedProp` which will persist
arbitrary values into audits; remove the `@ActivityLoggedProp` annotation from the
var customMetadata: Map<String, String>? declaration (or replace it with a
specific sanitizer wrapper) so arbitrary metadata values are not written to
activity logs, and if you still need visibility implement explicit sanitized
logging elsewhere (e.g., a method on ContentDeliveryConfig that returns a
redacted map of allowed keys) rather than annotating customMetadata directly.

Comment on lines +780 to +783
"content_delivery_custom_metadata_label": "Custom object metadata",
"content_delivery_custom_metadata_key": "Key",
"content_delivery_custom_metadata_value": "Value",
"content_delivery_custom_metadata_add": "Add metadata entry",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add the activity label for customMetadata.

ContentDeliveryConfig.customMetadata is activity-logged in backend/model/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryConfig.kt:147-150, but this locale file still lacks activity_entity_content_delivery_config.customMetadata. Without it, update history for this field will fall back to a raw/untranslated property name.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/i18n/en.json` around lines 780 - 783, Add the missing activity
label for the ContentDeliveryConfig.customMetadata field by adding the key
activity_entity_content_delivery_config.customMetadata to the en.json locale
with an appropriate human-readable value (e.g., "Custom metadata") so activity
logs use the translated label; locate the JSON entries near other
content_delivery_* keys (see ContentDeliveryConfig.customMetadata and
activity_entity_content_delivery_config.customMetadata) and insert the new
key/value following the project's localization style.

Comment on lines +25 to +50
{values.customMetadata.map((_, index) => (
<StyledRow key={index}>
<TextField
size="small"
name={`customMetadata.${index}.key`}
label={<T keyName="content_delivery_custom_metadata_key" />}
minHeight={false}
/>
<TextField
size="small"
name={`customMetadata.${index}.value`}
label={<T keyName="content_delivery_custom_metadata_value" />}
minHeight={false}
/>
<IconButton size="small" onClick={() => remove(index)}>
<Trash01 width={16} height={16} />
</IconButton>
</StyledRow>
))}
<Box>
<Button
size="small"
startIcon={<Plus width={16} height={16} />}
onClick={() => push({ key: '', value: '' })}
>
<T keyName="content_delivery_custom_metadata_add" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate duplicate metadata keys before submit.

webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx:56-60 serializes this array with Object.fromEntries(...), so two rows with the same header name are silently collapsed and only the last value is persisted. Please enforce uniqueness here (ideally case-insensitive after trim), otherwise the saved config can differ from what the dialog shows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/CdCustomMetadata.tsx`
around lines 25 - 50, The custom metadata list allows duplicate keys which are
later collapsed by Object.fromEntries in useCdActions.tsx; add a validation step
in the CdCustomMetadata component (or the form submit handler that calls the
serializer) that checks values.customMetadata for duplicate keys by comparing
each entry's key.trim().toLowerCase(), and if any duplicates are found prevent
submit and surface a validation error on the offending rows (or a general form
error) so users must remove/rename duplicates before push/remove updates are
accepted; update the UI to show the error near the TextField inputs (the ones
using name={`customMetadata.${index}.key`}) so duplicates are fixed prior to
serialization.

Comment on lines +212 to +214
<Box sx={{ gridColumn: '1 / span 2' }}>
<CdCustomMetadata />
</Box>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject duplicate metadata keys before submit.

The submit path in webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx:60-67 converts the entries with Object.fromEntries(...), so duplicate keys are silently collapsed and only the last value is sent. Now that this editor is exposed in the dialog, add uniqueness validation for customMetadata[].key to avoid silent data loss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/CdDialog.tsx` around
lines 212 - 214, The submit path in useCdActions (the function that builds
payload via Object.fromEntries) currently collapses duplicate customMetadata
keys; add a pre-submit validation that inspects the CdCustomMetadata array
(customMetadata[].key) for duplicates, and if any duplicate keys are found,
prevent submission and surface a validation error to the user (e.g. set
form/dialog error or reject the submit promise) indicating which key(s) are
duplicated so the user can fix them; implement the duplicate check inside the
submit handler in useCdActions before Object.fromEntries is called and ensure
the error blocks the existing conversion and network call.

Comment on lines +60 to +67
customMetadata:
values.customMetadata.length > 0
? Object.fromEntries(
values.customMetadata
.filter((e) => e.key.trim() !== '')
.map((e) => [e.key, e.value])
)
: undefined,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Normalize filtered keys before serializing metadata.

Blank rows are filtered with trim(), but the payload still uses the untrimmed key. A value like " Cache-Control " will miss the backend's special-header mapping, and rows that become empty after trimming currently serialize as {} instead of undefined.

♻️ Proposed fix
-      customMetadata:
-        values.customMetadata.length > 0
-          ? Object.fromEntries(
-              values.customMetadata
-                .filter((e) => e.key.trim() !== '')
-                .map((e) => [e.key, e.value])
-            )
-          : undefined,
+      customMetadata: (() => {
+        const entries = values.customMetadata
+          .map(({ key, value }) => [key.trim(), value] as const)
+          .filter(([key]) => key !== '');
+
+        return entries.length > 0 ? Object.fromEntries(entries) : undefined;
+      })(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/views/projects/developer/contentDelivery/useCdActions.tsx` around
lines 60 - 67, The customMetadata payload builds entries from
values.customMetadata but uses untrimmed keys and can produce {} when all rows
are blank; update the logic that constructs customMetadata so it first filters
entries where e.key.trim() !== '' then maps to [e.key.trim(), e.value] (use the
trimmed key) and after Object.fromEntries check if the resulting object has any
keys—if not, set customMetadata to undefined; adjust code around
values.customMetadata and the customMetadata property to implement this change.

Copy link
Copy Markdown
Contributor

@JanCizmar JanCizmar left a comment

Choose a reason for hiding this comment

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

Hello! Thanks a lot for the PR ❤️

Would you be open to add some tests (backend integration + E2E)?

val customMetadata = mutableMapOf<String, String>()
for ((key, value) in metadata) {
when (key.lowercase()) {
"cache-control" -> b.cacheControl(value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wonder, why we cannot treat all the metadata the same. Why do we need this special cases?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants