diff --git a/Caddyfile b/Caddyfile
index 4667cd8e51..373fcdc901 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -6,6 +6,29 @@
{$BASEROW_CADDY_GLOBAL_CONF}
}
+(baserow_media_files) {
+ handle_path /media/* {
+ @downloads {
+ query dl=*
+ }
+ header @downloads Content-Disposition "attachment; filename={query.dl}"
+ header X-Content-Type-Options "nosniff"
+ header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'"
+
+ # Allow to call head from public url to get size
+ header {
+ Access-Control-Allow-Origin {$BASEROW_PUBLIC_URL:http://localhost}
+ Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
+ Access-Control-Allow-Headers "*"
+ Access-Control-Expose-Headers "Content-Length, Content-Type"
+ }
+
+ file_server {
+ root {$MEDIA_ROOT:/baserow/media/}
+ }
+ }
+}
+
{$BASEROW_CADDY_ADDRESSES} {
tls {
on_demand
@@ -20,30 +43,27 @@
`
}
+ @is_baserow_media {
+ path /media/*
+ expression `
+ "{$MEDIA_URL:}".startsWith("http://" + {http.request.host} + "/") ||
+ "{$MEDIA_URL:}".startsWith("https://" + {http.request.host} + "/") ||
+ "{$MEDIA_URL:}".startsWith("http://" + {http.request.host} + ":") ||
+ "{$MEDIA_URL:}".startsWith("https://" + {http.request.host} + ":")
+ `
+ }
+
+ handle @is_baserow_media {
+ import baserow_media_files
+ }
+
handle @is_baserow_tool {
@backend_routes path /api/* /ws/* /mcp/* /assistant/* {$BASEROW_CADDY_BACKEND_EXTRA_ROUTES:}
handle @backend_routes {
reverse_proxy {$PRIVATE_BACKEND_URL:localhost:8000}
}
- handle_path /media/* {
- @downloads {
- query dl=*
- }
- header @downloads Content-disposition "attachment; filename={query.dl}"
-
- # Allow to call head from public url to get size
- header {
- Access-Control-Allow-Origin {$BASEROW_PUBLIC_URL:http://localhost}
- Access-Control-Allow-Methods "GET, HEAD, OPTIONS"
- Access-Control-Allow-Headers "*"
- Access-Control-Expose-Headers "Content-Length, Content-Type"
- }
-
- file_server {
- root {$MEDIA_ROOT:/baserow/media/}
- }
- }
+ import baserow_media_files
handle_path /static/* {
file_server {
diff --git a/Caddyfile.dev b/Caddyfile.dev
index a8d9ac84d8..3d342eebea 100644
--- a/Caddyfile.dev
+++ b/Caddyfile.dev
@@ -9,7 +9,9 @@
@downloads {
query dl=*
}
- header @downloads Content-disposition "attachment; filename={query.dl}"
+ header @downloads Content-Disposition "attachment; filename={query.dl}"
+ header X-Content-Type-Options "nosniff"
+ header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'"
header {
Access-Control-Allow-Origin {$PUBLIC_WEB_FRONTEND_URL:http://localhost:3000}
diff --git a/README.md b/README.md
index 8bb34b7957..c30b3f7024 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ existing tools and performs at any scale.
[](https://www.heroku.com/deploy/?template=https://github.com/baserow/baserow/tree/master)
```bash
-docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.1
+docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.2
```

@@ -108,7 +108,7 @@ Created by Baserow B.V. - bram@baserow.io.
Distributes under the MIT license. See `LICENSE` for more information.
-Version: 2.2.1
+Version: 2.2.2
The official repository can be found at https://github.com/baserow/baserow.
diff --git a/backend/docker/docker-entrypoint.sh b/backend/docker/docker-entrypoint.sh
index 6c8d6d780b..e359a4bf72 100755
--- a/backend/docker/docker-entrypoint.sh
+++ b/backend/docker/docker-entrypoint.sh
@@ -6,7 +6,7 @@ set -euo pipefail
# ENVIRONMENT VARIABLES USED DIRECTLY BY THIS ENTRYPOINT
# ======================================================
-export BASEROW_VERSION="2.2.1"
+export BASEROW_VERSION="2.2.2"
# Used by docker-entrypoint.sh to start the dev server
# If not configured you'll receive this: CommandError: "0.0.0.0:" is not a valid port number or address:port pair.
diff --git a/backend/src/baserow/api/user_files/views.py b/backend/src/baserow/api/user_files/views.py
index 46a5be76c8..c07d335f4d 100644
--- a/backend/src/baserow/api/user_files/views.py
+++ b/backend/src/baserow/api/user_files/views.py
@@ -10,6 +10,7 @@
from baserow.api.schemas import get_error_schema
from baserow.contrib.database.api.tokens.authentications import TokenAuthentication
from baserow.core.user_files.exceptions import (
+ ActiveContentBlockedUserFileError,
FileSizeTooLargeError,
FileURLCouldNotBeReached,
InvalidFileStreamError,
@@ -49,6 +50,7 @@ class UploadFileView(APIView):
{
InvalidFileStreamError: ERROR_INVALID_FILE,
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
+ ActiveContentBlockedUserFileError: ERROR_INVALID_FILE,
}
)
def post(self, request):
@@ -93,6 +95,7 @@ class UploadViaURLView(APIView):
FileSizeTooLargeError: ERROR_FILE_SIZE_TOO_LARGE,
FileURLCouldNotBeReached: ERROR_FILE_URL_COULD_NOT_BE_REACHED,
InvalidFileURLError: ERROR_INVALID_FILE_URL,
+ ActiveContentBlockedUserFileError: ERROR_INVALID_FILE,
}
)
@validate_body(UserFileUploadViaURLRequestSerializer)
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 9f04b9b4ab..64356a0632 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -494,7 +494,7 @@
"name": "MIT",
"url": "https://github.com/baserow/baserow/blob/develop/LICENSE",
},
- "VERSION": "2.2.1",
+ "VERSION": "2.2.2",
"SERVE_INCLUDE_SCHEMA": False,
"TAGS": [
{"name": "Settings"},
@@ -611,6 +611,15 @@
Decimal(os.getenv("BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB", 1024 * 1024)) * 1024 * 1024
) # ~1TB by default
+FILE_UPLOAD_ACTIVE_CONTENT_POLICY = os.getenv(
+ "BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY", "download"
+).lower()
+if FILE_UPLOAD_ACTIVE_CONTENT_POLICY not in ("download", "block"):
+ raise ImproperlyConfigured(
+ "BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY must be set to "
+ "'download' or 'block'."
+ )
+
BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB = int(
os.getenv("BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB", 512)
)
@@ -778,15 +787,22 @@ def __setitem__(self, key, value):
if not BASEROW_EMBEDDED_SHARE_URL:
BASEROW_EMBEDDED_SHARE_URL = PUBLIC_WEB_FRONTEND_URL
+MEDIA_URL_PATH = "/media/"
+MEDIA_URL = os.getenv("MEDIA_URL", urljoin(PUBLIC_BACKEND_URL, MEDIA_URL_PATH))
+
PRIVATE_BACKEND_URL = os.getenv("PRIVATE_BACKEND_URL", "http://backend:8000")
PUBLIC_BACKEND_HOSTNAME = urlparse(PUBLIC_BACKEND_URL).hostname
PUBLIC_WEB_FRONTEND_HOSTNAME = urlparse(PUBLIC_WEB_FRONTEND_URL).hostname
BASEROW_EMBEDDED_SHARE_HOSTNAME = urlparse(BASEROW_EMBEDDED_SHARE_URL).hostname
+MEDIA_URL_HOSTNAME = urlparse(MEDIA_URL).hostname
PRIVATE_BACKEND_HOSTNAME = urlparse(PRIVATE_BACKEND_URL).hostname
if PUBLIC_BACKEND_HOSTNAME:
ALLOWED_HOSTS.append(PUBLIC_BACKEND_HOSTNAME)
+if MEDIA_URL_HOSTNAME:
+ ALLOWED_HOSTS.append(MEDIA_URL_HOSTNAME)
+
if PRIVATE_BACKEND_HOSTNAME:
ALLOWED_HOSTS.append(PRIVATE_BACKEND_HOSTNAME)
@@ -951,8 +967,6 @@ def __setitem__(self, key, value):
os.getenv("BASEROW_INITIAL_CREATE_SYNC_TABLE_DATA_LIMIT", 5000)
)
-MEDIA_URL_PATH = "/media/"
-MEDIA_URL = os.getenv("MEDIA_URL", urljoin(PUBLIC_BACKEND_URL, MEDIA_URL_PATH))
MEDIA_ROOT = os.getenv("MEDIA_ROOT", "/baserow/media")
# Indicates the directory where the user files and user thumbnails are stored.
diff --git a/backend/src/baserow/contrib/builder/api/domains/views.py b/backend/src/baserow/contrib/builder/api/domains/views.py
index 8a0a0b79af..481c5f3a47 100644
--- a/backend/src/baserow/contrib/builder/api/domains/views.py
+++ b/backend/src/baserow/contrib/builder/api/domains/views.py
@@ -401,6 +401,8 @@ def get(self, request):
+ settings.EXTRA_PUBLIC_BACKEND_HOSTNAMES
+ settings.EXTRA_PUBLIC_WEB_FRONTEND_HOSTNAMES
)
+ if settings.MEDIA_URL_HOSTNAME:
+ allowed_domain.add(settings.MEDIA_URL_HOSTNAME)
if domain_name in allowed_domain:
return Response(None, status=200)
diff --git a/backend/src/baserow/core/user_files/exceptions.py b/backend/src/baserow/core/user_files/exceptions.py
index 3c0cd7d077..b08165ab9b 100644
--- a/backend/src/baserow/core/user_files/exceptions.py
+++ b/backend/src/baserow/core/user_files/exceptions.py
@@ -15,6 +15,10 @@ def __init__(self, max_size_bytes, *args, **kwargs):
super().__init__(*args, **kwargs)
+class ActiveContentBlockedUserFileError(Exception):
+ """Raised when the file upload active content policy blocks a file."""
+
+
class FileURLCouldNotBeReached(Exception):
"""
Raised when the provided URL could not be reached or points to an internal
diff --git a/backend/src/baserow/core/user_files/handler.py b/backend/src/baserow/core/user_files/handler.py
index e214db7481..20b1842983 100644
--- a/backend/src/baserow/core/user_files/handler.py
+++ b/backend/src/baserow/core/user_files/handler.py
@@ -31,6 +31,7 @@
from baserow.core.utils import random_string, sha256_hash, stream_size, truncate_middle
from .exceptions import (
+ ActiveContentBlockedUserFileError,
FileSizeTooLargeError,
FileURLCouldNotBeReached,
InvalidFileStreamError,
@@ -43,9 +44,66 @@
from PIL import Image
MIME_TYPE_UNKNOWN = "application/octet-stream"
+ACTIVE_CONTENT_EXTENSIONS = {"html", "htm", "xhtml", "xml", "svg", "svgz"}
+ACTIVE_CONTENT_MIME_TYPES = {
+ "application/xhtml+xml",
+ "application/xml",
+ "image/svg+xml",
+ "text/html",
+ "text/xml",
+}
class UserFileHandler:
+ def _is_active_content_extension(self, extension: str) -> bool:
+ return extension.lower() in ACTIVE_CONTENT_EXTENSIONS
+
+ def _is_active_content_mime_type(self, mime_type: str) -> bool:
+ return mime_type.lower() in ACTIVE_CONTENT_MIME_TYPES
+
+ def _resolve_mime_type_and_active_content(
+ self, file_name: str, extension: str, stream
+ ) -> tuple[str, bool]:
+ """
+ Resolves the MIME type for an uploaded file and decides whether it should be
+ treated as active content.
+
+ Both the filename-derived MIME type and the client-supplied `content_type` are
+ checked independently against the active-content blocklist so that a malicious
+ client cannot bypass the gate by pairing an innocuous extension with a dangerous
+ Content-Type header (or vice versa).
+
+ :param file_name: The name of the uploaded file.
+ :param extension: The file extension of the uploaded file.
+ :param stream: The file content stream of the uploaded file, which may have a
+ `content_type` attribute.
+ :return: A tuple of the resolved MIME type and whether the file is considered
+ active content.
+ """
+
+ guessed_mime_type = mimetypes.guess_type(file_name)[0]
+ uploaded_mime_type = getattr(stream, "content_type", None)
+ mime_type = guessed_mime_type or uploaded_mime_type or MIME_TYPE_UNKNOWN
+ is_active_content = (
+ self._is_active_content_extension(extension)
+ or (
+ guessed_mime_type is not None
+ and self._is_active_content_mime_type(guessed_mime_type)
+ )
+ or (
+ uploaded_mime_type is not None
+ and self._is_active_content_mime_type(uploaded_mime_type)
+ )
+ )
+ return mime_type, is_active_content
+
+ def _neutralize_active_content(self, user_file: UserFile) -> UserFile:
+ user_file.mime_type = MIME_TYPE_UNKNOWN
+ user_file.is_image = False
+ user_file.image_width = None
+ user_file.image_height = None
+ return user_file
+
def is_user_file_name(self, user_file_name: str) -> bool:
"""
Checks if the given name is a user file name.
@@ -266,6 +324,15 @@ def upload_user_file(self, user, file_name, stream, storage=None):
storage = storage or get_default_storage()
stream_hash = sha256_hash(stream)
file_name = truncate_middle(file_name, 64)
+ extension = pathlib.Path(file_name).suffix[1:].lower()
+ mime_type, is_active_content = self._resolve_mime_type_and_active_content(
+ file_name, extension, stream
+ )
+
+ if is_active_content and settings.FILE_UPLOAD_ACTIVE_CONTENT_POLICY == "block":
+ raise ActiveContentBlockedUserFileError(
+ "The provided file type is not allowed."
+ )
existing_user_file = UserFile.objects.filter(
original_name=file_name,
@@ -274,14 +341,18 @@ def upload_user_file(self, user, file_name, stream, storage=None):
).first()
if existing_user_file:
+ if is_active_content:
+ self._neutralize_active_content(existing_user_file)
+ existing_user_file.save(
+ update_fields=[
+ "mime_type",
+ "is_image",
+ "image_width",
+ "image_height",
+ ]
+ )
return existing_user_file
- extension = pathlib.Path(file_name).suffix[1:].lower()
- mime_type = (
- mimetypes.guess_type(file_name)[0]
- or getattr(stream, "content_type", None)
- or MIME_TYPE_UNKNOWN
- )
unique = self.generate_unique(stream_hash, extension)
user_file = UserFile(
original_name=file_name,
@@ -293,26 +364,30 @@ def upload_user_file(self, user, file_name, stream, storage=None):
sha256_hash=stream_hash,
)
- image = None
- try:
- image = Image.open(stream)
- user_file.mime_type = f"image/{image.format}".lower()
- self.generate_and_save_image_thumbnails(
- image, user_file.name, storage=storage
- )
- # Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
- user_file.is_image = True
- user_file.image_width = image.width
- user_file.image_height = image.height
- except IOError:
- pass # Not an image
- except Exception as exc:
- logger.warning(
- f"Failed to generate thumbnails for user file of type {mime_type}: {exc}"
- )
- finally:
- if image is not None:
- del image
+ if is_active_content:
+ self._neutralize_active_content(user_file)
+ else:
+ image = None
+ try:
+ image = Image.open(stream)
+ user_file.mime_type = f"image/{image.format}".lower()
+ self.generate_and_save_image_thumbnails(
+ image, user_file.name, storage=storage
+ )
+ # Skip marking as images if thumbnails cannot be generated (i.e. PSD files).
+ user_file.is_image = True
+ user_file.image_width = image.width
+ user_file.image_height = image.height
+ except IOError:
+ pass # Not an image
+ except Exception as exc:
+ logger.warning(
+ f"Failed to generate thumbnails for user file of type "
+ f"{mime_type}: {exc}"
+ )
+ finally:
+ if image is not None:
+ del image
user_file.save()
diff --git a/backend/src/baserow/version.py b/backend/src/baserow/version.py
index 3f755ed209..4c337c2066 100644
--- a/backend/src/baserow/version.py
+++ b/backend/src/baserow/version.py
@@ -1 +1 @@
-VERSION = "2.2.1"
+VERSION = "2.2.2"
diff --git a/backend/tests/baserow/api/user_files/test_user_files_views.py b/backend/tests/baserow/api/user_files/test_user_files_views.py
index 2310d92d80..fbf0c7c7c8 100644
--- a/backend/tests/baserow/api/user_files/test_user_files_views.py
+++ b/backend/tests/baserow/api/user_files/test_user_files_views.py
@@ -4,6 +4,7 @@
from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.shortcuts import reverse
+from django.test import override_settings
import pytest
import responses
@@ -19,6 +20,22 @@
from baserow.core.models import UserFile
+@pytest.mark.django_db
+@override_settings(FILE_UPLOAD_ACTIVE_CONTENT_POLICY="block")
+def test_upload_file_active_content_blocked(api_client, data_fixture):
+ user, token = data_fixture.create_user_and_token()
+
+ response = api_client.post(
+ reverse("api:user_files:upload_file"),
+ data={"file": SimpleUploadedFile("index.html", b"")},
+ format="multipart",
+ HTTP_AUTHORIZATION=f"JWT {token}",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert response.json()["error"] == "ERROR_INVALID_FILE"
+
+
@pytest.mark.django_db
def test_upload_file_with_jwt_auth(api_client, data_fixture, tmpdir):
user, token = data_fixture.create_user_and_token(
diff --git a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
index 63e375f9c7..05ef30024b 100644
--- a/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
+++ b/backend/tests/baserow/contrib/builder/api/domains/test_domain_public_views.py
@@ -646,6 +646,7 @@ def test_ask_public_builder_domain_exists(api_client, data_fixture):
@override_settings(
PUBLIC_BACKEND_HOSTNAME="backend.localhost",
PUBLIC_WEB_FRONTEND_HOSTNAME="web-frontend.localhost",
+ MEDIA_URL_HOSTNAME="media.localhost",
)
def test_ask_public_builder_domain_exists_with_public_backend_and_web_frontend_domains(
api_client, data_fixture
@@ -662,6 +663,10 @@ def test_ask_public_builder_domain_exists_with_public_backend_and_web_frontend_d
response = api_client.get(url)
assert response.status_code == 200
+ url = reverse("api:builder:domains:ask_exists") + "?domain=media.localhost"
+ response = api_client.get(url)
+ assert response.status_code == 200
+
@pytest.mark.django_db
@override_settings(
diff --git a/backend/tests/baserow/core/user_file/test_user_file_handler.py b/backend/tests/baserow/core/user_file/test_user_file_handler.py
index b4b0a53a89..82e1b8f4b5 100644
--- a/backend/tests/baserow/core/user_file/test_user_file_handler.py
+++ b/backend/tests/baserow/core/user_file/test_user_file_handler.py
@@ -8,6 +8,8 @@
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile
from django.core.files.storage import FileSystemStorage
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import override_settings
import pytest
import responses
@@ -18,13 +20,14 @@
from baserow.core.models import UserFile
from baserow.core.storage import ExportZipFile
from baserow.core.user_files.exceptions import (
+ ActiveContentBlockedUserFileError,
FileSizeTooLargeError,
FileURLCouldNotBeReached,
InvalidFileStreamError,
InvalidFileURLError,
MaximumUniqueTriesError,
)
-from baserow.core.user_files.handler import UserFileHandler
+from baserow.core.user_files.handler import MIME_TYPE_UNKNOWN, UserFileHandler
GENERATED_FILE_NAME_LENGTH = 16 # 12 hexdigest + '.' + ext
@@ -286,6 +289,60 @@ def test_upload_user_file_with_unsupported_image_format(
assert not file_path.isfile()
+@pytest.mark.django_db
+@override_settings(FILE_UPLOAD_ACTIVE_CONTENT_POLICY="download")
+def test_upload_user_file_active_content_download_policy(data_fixture, tmpdir):
+ user = data_fixture.create_user()
+ storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost")
+ handler = UserFileHandler()
+
+ user_file = handler.upload_user_file(
+ user,
+ "logo.svg",
+ ContentFile(b''),
+ storage=storage,
+ )
+
+ assert user_file.original_extension == "svg"
+ assert user_file.mime_type == MIME_TYPE_UNKNOWN
+ assert user_file.is_image is False
+ assert user_file.image_width is None
+ assert user_file.image_height is None
+ assert tmpdir.join("user_files", user_file.name).isfile()
+ assert not tmpdir.join("thumbnails", "tiny", user_file.name).isfile()
+
+
+@pytest.mark.django_db
+@override_settings(FILE_UPLOAD_ACTIVE_CONTENT_POLICY="block")
+@pytest.mark.parametrize(
+ "file_name",
+ ["index.html", "index.htm", "page.xhtml", "feed.xml", "logo.svg", "logo.svgz"],
+)
+def test_upload_user_file_active_content_block_policy(data_fixture, tmpdir, file_name):
+ user = data_fixture.create_user()
+ storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost")
+
+ with pytest.raises(ActiveContentBlockedUserFileError):
+ UserFileHandler().upload_user_file(
+ user, file_name, ContentFile(b"active content"), storage=storage
+ )
+
+
+@pytest.mark.django_db
+@override_settings(FILE_UPLOAD_ACTIVE_CONTENT_POLICY="block")
+def test_upload_user_file_active_content_block_policy_uses_mime_type(
+ data_fixture, tmpdir
+):
+ user = data_fixture.create_user()
+ storage = FileSystemStorage(location=str(tmpdir), base_url="http://localhost")
+ file = SimpleUploadedFile(
+ "active-content.txt", b"", content_type="text/html"
+ )
+
+ with pytest.raises(ActiveContentBlockedUserFileError):
+ UserFileHandler().upload_user_file(user, file.name, file, storage=storage)
+
+
@pytest.mark.django_db
@responses.activate
def test_upload_user_file_by_url(data_fixture, tmpdir):
diff --git a/backend/uv.lock b/backend/uv.lock
index 1787aade05..b9c2dd6eaa 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -461,12 +461,12 @@ dev = [
[[package]]
name = "baserow-enterprise"
-version = "2.2.1"
+version = "2.2.2"
source = { editable = "../enterprise/backend" }
[[package]]
name = "baserow-premium"
-version = "2.2.1"
+version = "2.2.2"
source = { editable = "../premium/backend" }
[[package]]
diff --git a/changelog.md b/changelog.md
index e82983e18c..6b08d84eda 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,21 @@
# Changelog
+## Released 2.2.2
+
+### New features
+* [Automation] Send notification when a workflow is disabled [#5186](https://github.com/baserow/baserow/issues/5186)
+* [Core] Allow self-hosted operators to inject custom client-side scripts via environment variables.
+
+### Bug fixes
+* [Builder] Resolved a bug which prevented users from creating data sources from the data source dropdown's footer. [#5118](https://github.com/baserow/baserow/issues/5118)
+* [Core] Give Kuma the current license tier in its context and steer uncertain feature or plan questions to docs search. [#5210](https://github.com/baserow/baserow/issues/5210)
+* [Core] Hardened user uploaded media serving and neutralized active-content file uploads by default.
+* [Builder] stop infinite `/dispatch-data-sources/` refetch loop in page editor
+
+### Refactors
+* [Automation] Optimize Automation History clean-up by moving it to a separate periodic task.
+
+
## Released 2.2.1
### New features
diff --git a/changelog/entries/unreleased/bug/5118_resolved_a_bug_which_prevented_users_from_creating_data_sour.json b/changelog/entries/2.2.2/bug/5118_resolved_a_bug_which_prevented_users_from_creating_data_sour.json
similarity index 100%
rename from changelog/entries/unreleased/bug/5118_resolved_a_bug_which_prevented_users_from_creating_data_sour.json
rename to changelog/entries/2.2.2/bug/5118_resolved_a_bug_which_prevented_users_from_creating_data_sour.json
diff --git a/changelog/entries/2.2.2/bug/5210_kuma_plan_context_and_hallucination_guardrail.json b/changelog/entries/2.2.2/bug/5210_kuma_plan_context_and_hallucination_guardrail.json
new file mode 100644
index 0000000000..b9a30c2883
--- /dev/null
+++ b/changelog/entries/2.2.2/bug/5210_kuma_plan_context_and_hallucination_guardrail.json
@@ -0,0 +1,9 @@
+{
+ "type": "bug",
+ "message": "Give Kuma the current license tier in its context and steer uncertain feature or plan questions to docs search.",
+ "issue_origin": "github",
+ "issue_number": 5210,
+ "domain": "core",
+ "bullet_points": [],
+ "created_at": "2026-04-17"
+}
diff --git a/changelog/entries/2.2.2/bug/harden_user_file_media_serving.json b/changelog/entries/2.2.2/bug/harden_user_file_media_serving.json
new file mode 100644
index 0000000000..575a79b48e
--- /dev/null
+++ b/changelog/entries/2.2.2/bug/harden_user_file_media_serving.json
@@ -0,0 +1,9 @@
+{
+ "type": "bug",
+ "message": "Hardened user uploaded media serving and neutralized active-content file uploads by default.",
+ "issue_origin": "github",
+ "issue_number": null,
+ "domain": "core",
+ "bullet_points": [],
+ "created_at": "2026-04-28"
+}
diff --git a/changelog/entries/unreleased/bug/stop_infinite_dispatchdatasources_refetch_loop_in_page_edito.json b/changelog/entries/2.2.2/bug/stop_infinite_dispatchdatasources_refetch_loop_in_page_edito.json
similarity index 100%
rename from changelog/entries/unreleased/bug/stop_infinite_dispatchdatasources_refetch_loop_in_page_edito.json
rename to changelog/entries/2.2.2/bug/stop_infinite_dispatchdatasources_refetch_loop_in_page_edito.json
diff --git a/changelog/entries/unreleased/feature/5186_send_notification_when_a_workflow_is_disabled.json b/changelog/entries/2.2.2/feature/5186_send_notification_when_a_workflow_is_disabled.json
similarity index 100%
rename from changelog/entries/unreleased/feature/5186_send_notification_when_a_workflow_is_disabled.json
rename to changelog/entries/2.2.2/feature/5186_send_notification_when_a_workflow_is_disabled.json
diff --git a/changelog/entries/unreleased/feature/allow_custom_client_scripts.json b/changelog/entries/2.2.2/feature/allow_custom_client_scripts.json
similarity index 79%
rename from changelog/entries/unreleased/feature/allow_custom_client_scripts.json
rename to changelog/entries/2.2.2/feature/allow_custom_client_scripts.json
index cf4c299d26..029b21fc1e 100644
--- a/changelog/entries/unreleased/feature/allow_custom_client_scripts.json
+++ b/changelog/entries/2.2.2/feature/allow_custom_client_scripts.json
@@ -1,6 +1,8 @@
{
"type": "feature",
"message": "Allow self-hosted operators to inject custom client-side scripts via environment variables.",
+ "issue_origin": "github",
+ "issue_number": null,
"domain": "core",
"bullet_points": [],
"created_at": "2026-04-21"
diff --git a/changelog/entries/unreleased/refactor/move_automation_history_cleanup_to_a_separate_periodic_task.json b/changelog/entries/2.2.2/refactor/move_automation_history_cleanup_to_a_separate_periodic_task.json
similarity index 100%
rename from changelog/entries/unreleased/refactor/move_automation_history_cleanup_to_a_separate_periodic_task.json
rename to changelog/entries/2.2.2/refactor/move_automation_history_cleanup_to_a_separate_periodic_task.json
diff --git a/changelog/releases.json b/changelog/releases.json
index 0f84b2c957..bfb3313bf1 100644
--- a/changelog/releases.json
+++ b/changelog/releases.json
@@ -1,5 +1,9 @@
{
"releases": [
+ {
+ "name": "2.2.2",
+ "created_at": "2026-04-28"
+ },
{
"name": "2.2.1",
"created_at": "2026-04-22"
diff --git a/deploy/all-in-one/README.md b/deploy/all-in-one/README.md
index 4f4df51e3d..c672ee9ec8 100644
--- a/deploy/all-in-one/README.md
+++ b/deploy/all-in-one/README.md
@@ -15,7 +15,7 @@ tool gives you the powers of a developer without leaving your browser.
[Vue.js](https://vuejs.org/) and [PostgreSQL](https://www.postgresql.org/).
```bash
-docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.1
+docker run -v baserow_data:/baserow/data -p 80:80 -p 443:443 baserow/baserow:2.2.2
```
## Quick Reference
@@ -52,7 +52,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
* Change `BASEROW_PUBLIC_URL` to `https://YOUR_DOMAIN` or `http://YOUR_IP` to enable
@@ -75,7 +75,7 @@ docker run \
## Image Feature Overview
-The `baserow/baserow:2.2.1` image by default runs all of Baserow's various services in
+The `baserow/baserow:2.2.2` image by default runs all of Baserow's various services in
a single container for maximum ease of use.
> This image is designed for simple single server deployments or simple container
@@ -223,7 +223,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Behind a reverse proxy already handling ssl
@@ -236,7 +236,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### On a nonstandard HTTP port
@@ -249,7 +249,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 3001:80 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external PostgresSQL server
@@ -268,7 +268,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external Redis server
@@ -289,7 +289,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external email server
@@ -309,7 +309,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With a Postgresql server running on the same host as the Baserow docker container
@@ -347,7 +347,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
-p 443:443 \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Supply secrets using files
@@ -374,7 +374,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
-p 443:443 \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Start just the embedded database
@@ -387,7 +387,7 @@ docker run -it \
--name baserow \
-p 5432:5432 \
-v baserow_data:/baserow/data \
- baserow/baserow:2.2.1 \
+ baserow/baserow:2.2.2 \
start-only-db
# Now get the password from
docker exec -it baserow cat /baserow/data/.pgpass
@@ -419,7 +419,7 @@ docker run -it \
--rm \
--name baserow \
-v baserow_data:/baserow/data \
- baserow/baserow:2.2.1 \
+ baserow/baserow:2.2.2 \
backend-cmd-with-db manage dbshell
```
@@ -542,19 +542,19 @@ the command below.
```bash
# First read the help message for this command
-docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.1 \
+docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.2 \
backend-cmd-with-db backup --help
# Stop Baserow instance
docker stop baserow
# The command below backs up Baserow to the backups folder in the baserow_data volume:
-docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.1 \
+docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.2 \
backend-cmd-with-db backup -f /baserow/data/backups/backup.tar.gz
# Or backup to a file on your host instead run something like:
docker run -it --rm -v baserow_data:/baserow/data -v $PWD:/baserow/host \
- baserow/baserow:2.2.1 backend-cmd-with-db backup -f /baserow/host/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db backup -f /baserow/host/backup.tar.gz
```
### Restore only Baserow's Postgres Database
@@ -570,13 +570,13 @@ docker stop baserow
docker run -it --rm \
-v old_baserow_data_volume_containing_the_backup_tar_gz:/baserow/old_data \
-v new_baserow_data_volume_to_restore_into:/baserow/data \
- baserow/baserow:2.2.1 backend-cmd-with-db restore -f /baserow/old_data/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db restore -f /baserow/old_data/backup.tar.gz
# Or to restore from a file on your host instead run something like:
docker run -it --rm \
-v baserow_data:/baserow/data -v \
$(pwd):/baserow/host \
- baserow/baserow:2.2.1 backend-cmd-with-db restore -f /baserow/host/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db restore -f /baserow/host/backup.tar.gz
```
## Running healthchecks on Baserow
@@ -627,7 +627,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
Or you can just store it directly in the volume at `baserow_data/env` meaning it will be
@@ -636,7 +636,7 @@ loaded whenever you mount in this data volume.
### Building your own image from Baserow
```dockerfile
-FROM baserow/baserow:2.2.1
+FROM baserow/baserow:2.2.2
# Any .sh files found in /baserow/supervisor/env/ will be sourced and loaded at startup
# useful for storing your own environment variable overrides.
diff --git a/deploy/all-in-one/docker-compose.yml b/deploy/all-in-one/docker-compose.yml
index ae520e1dbd..38613a7052 100644
--- a/deploy/all-in-one/docker-compose.yml
+++ b/deploy/all-in-one/docker-compose.yml
@@ -11,6 +11,8 @@ services:
EMAIL_SMTP_HOST: 'mailhog'
EMAIL_SMTP_PORT: '1025'
BASEROW_PUBLIC_URL:
+ MEDIA_URL:
+ BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY:
ports:
- "80:80"
- "443:443"
diff --git a/deploy/all-in-one/supervisor/start.sh b/deploy/all-in-one/supervisor/start.sh
index 2f9645f871..94a6623e74 100755
--- a/deploy/all-in-one/supervisor/start.sh
+++ b/deploy/all-in-one/supervisor/start.sh
@@ -14,7 +14,7 @@ cat << EOF
██████╔╝██║ ██║███████║███████╗██║ ██║╚██████╔╝╚███╔███╔╝
╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝
-Version 2.2.1
+Version 2.2.2
=========================================================================================
EOF
diff --git a/deploy/apache/no-caddy/sub-domain.conf b/deploy/apache/no-caddy/sub-domain.conf
index 1f842d0142..a381413346 100644
--- a/deploy/apache/no-caddy/sub-domain.conf
+++ b/deploy/apache/no-caddy/sub-domain.conf
@@ -5,11 +5,13 @@ ProxyPreserveHost On
# Replace with your sub domain
ServerName example.localhost
-# Serve user uploaded files and add the Content-Disposition header when the filename
-# query param is set.
+# Serve user uploaded files with download and sandboxing headers.
+RewriteRule ^/media/.* - [E=IS_MEDIA:1]
RewriteCond %{QUERY_STRING} (?:^|&)dl=([^&]+)
RewriteRule ^/media/.* - [E=FILENAME:%1]
Header set "Content-Disposition" "attachment; filename=\"%{FILENAME}e\"" env=FILENAME
+Header set "X-Content-Type-Options" "nosniff" env=IS_MEDIA
+Header set "Content-Security-Policy" "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" env=IS_MEDIA
ProxyPass /media !
Alias /media /baserow/media
diff --git a/deploy/cloudron/CloudronManifest.json b/deploy/cloudron/CloudronManifest.json
index 5ffb1dfdc1..df5237f36a 100644
--- a/deploy/cloudron/CloudronManifest.json
+++ b/deploy/cloudron/CloudronManifest.json
@@ -8,7 +8,7 @@
"contactEmail": "bram@baserow.io",
"icon": "file://logo.png",
"tags": ["no-code", "nocode", "database", "data", "collaborate", "airtable"],
- "version": "2.2.1",
+ "version": "2.2.2",
"healthCheckPath": "/api/_health/",
"httpPort": 80,
"addons": {
diff --git a/deploy/cloudron/Dockerfile b/deploy/cloudron/Dockerfile
index d664583e2c..abcc9fcdb4 100644
--- a/deploy/cloudron/Dockerfile
+++ b/deploy/cloudron/Dockerfile
@@ -1,4 +1,4 @@
-ARG FROM_IMAGE=baserow/baserow:2.2.1
+ARG FROM_IMAGE=baserow/baserow:2.2.2
# This is pinned as version pinning is done by the CI setting FROM_IMAGE.
# hadolint ignore=DL3006
FROM $FROM_IMAGE AS image_base
diff --git a/deploy/helm/baserow/Chart.lock b/deploy/helm/baserow/Chart.lock
index 33fb420ae3..cca5b2a8b1 100644
--- a/deploy/helm/baserow/Chart.lock
+++ b/deploy/helm/baserow/Chart.lock
@@ -1,28 +1,28 @@
dependencies:
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: baserow
repository: file://charts/baserow-common
- version: 1.0.51
+ version: 1.0.52
- name: redis
repository: https://charts.bitnami.com/bitnami
version: 19.5.5
@@ -35,5 +35,5 @@ dependencies:
- name: caddy-ingress-controller
repository: https://caddyserver.github.io/ingress
version: 1.1.0
-digest: sha256:85683908e75597f5f0b821a562461fceca2f05308b7ef683d28d401f60373b44
-generated: "2026-04-22T20:47:28.551027+02:00"
+digest: sha256:f76cc703ae9597738bde9b67ff4db4455ef1a7f9edf2cb53d20fb4e978f66ada
+generated: "2026-04-28T18:12:51.830902+02:00"
diff --git a/deploy/helm/baserow/Chart.yaml b/deploy/helm/baserow/Chart.yaml
index 9ad388b07a..eee7be4e97 100644
--- a/deploy/helm/baserow/Chart.yaml
+++ b/deploy/helm/baserow/Chart.yaml
@@ -2,8 +2,8 @@ apiVersion: v2
name: baserow
description: The open platform to create scalable databases and applications—without coding.
type: application
-version: 1.0.51
-appVersion: "2.2.1"
+version: 1.0.52
+appVersion: "2.2.2"
home: https://github.com/baserow/baserow/blob/develop/deploy/helm/baserow?ref_type=heads
icon: https://baserow.io/img/favicon_192.png
sources:
@@ -13,43 +13,43 @@ sources:
dependencies:
- name: baserow
alias: baserow-backend-asgi
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-backend-wsgi
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-frontend
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-celery-beat-worker
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-celery-export-worker
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-celery-worker
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
- name: baserow
alias: baserow-celery-flower
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
condition: baserow-celery-flower.enabled
- name: baserow
alias: baserow-embeddings
- version: "1.0.51"
+ version: "1.0.52"
repository: "file://charts/baserow-common"
condition: baserow-embeddings.enabled
diff --git a/deploy/helm/baserow/README.md b/deploy/helm/baserow/README.md
index d2862749c4..fe7be8d44f 100644
--- a/deploy/helm/baserow/README.md
+++ b/deploy/helm/baserow/README.md
@@ -232,7 +232,7 @@ caddy:
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | ----------------------- |
| `global.baserow.imageRegistry` | Global Docker image registry | `baserow` |
| `global.baserow.imagePullSecrets` | Global Docker registry secret names as an array | `[]` |
-| `global.baserow.image.tag` | Global Docker image tag | `2.2.1` |
+| `global.baserow.image.tag` | Global Docker image tag | `2.2.2` |
| `global.baserow.serviceAccount.shared` | Set to true to share the service account between all application components. | `true` |
| `global.baserow.serviceAccount.create` | Set to true to create a service account to share between all application components. | `true` |
| `global.baserow.serviceAccount.name` | Configure a name for service account to share between all application components. | `baserow` |
diff --git a/deploy/helm/baserow/charts/baserow-common/Chart.yaml b/deploy/helm/baserow/charts/baserow-common/Chart.yaml
index 0139cc2c30..711b886fef 100644
--- a/deploy/helm/baserow/charts/baserow-common/Chart.yaml
+++ b/deploy/helm/baserow/charts/baserow-common/Chart.yaml
@@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 1.0.51
+version: 1.0.52
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
-appVersion: "2.2.1"
+appVersion: "2.2.2"
diff --git a/deploy/helm/baserow/charts/baserow-common/README.md b/deploy/helm/baserow/charts/baserow-common/README.md
index 959f5944d3..a97d7c21c0 100644
--- a/deploy/helm/baserow/charts/baserow-common/README.md
+++ b/deploy/helm/baserow/charts/baserow-common/README.md
@@ -6,7 +6,7 @@
| ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `global.baserow.imageRegistry` | Global Docker image registry | `baserow` |
| `global.baserow.imagePullSecrets` | Global Docker registry secret names as an array | `[]` |
-| `global.baserow.image.tag` | Global Docker image tag | `2.2.1` |
+| `global.baserow.image.tag` | Global Docker image tag | `2.2.2` |
| `global.baserow.serviceAccount.shared` | Set to true to share the service account between all application components. | `true` |
| `global.baserow.serviceAccount.create` | Set to true to create a service account to share between all application components. | `true` |
| `global.baserow.serviceAccount.name` | Configure a name for service account to share between all application components. | `baserow` |
diff --git a/deploy/helm/baserow/charts/baserow-common/values.yaml b/deploy/helm/baserow/charts/baserow-common/values.yaml
index 26835a76bc..284ca07605 100644
--- a/deploy/helm/baserow/charts/baserow-common/values.yaml
+++ b/deploy/helm/baserow/charts/baserow-common/values.yaml
@@ -38,7 +38,7 @@ global:
baserow:
imageRegistry: baserow
image:
- tag: 2.2.1
+ tag: 2.2.2
imagePullSecrets: []
serviceAccount:
shared: true
@@ -83,7 +83,7 @@ global:
##
image:
repository: baserow/baserow # Docker image repository
- tag: 2.2.1 # Docker image tag
+ tag: 2.2.2 # Docker image tag
pullPolicy: IfNotPresent # Image pull policy
## @param workingDir Application container working directory
diff --git a/deploy/helm/baserow/values.yaml b/deploy/helm/baserow/values.yaml
index eac56cd3a7..731c8da6ec 100644
--- a/deploy/helm/baserow/values.yaml
+++ b/deploy/helm/baserow/values.yaml
@@ -43,7 +43,7 @@ global:
baserow:
imageRegistry: baserow
image:
- tag: 2.2.1
+ tag: 2.2.2
imagePullSecrets: []
serviceAccount:
shared: true
diff --git a/deploy/nginx/no-caddy/nginx.conf b/deploy/nginx/no-caddy/nginx.conf
index e5f4fe6a56..469e91b2a2 100644
--- a/deploy/nginx/no-caddy/nginx.conf
+++ b/deploy/nginx/no-caddy/nginx.conf
@@ -7,6 +7,12 @@ error_log /dev/stdout info;
http {
access_log /dev/stdout;
client_max_body_size 20m;
+
+ map $arg_dl $baserow_media_content_disposition {
+ default "attachment; filename=$arg_dl";
+ "" "";
+ }
+
server {
server_name example.localhost;
@@ -22,9 +28,9 @@ http {
}
location /media/ {
- if ($arg_dl) {
- add_header Content-disposition "attachment; filename=$arg_dl";
- }
+ add_header Content-Disposition $baserow_media_content_disposition always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
# TODO Change to your media folder location!
alias /baserow/media/;
}
diff --git a/deploy/render/Dockerfile b/deploy/render/Dockerfile
index eaff0227b9..58e42af021 100644
--- a/deploy/render/Dockerfile
+++ b/deploy/render/Dockerfile
@@ -1,4 +1,4 @@
-ARG FROM_IMAGE=baserow/baserow:2.2.1
+ARG FROM_IMAGE=baserow/baserow:2.2.2
# This is pinned as version pinning is done by the CI setting FROM_IMAGE.
# hadolint ignore=DL3006
FROM $FROM_IMAGE AS image_base
diff --git a/docker-compose.all-in-one.yml b/docker-compose.all-in-one.yml
index b80aa0e5fb..de3f6d15e3 100644
--- a/docker-compose.all-in-one.yml
+++ b/docker-compose.all-in-one.yml
@@ -3,7 +3,7 @@
services:
baserow:
container_name: baserow
- image: baserow/baserow:${BASEROW_VERSION:-2.2.1}
+ image: baserow/baserow:${BASEROW_VERSION:-2.2.2}
environment:
BASEROW_PUBLIC_URL: 'http://localhost'
ports:
diff --git a/docker-compose.no-caddy.yml b/docker-compose.no-caddy.yml
index 88855be98b..ccdfa56767 100644
--- a/docker-compose.no-caddy.yml
+++ b/docker-compose.no-caddy.yml
@@ -82,6 +82,7 @@ x-backend-variables:
BATCH_ROWS_SIZE_LIMIT:
INITIAL_TABLE_DATA_LIMIT:
BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB:
+ BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY:
BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB:
BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT:
@@ -204,7 +205,7 @@ x-backend-variables:
services:
backend:
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
restart: unless-stopped
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:8000:8000"
@@ -219,12 +220,13 @@ services:
local:
web-frontend:
- image: baserow/web-frontend:2.2.1
+ image: baserow/web-frontend:2.2.2
restart: unless-stopped
ports:
- "${HOST_PUBLISH_IP:-127.0.0.1}:3000:3000"
environment:
BASEROW_PUBLIC_URL:
+ MEDIA_URL:
PRIVATE_BACKEND_URL: ${PRIVATE_BACKEND_URL:-http://backend:8000}
PUBLIC_BACKEND_URL:
PUBLIC_WEB_FRONTEND_URL:
@@ -270,7 +272,7 @@ services:
local:
celery:
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
restart: unless-stopped
environment:
<<: *backend-variables
@@ -291,7 +293,7 @@ services:
local:
celery-export-worker:
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
restart: unless-stopped
command: celery-exportworker
environment:
@@ -312,7 +314,7 @@ services:
local:
celery-beat-worker:
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
restart: unless-stopped
command: celery-beat
environment:
diff --git a/docker-compose.yml b/docker-compose.yml
index 4f30b0014e..2236937a4e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -96,6 +96,7 @@ x-backend-variables:
BATCH_ROWS_SIZE_LIMIT:
INITIAL_TABLE_DATA_LIMIT:
BASEROW_FILE_UPLOAD_SIZE_LIMIT_MB:
+ BASEROW_FILE_UPLOAD_ACTIVE_CONTENT_POLICY:
BASEROW_OPENAI_UPLOADED_FILE_SIZE_LIMIT_MB:
BASEROW_UNIQUE_ROW_VALUES_SIZE_LIMIT:
@@ -273,6 +274,7 @@ services:
PRIVATE_WEB_FRONTEND_URL: ${PRIVATE_WEB_FRONTEND_URL:-http://web-frontend:3000}
PRIVATE_BACKEND_URL: ${PRIVATE_BACKEND_URL:-http://backend:8000}
BASEROW_PUBLIC_URL: ${BASEROW_PUBLIC_URL:-}
+ MEDIA_URL: ${MEDIA_URL:-}
ports:
- "${HOST_PUBLISH_IP:-0.0.0.0}:${WEB_FRONTEND_PORT:-80}:80"
- "${HOST_PUBLISH_IP:-0.0.0.0}:${WEB_FRONTEND_SSL_PORT:-443}:443"
@@ -285,7 +287,7 @@ services:
local:
backend:
- image: baserow/backend:${BASEROW_VERSION:-2.2.1}
+ image: baserow/backend:${BASEROW_VERSION:-2.2.2}
restart: unless-stopped
environment:
@@ -299,10 +301,11 @@ services:
local:
web-frontend:
- image: baserow/web-frontend:${BASEROW_VERSION:-2.2.1}
+ image: baserow/web-frontend:${BASEROW_VERSION:-2.2.2}
restart: unless-stopped
environment:
BASEROW_PUBLIC_URL: ${BASEROW_PUBLIC_URL-http://localhost}
+ MEDIA_URL:
BASEROW_EXTRA_PUBLIC_URLS:
PRIVATE_BACKEND_URL: ${PRIVATE_BACKEND_URL:-http://backend:8000}
PUBLIC_BACKEND_URL:
@@ -355,7 +358,7 @@ services:
local:
celery:
- image: baserow/backend:${BASEROW_VERSION:-2.2.1}
+ image: baserow/backend:${BASEROW_VERSION:-2.2.2}
restart: unless-stopped
environment:
<<: *backend-variables
@@ -377,7 +380,7 @@ services:
local:
celery-export-worker:
- image: baserow/backend:${BASEROW_VERSION:-2.2.1}
+ image: baserow/backend:${BASEROW_VERSION:-2.2.2}
restart: unless-stopped
command: celery-exportworker
environment:
@@ -399,7 +402,7 @@ services:
local:
celery-beat-worker:
- image: baserow/backend:${BASEROW_VERSION:-2.2.1}
+ image: baserow/backend:${BASEROW_VERSION:-2.2.2}
restart: unless-stopped
command: celery-beat
healthcheck:
diff --git a/docs/installation/configuration-files/nginx.conf b/docs/installation/configuration-files/nginx.conf
index 5ffa15fbd4..9f7ae9e288 100644
--- a/docs/installation/configuration-files/nginx.conf
+++ b/docs/installation/configuration-files/nginx.conf
@@ -62,6 +62,11 @@ server {
}
# Media
+map $arg_dl $baserow_media_content_disposition {
+ default "attachment; filename=$arg_dl";
+ "" "";
+}
+
server {
listen 80;
server_name "*YOUR_MEDIA_DOMAIN*";
@@ -71,24 +76,23 @@ server {
gzip_disable "msie6";
location / {
- if ($arg_dl) {
- add_header Content-disposition "attachment; filename=$arg_dl";
- }
+ add_header Content-Disposition $baserow_media_content_disposition always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
root /baserow/media;
}
location /user_files {
- if ($arg_dl) {
- add_header Content-disposition "attachment; filename=$arg_dl";
- }
+ add_header Content-Disposition $baserow_media_content_disposition always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
root /baserow/media;
}
location /export_files {
- if ($arg_dl) {
- add_header Content-disposition "attachment; filename=$arg_dl";
- }
+ add_header Content-Disposition $baserow_media_content_disposition always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
root /baserow/media;
}
}
-
diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md
index 4b96496f12..841e9f066d 100644
--- a/docs/installation/configuration.md
+++ b/docs/installation/configuration.md
@@ -55,6 +55,7 @@ The installation methods referred to in the variable descriptions are:
| INITIAL\_TABLE\_DATA\_LIMIT | The amount of rows that can be imported when creating a table. Defaults to empty which means unlimited rows. | |
| BASEROW\_ROW\_PAGE\_SIZE\_LIMIT | The maximum number of rows that can be requested at once. | 200 |
| BASEROW\_FILE_UPLOAD\_SIZE\_LIMIT\_MB | The max file size in MB allowed to be uploaded by users into a Baserow File Field. | 1048576 (1 TB or 1024*1024) |
+| BASEROW\_FILE\_UPLOAD\_ACTIVE\_CONTENT\_POLICY | Controls how uploads with active-content extensions (`.html`, `.htm`, `.xhtml`, `.xml`, `.svg`, `.svgz`) or MIME types (`text/html`, `text/xml`, `application/xml`, `application/xhtml+xml`, `image/svg+xml`) are handled. Set to `download` to allow them but store them as `application/octet-stream` without image previews. Set to `block` to reject them. | download |
| BASEROW\_OPENAI\_UPLOADED\_FILE\_SIZE\_LIMIT\_MB | The max file size in MB allowed to be loaded in RAM and uploaded to OpenAI servers. See also [OpenAI docs](https://platform.openai.com/docs/api-reference/files/create). | 512 |
| BATCH\_ROWS\_SIZE\_LIMIT | Controls how many rows can be created, deleted or updated at once using the batch endpoints. | 200 |
| BATCH\_ROWS\_SIZE\_LIMIT | Controls how many rows can be created, deleted or updated at once using the batch endpoints. | 200 |
@@ -305,7 +306,7 @@ domain than your Baserow, you need to make sure CORS is configured correctly.
| Name | Description | Defaults |
|----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------|
-| MEDIA\_URL | **INTERNAL** The URL at which user uploaded media files will be made available | $PUBLIC\_BACKEND\_URL/media/ |
+| MEDIA\_URL | The URL at which user uploaded media files will be made available. Set this to a different origin, for example `https://media.baserow.example.com/media/`, to serve user uploads from an isolated media domain. | $PUBLIC\_BACKEND\_URL/media/ |
| MEDIA\_ROOT | **INTERNAL** The folder in which the backend will store user uploaded files | /baserow/media or $DATA_DIR/media for the `baserow/baserow` all-in-one image |
| ** ** | | |
| AWS\_ACCESS\_KEY\_ID | The access key for your AWS account. When set to anything other than empty will switch Baserow to use a S3 compatible bucket for storing user file uploads. | |
diff --git a/docs/installation/install-behind-apache.md b/docs/installation/install-behind-apache.md
index 645db490ac..1afc283f39 100644
--- a/docs/installation/install-behind-apache.md
+++ b/docs/installation/install-behind-apache.md
@@ -3,7 +3,7 @@
If you have an [Apache server](https://www.apache.com/) this guide will explain how to
configure it to pass requests through to Baserow.
-We strongly recommend you use our `baserow/baserow:2.2.1` image or the example
+We strongly recommend you use our `baserow/baserow:2.2.2` image or the example
`docker-compose.yml` files (excluding the `.no-caddy.yml` variant) provided in
our [git repository](https://github.com/baserow/baserow/tree/master/deploy/apache/).
@@ -18,8 +18,8 @@ simplifies your life by:
> If you do not want to use our embedded Caddy service behind your Apache then
> make sure you are using one of the two following deployment methods:
>
-> * Your own container setup with our single service `baserow/backend:2.2.1`
- and `baserow/web-frontend:2.2.1` images.
+> * Your own container setup with our single service `baserow/backend:2.2.2`
+ and `baserow/web-frontend:2.2.2` images.
> * Or our `docker-compose.no-caddy.yml` example file in our [git repository](https://github.com/baserow/baserow/tree/master/deploy/apache/).
>
> Then you should use **Option 2: Without our embedded Caddy** section instead.
@@ -32,7 +32,7 @@ simplifies your life by:
Follow this option if you are using:
-* The all-in-one Baserow image `baserow/baserow:2.2.1`
+* The all-in-one Baserow image `baserow/baserow:2.2.2`
* Any of the example compose files found in the root of our git
repository `docker-compose.yml`/`docker-compose.all-in-one.yml`
@@ -115,7 +115,7 @@ You should now be able to access Baserow on you configured subdomain.
Follow this option if you are using:
-* Our standalone `baserow/backend:2.2.1` and `baserow/web-frontend:2.2.1` images with
+* Our standalone `baserow/backend:2.2.2` and `baserow/web-frontend:2.2.2` images with
your own container orchestrator.
* Or the `docker-compose.no-caddy.yml` example docker compose file in the root of our
git repository.
@@ -147,7 +147,7 @@ sudo systemctl restart apache2
You need to ensure user uploaded files are accessible in a folder for Apache to serve. In
the rest of the guide we will use the example `/var/web` folder for this purpose.
-If you are using the `baserow/backend:2.2.1` image then you can do this by adding
+If you are using the `baserow/backend:2.2.2` image then you can do this by adding
`-v /var/web:/baserow/data/media` to your normal `docker run` command used to launch the
Baserow backend.
@@ -180,11 +180,13 @@ ProxyPreserveHost On
# Replace with your sub domain
ServerName example.localhost
-# Serve user uploaded files and add the Content-Disposition header when the filename
-# query param is set.
+# Serve user uploaded files with download and sandboxing headers.
+RewriteRule ^/media/.* - [E=IS_MEDIA:1]
RewriteCond %{QUERY_STRING} (?:^|&)dl=([^&]+)
RewriteRule ^/media/.* - [E=FILENAME:%1]
Header set "Content-Disposition" "attachment; filename=\"%{FILENAME}e\"" env=FILENAME
+Header set "X-Content-Type-Options" "nosniff" env=IS_MEDIA
+Header set "Content-Security-Policy" "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" env=IS_MEDIA
ProxyPass /media !
Alias /media /var/www
@@ -229,4 +231,3 @@ them (you are getting 403 denied errors when accessing the files) then:
your Apache user by running `cd /var/web && chmod 755 *`.
* Fix any file permissions found inside the `/var/web` sub-folders to be readable by
your Apache user.
-
diff --git a/docs/installation/install-behind-nginx.md b/docs/installation/install-behind-nginx.md
index bd0007996d..1c36a09e2b 100644
--- a/docs/installation/install-behind-nginx.md
+++ b/docs/installation/install-behind-nginx.md
@@ -3,7 +3,7 @@
If you have an [Nginx server](https://www.nginx.com/) this guide will explain how to
configure it to pass requests through to Baserow.
-We strongly recommend you use our `baserow/baserow:2.2.1` image or the example
+We strongly recommend you use our `baserow/baserow:2.2.2` image or the example
`docker-compose.yml` files (excluding the `.no-caddy.yml` variant) provided in
our [git repository](https://github.com/baserow/baserow/tree/master/deploy/nginx/).
@@ -18,8 +18,8 @@ simplifies your life by:
> If you do not want to use our embedded Caddy service behind your Nginx then
> make sure you are using one of the two following deployment methods:
>
-> * Your own container setup with our single service `baserow/backend:2.2.1`
- and `baserow/web-frontend:2.2.1` images.
+> * Your own container setup with our single service `baserow/backend:2.2.2`
+ and `baserow/web-frontend:2.2.2` images.
> * Or our `docker-compose.no-caddy.yml` example file in our [git repository](https://github.com/baserow/baserow/tree/master/deploy/nginx/).
>
> Then you should use **Option 2: Without our embedded Caddy** section instead.
@@ -32,7 +32,7 @@ simplifies your life by:
Follow this option if you are using:
-* The all-in-one Baserow image `baserow/baserow:2.2.1`
+* The all-in-one Baserow image `baserow/baserow:2.2.2`
* Any of the example compose files found in the root of our git
repository `docker-compose.yml`/`docker-compose.all-in-one.yml`
@@ -61,6 +61,11 @@ Create a new `baserow.conf` in `/etc/nginx/sites-available/` with the following
> your particular Baserow deployment.
```
+map $arg_dl $baserow_media_content_disposition {
+ default "attachment; filename=$arg_dl";
+ "" "";
+}
+
server {
server_name baserow.example.com;
@@ -107,7 +112,7 @@ You should now be able to access Baserow on you configured subdomain.
Follow this option if you are using:
-* Our standalone `baserow/backend:2.2.1` and `baserow/web-frontend:2.2.1` images with
+* Our standalone `baserow/backend:2.2.2` and `baserow/web-frontend:2.2.2` images with
your own container orchestrator.
* Or the `docker-compose.no-caddy.yml` example docker compose file in the root of our
git repository.
@@ -126,7 +131,7 @@ but you might have to run different commands.
You need to ensure user uploaded files are accessible in a folder for Nginx to serve. In
the rest of the guide we will use the example `/var/web` folder for this purpose.
-If you are using the `baserow/backend:2.2.1` image then you can do this by adding
+If you are using the `baserow/backend:2.2.2` image then you can do this by adding
`-v /var/web:/baserow/data/media` to your normal `docker run` command used to launch the
Baserow backend.
@@ -168,9 +173,9 @@ server {
}
location /media/ {
- if ($arg_dl) {
- add_header Content-disposition "attachment; filename=$arg_dl";
- }
+ add_header Content-Disposition $baserow_media_content_disposition always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Content-Security-Policy "sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'" always;
# TODO Change to your media folder location!
alias /var/www/;
}
@@ -207,4 +212,3 @@ them (you are getting 403 denied errors when accessing the files) then:
your Nginx user by running `cd /var/web && chmod 755 *`.
* Fix any file permissions found inside the `/var/web` sub-folders to be readable by
your Nginx user.
-
diff --git a/docs/installation/install-on-aws.md b/docs/installation/install-on-aws.md
index cf7103ad82..a027c3d301 100644
--- a/docs/installation/install-on-aws.md
+++ b/docs/installation/install-on-aws.md
@@ -49,7 +49,7 @@ overview this is what any AWS deployment of Baserow will need:
## Option 1) Deploying the all-in-one image to Fargate/ECS
-The `baserow/baserow:2.2.1` image runs all of Baserow’s various services inside the
+The `baserow/baserow:2.2.2` image runs all of Baserow’s various services inside the
container for ease of use.
This image is designed for single server deployments or simple deployments to
@@ -67,7 +67,7 @@ Run.
* You don't need to worry about configuring and linking together the different
services that make up a Baserow deployment.
* Configuring load balancers is easier as you can just directly route through all
- requests to any horizontally scaled container running `baserow/baserow:2.2.1`.
+ requests to any horizontally scaled container running `baserow/baserow:2.2.2`.
#### Cons
@@ -75,7 +75,7 @@ Run.
* Potentially higher resource usage overall as each of the all-in-one containers will
come with its internal services, so you have less granular control over scaling
specific services.
- * For example if you deploy 10 `baserow/baserow:2.2.1` containers horizontally you
+ * For example if you deploy 10 `baserow/baserow:2.2.2` containers horizontally you
by default end up with:
* 10 web-frontend services
* 10 backend services
@@ -188,18 +188,18 @@ Generally, the Redis server is not the bottleneck in Baserow deployments as they
Now create a target group on port 80 and ALB ready to route traffic to the Baserow
containers.
-When setting up the health check for the ALB the `baserow/baserow:2.2.1` container
+When setting up the health check for the ALB the `baserow/baserow:2.2.2` container
,which you'll be deploying next, choose port `80` and health check
URL `/api/_health/`. We recommend a long grace period of 900 seconds to account for
first-time migrations being run on the first container's startup.
#### 5) Launching Baserow on ECS/Fargate
-Now we are ready to spin up our `baserow/baserow:2.2.1` containers. See below for a
+Now we are ready to spin up our `baserow/baserow:2.2.2` containers. See below for a
full task definition and environment variables. We recommend launching the containers
with 2vCPUs and 4 GB of RAM each to start with. In short, you will want to:
-1. Select the `baserow/baserow:2.2.1` image.
+1. Select the `baserow/baserow:2.2.2` image.
2. Add a port mapping of `80` on TCP as this is where this images HTTP server is
listening by default.
3. Mark the container as essential.
@@ -244,7 +244,7 @@ container_definitions = < We recommend setting the timeout of each HTTP API request to 60 seconds in the
@@ -484,7 +484,7 @@ This service is our HTTP REST API service. When creating the task definition you
This service is our Websocket API service and when configuring the task definition you
should:
-1. Use the `baserow/backend:2.2.1`
+1. Use the `baserow/backend:2.2.2`
2. Under docker configuration set `gunicorn` as the Command.
3. We recommend 2vCPUs and 4 GB of RAM per container to start with.
4. Map the container port `8000`/`TCP`
@@ -496,7 +496,7 @@ should:
This service is our asynchronous high priority task worker queue used for realtime
collaboration and sending emails.
-1. Use the `baserow/backend:2.2.1` image with `celery-worker` as the image command.
+1. Use the `baserow/backend:2.2.2` image with `celery-worker` as the image command.
2. Under docker configuration set `celery-worker` as the Command.
3. No port mappings needed.
4. We recommend 2vCPUs and 4 GB of RAM per container to start with.
@@ -509,7 +509,7 @@ This service is our asynchronous slow/low priority task worker queue for batch
processes and running potentially slow operations for users like table exports and
imports etc.
-1. Use the `baserow/backend:2.2.1` image.
+1. Use the `baserow/backend:2.2.2` image.
2. Under docker configuration set `celery-exportworker` as the Command.
3. No port mappings needed.
4. We recommend 2vCPUs and 4 GB of RAM per container to start with.
@@ -520,7 +520,7 @@ imports etc.
This service is our CRON task scheduler that can have multiple replicas deployed.
-1. Use the `baserow/backend:2.2.1` image.
+1. Use the `baserow/backend:2.2.2` image.
2. Under docker configuration set `celery-beat` as the Command.
3. No port mapping needed.
4. We recommend 1vCPUs and 3 GB of RAM per container to start with.
@@ -537,7 +537,7 @@ This service is our CRON task scheduler that can have multiple replicas deployed
Finally, this service is used for server side rendering and serving the frontend of
Baserow.
-1. Use the `baserow/web-frontend:2.2.1` image with no arguments needed.
+1. Use the `baserow/web-frontend:2.2.2` image with no arguments needed.
2. Map the container port `3000`
3. We recommend 2vCPUs and 4 GB of RAM per container to start with.
4. Mark the container as essential.
diff --git a/docs/installation/install-on-cloudron.md b/docs/installation/install-on-cloudron.md
index 43e641c970..c36febb031 100644
--- a/docs/installation/install-on-cloudron.md
+++ b/docs/installation/install-on-cloudron.md
@@ -46,7 +46,7 @@ $ cd baserow/deploy/cloudron
After that you can install the Baserow Cloudron app by executing the following commands.
```
-$ cloudron install -l baserow.{YOUR_DOMAIN} --image baserow/cloudron:2.2.1
+$ cloudron install -l baserow.{YOUR_DOMAIN} --image baserow/cloudron:2.2.2
App is being installed.
...
App is installed.
@@ -89,7 +89,7 @@ the `baserow/deploy/cloudron` folder, you can upgrade your cloudron baserow serv
the latest version by running the following command:
```
-cloudron update --app {YOUR_APP_ID} --image baserow/cloudron:2.2.1
+cloudron update --app {YOUR_APP_ID} --image baserow/cloudron:2.2.2
```
> Note that you must replace the image with the most recent image of Baserow. The
diff --git a/docs/installation/install-on-digital-ocean.md b/docs/installation/install-on-digital-ocean.md
index f471a43018..387481eec4 100644
--- a/docs/installation/install-on-digital-ocean.md
+++ b/docs/installation/install-on-digital-ocean.md
@@ -51,7 +51,7 @@ Navigate to the `Apps` page in the left sidebar of your Digital Ocean dashboard.
on `Create App`, select `Docker Hub`, and fill out the following:
Repository: `baserow/baserow`
-Image tag or digest: `2.2.1`
+Image tag or digest: `2.2.2`
Click on `Next`, then on the `Edit` button of the `baserow-baserow` web service. Here
you must change the HTTP Port to 80, and then click on `Back`. Click on the `Next`
@@ -124,7 +124,7 @@ environment.
In order to update the Baserow version, you simply need to replace the image tag.
Navigate to the `Settings` tag of your created app, click on the `baserow-baserow`
component, then click on the `Edit` button next to source, change the `Image tag` into
-the desired version (latest is `2.2.1`), and click on save. The app will redeploy
+the desired version (latest is `2.2.2`), and click on save. The app will redeploy
with the latest version.
## External email server
diff --git a/docs/installation/install-on-ubuntu.md b/docs/installation/install-on-ubuntu.md
index 6204ccd2eb..09fd1ff8ff 100644
--- a/docs/installation/install-on-ubuntu.md
+++ b/docs/installation/install-on-ubuntu.md
@@ -34,7 +34,7 @@ docker run -e BASEROW_PUBLIC_URL=http://localhost \
-v baserow_data:/baserow/data \
-p 80:80 \
-p 443:443 \
-baserow/baserow:2.2.1
+baserow/baserow:2.2.2
# Watch the logs for Baserow to come available by running:
docker logs baserow
```
@@ -147,7 +147,7 @@ docker run \
-v /baserow/media:/baserow/data/media \
-p 80:80 \
-p 443:443 \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
# Check the logs and wait for Baserow to become available
docker logs baserow
```
diff --git a/docs/installation/install-using-standalone-images.md b/docs/installation/install-using-standalone-images.md
index a28cd684c2..79ab22fc19 100644
--- a/docs/installation/install-using-standalone-images.md
+++ b/docs/installation/install-using-standalone-images.md
@@ -10,9 +10,9 @@
Baserow consists of a number of services, two of which are built and provided as
separate standalone images by us:
-* `baserow/backend:2.2.1` which by default starts the Gunicorn Django backend server
+* `baserow/backend:2.2.2` which by default starts the Gunicorn Django backend server
for Baserow but is also used to start the celery workers and celery beat services.
-* `baserow/web-frontend:2.2.1` which is a Nuxt server providing Server Side rendering
+* `baserow/web-frontend:2.2.2` which is a Nuxt server providing Server Side rendering
for the website.
If you want to use your own container orchestration software like Kubernetes then these
@@ -27,10 +27,10 @@ in the root of our repository.
These are all the services you need to set up to run a Baserow using the standalone
images:
-* `baserow/backend:2.2.1` (default command is `gunicorn`)
-* `baserow/backend:2.2.1` with command `celery-worker`
-* `baserow/backend:2.2.1` with command `celery-export-worker`
-* `baserow/web-frontend:2.2.1` (default command is `nuxt-prod`)
+* `baserow/backend:2.2.2` (default command is `gunicorn`)
+* `baserow/backend:2.2.2` with command `celery-worker`
+* `baserow/backend:2.2.2` with command `celery-export-worker`
+* `baserow/web-frontend:2.2.2` (default command is `nuxt-prod`)
* A postgres database
* A redis server
@@ -54,9 +54,11 @@ images:
* Redirect `/api/` and `/ws/` requests to the backend gunicorn service without
dropping these prefixes.
* Serve the files in the `/baserow/media` folder in the backend gunicorn service
- (share with your proxy using a volume) at the `/media` endpoint. Ensure
- that requests with a `dl` query parameter have a `Content-disposition` header added
- with the value of `attachment; filename=THE_DL_QUERY_PARAM_VALUE`
+ (share with your proxy using a volume) at the `/media` endpoint. Ensure all media
+ responses have `X-Content-Type-Options: nosniff` and
+ `Content-Security-Policy: sandbox; default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'`.
+ Requests with a `dl` query parameter can use
+ `Content-Disposition: attachment; filename=THE_DL_QUERY_PARAM_VALUE`.
* Send all other requests to the web-frontend service.
* You must provide all email related environment variables to both the backend and
celery-worker services. This is because the `celery-worker` service is the one
diff --git a/docs/installation/install-with-docker-compose.md b/docs/installation/install-with-docker-compose.md
index 313a9375a4..6699667f4f 100644
--- a/docs/installation/install-with-docker-compose.md
+++ b/docs/installation/install-with-docker-compose.md
@@ -15,7 +15,7 @@ guide on the specifics of how to work with this image.
services:
baserow:
container_name: baserow
- image: baserow/baserow:2.2.1
+ image: baserow/baserow:2.2.2
environment:
BASEROW_PUBLIC_URL: 'http://localhost'
ports:
diff --git a/docs/installation/install-with-docker.md b/docs/installation/install-with-docker.md
index b82188b158..72d2f8ed64 100644
--- a/docs/installation/install-with-docker.md
+++ b/docs/installation/install-with-docker.md
@@ -29,7 +29,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
* Change `BASEROW_PUBLIC_URL` to `https://YOUR_DOMAIN` or `http://YOUR_IP` to enable
@@ -52,7 +52,7 @@ docker run \
## Image Feature Overview
-The `baserow/baserow:2.2.1` image by default runs all of Baserow's various services in
+The `baserow/baserow:2.2.2` image by default runs all of Baserow's various services in
a single container for maximum ease of use.
> This image is designed for simple single server deployments or simple container
@@ -200,7 +200,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Behind a reverse proxy already handling ssl
@@ -213,7 +213,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### On a nonstandard HTTP port
@@ -226,7 +226,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 3001:80 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external PostgresSQL server
@@ -245,7 +245,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external Redis server
@@ -266,7 +266,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With an external email server
@@ -286,7 +286,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### With a Postgresql server running on the same host as the Baserow docker container
@@ -324,7 +324,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
-p 443:443 \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Supply secrets using files
@@ -351,7 +351,7 @@ docker run \
-v baserow_data:/baserow/data \
-p 80:80 \
-p 443:443 \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
### Start just the embedded database
@@ -364,7 +364,7 @@ docker run -it \
--name baserow \
-p 5432:5432 \
-v baserow_data:/baserow/data \
- baserow/baserow:2.2.1 \
+ baserow/baserow:2.2.2 \
start-only-db
# Now get the password from
docker exec -it baserow cat /baserow/data/.pgpass
@@ -396,7 +396,7 @@ docker run -it \
--rm \
--name baserow \
-v baserow_data:/baserow/data \
- baserow/baserow:2.2.1 \
+ baserow/baserow:2.2.2 \
backend-cmd-with-db manage dbshell
```
@@ -519,19 +519,19 @@ the command below.
```bash
# First read the help message for this command
-docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.1 \
+docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.2 \
backend-cmd-with-db backup --help
# Stop Baserow instance
docker stop baserow
# The command below backs up Baserow to the backups folder in the baserow_data volume:
-docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.1 \
+docker run -it --rm -v baserow_data:/baserow/data baserow/baserow:2.2.2 \
backend-cmd-with-db backup -f /baserow/data/backups/backup.tar.gz
# Or backup to a file on your host instead run something like:
docker run -it --rm -v baserow_data:/baserow/data -v $PWD:/baserow/host \
- baserow/baserow:2.2.1 backend-cmd-with-db backup -f /baserow/host/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db backup -f /baserow/host/backup.tar.gz
```
### Restore only Baserow's Postgres Database
@@ -547,13 +547,13 @@ docker stop baserow
docker run -it --rm \
-v old_baserow_data_volume_containing_the_backup_tar_gz:/baserow/old_data \
-v new_baserow_data_volume_to_restore_into:/baserow/data \
- baserow/baserow:2.2.1 backend-cmd-with-db restore -f /baserow/old_data/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db restore -f /baserow/old_data/backup.tar.gz
# Or to restore from a file on your host instead run something like:
docker run -it --rm \
-v baserow_data:/baserow/data -v \
$(pwd):/baserow/host \
- baserow/baserow:2.2.1 backend-cmd-with-db restore -f /baserow/host/backup.tar.gz
+ baserow/baserow:2.2.2 backend-cmd-with-db restore -f /baserow/host/backup.tar.gz
```
## Running healthchecks on Baserow
@@ -604,7 +604,7 @@ docker run \
-p 80:80 \
-p 443:443 \
--restart unless-stopped \
- baserow/baserow:2.2.1
+ baserow/baserow:2.2.2
```
Or you can just store it directly in the volume at `baserow_data/env` meaning it will be
@@ -613,7 +613,7 @@ loaded whenever you mount in this data volume.
### Building your own image from Baserow
```dockerfile
-FROM baserow/baserow:2.2.1
+FROM baserow/baserow:2.2.2
# Any .sh files found in /baserow/supervisor/env/ will be sourced and loaded at startup
# useful for storing your own environment variable overrides.
diff --git a/docs/installation/install-with-helm.md b/docs/installation/install-with-helm.md
index c6a6b0739c..cb9b639fbe 100644
--- a/docs/installation/install-with-helm.md
+++ b/docs/installation/install-with-helm.md
@@ -133,7 +133,7 @@ You can specify a particular Baserow version by updating your `config.yaml`:
```yaml
global:
baserow:
- image: 2.2.1
+ image: 2.2.2
```
Or specify the chart version directly:
diff --git a/docs/installation/install-with-k8s.md b/docs/installation/install-with-k8s.md
index 86ea504295..7d9fda6f03 100644
--- a/docs/installation/install-with-k8s.md
+++ b/docs/installation/install-with-k8s.md
@@ -167,7 +167,7 @@ spec:
topologyKey: "kubernetes.io/hostname"
containers:
- name: backend-asgi
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
workingDir: /baserow
args:
- "gunicorn"
@@ -224,7 +224,7 @@ spec:
topologyKey: "kubernetes.io/hostname"
containers:
- name: backend-wsgi
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
workingDir: /baserow
args:
- "gunicorn-wsgi"
@@ -283,7 +283,7 @@ spec:
topologyKey: "kubernetes.io/hostname"
containers:
- name: backend-worker
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
args:
- "celery-worker"
imagePullPolicy: Always
@@ -300,7 +300,7 @@ spec:
- secretRef:
name: YOUR_ENV_SECRET_REF
- name: backend-export-worker
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
args:
- "celery-exportworker"
imagePullPolicy: Always
@@ -317,7 +317,7 @@ spec:
- secretRef:
name: YOUR_ENV_SECRET_REF
- name: backend-beat-worker
- image: baserow/backend:2.2.1
+ image: baserow/backend:2.2.2
args:
- "celery-beat"
imagePullPolicy: Always
@@ -358,7 +358,7 @@ spec:
topologyKey: "kubernetes.io/hostname"
containers:
- name: web-frontend
- image: baserow/web-frontend:2.2.1
+ image: baserow/web-frontend:2.2.2
args:
- nuxt
ports:
diff --git a/docs/installation/install-with-traefik.md b/docs/installation/install-with-traefik.md
index 7d36596c47..0e8b27f8cf 100644
--- a/docs/installation/install-with-traefik.md
+++ b/docs/installation/install-with-traefik.md
@@ -10,7 +10,7 @@ See below for an example docker-compose file that will enable Baserow with Traef
```
services:
baserow:
- image: baserow/baserow:2.2.1
+ image: baserow/baserow:2.2.2
container_name: baserow
labels:
# Explicitly tell Traefik to expose this container
diff --git a/docs/installation/supported.md b/docs/installation/supported.md
index aa03b54865..356da34617 100644
--- a/docs/installation/supported.md
+++ b/docs/installation/supported.md
@@ -8,7 +8,7 @@ Software versions are divided into the following groups:
before the release.
* `Recommended`: Recommended software for the best experience.
-## Baserow 2.2.1
+## Baserow 2.2.2
| Dependency | Supported versions | Tested versions | Recommended versions |
diff --git a/docs/plugins/creation.md b/docs/plugins/creation.md
index 628c34532c..643ff22be4 100644
--- a/docs/plugins/creation.md
+++ b/docs/plugins/creation.md
@@ -122,7 +122,7 @@ containing metadata about your plugin. It should have the following JSON structu
{
"name": "TODO",
"version": "TODO",
- "supported_baserow_versions": "2.2.1",
+ "supported_baserow_versions": "2.2.2",
"plugin_api_version": "0.0.1-alpha",
"description": "TODO",
"author": "TODO",
diff --git a/docs/plugins/installation.md b/docs/plugins/installation.md
index e17c58d831..1dbd9a6da2 100644
--- a/docs/plugins/installation.md
+++ b/docs/plugins/installation.md
@@ -36,7 +36,7 @@ build your own image based off the Baserow all-in-one image.
4. Next copy the contents shown into your `Dockerfile`
```dockerfile
-FROM baserow/baserow:2.2.1
+FROM baserow/baserow:2.2.2
# You can install a plugin found in a git repo:
RUN /baserow/plugins/install_plugin.sh \
@@ -70,9 +70,9 @@ RUN /baserow/plugins/install_plugin.sh \
5. Choose which of the `RUN` commands you'd like to use to install your plugins and
delete the rest, replace the example URLs with ones pointing to your plugin.
6. Now build your custom Baserow with the plugin installed by running:
- `docker build -t my-customized-baserow:2.2.1 .`
+ `docker build -t my-customized-baserow:2.2.2 .`
7. Finally, you can run your new customized image just like the normal Baserow image:
- `docker run -p 80:80 -v baserow_data:/baserow/data my-customized-baserow:2.2.1`
+ `docker run -p 80:80 -v baserow_data:/baserow/data my-customized-baserow:2.2.2`
### Installing in an existing Baserow all-in-one container
@@ -111,7 +111,7 @@ docker run \
-v baserow_data:/baserow/data \
# ... All your normal launch args go here
-e BASEROW_PLUGIN_GIT_REPOS=https://example.com/example/plugin1.git,https://example.com/example/plugin2.git
- baserow:2.2.1
+ baserow:2.2.2
```
These variables will only trigger and installation when found on startup of the
@@ -120,7 +120,7 @@ container. To uninstall a plugin you must still manually follow the instructions
### Caveats when installing into an existing container
If you ever delete the container you've installed plugins into at runtime and re-create
-it, the new container is created from the `baserow/baserow:2.2.1` image which does not
+it, the new container is created from the `baserow/baserow:2.2.2` image which does not
have any plugins installed.
However, when a plugin is installed at runtime or build time it is stored in the
@@ -135,7 +135,7 @@ scratch.
### Installing into standalone Baserow service images
-Baserow also provides `baserow/backend:2.2.1` and `baserow/web-frontend:2.2.1` images
+Baserow also provides `baserow/backend:2.2.2` and `baserow/web-frontend:2.2.2` images
which only run the respective backend/celery/web-frontend services. These images are
used for more advanced self-hosted deployments like a multi-service docker-compose, k8s
etc.
@@ -145,8 +145,8 @@ used with docker run and a specified command and the plugin env vars shown above
example:
```
-docker run --rm baserow/backend:2.2.1 install-plugin ...
-docker run -e BASEROW_PLUGIN_GIT_REPOS=https://example.com/example/plugin1.git,https://example.com/example/plugin2.git --rm baserow/backend:2.2.1
+docker run --rm baserow/backend:2.2.2 install-plugin ...
+docker run -e BASEROW_PLUGIN_GIT_REPOS=https://example.com/example/plugin1.git,https://example.com/example/plugin2.git --rm baserow/backend:2.2.2
```
You can use these scripts exactly as you would in the sections above to install a plugin
@@ -169,13 +169,13 @@ associated data permanently.
[Docker install guide backup section](../installation/install-with-docker.md)
for more details on how to do this.
2. Stop your Baserow server first - `docker stop baserow`
-3. `docker run --rm -v baserow_data:/baserow/data baserow:2.2.1 uninstall-plugin plugin_name`
+3. `docker run --rm -v baserow_data:/baserow/data baserow:2.2.2 uninstall-plugin plugin_name`
4. Now the plugin has uninstalled itself and all associated data has been removed.
5. Edit your custom `Dockerfile` and remove the plugin.
-6. Rebuild your image - `docker build -t my-customized-baserow:2.2.1 .`
+6. Rebuild your image - `docker build -t my-customized-baserow:2.2.2 .`
7. Remove the old container using the old image - `docker rm baserow`
8. Run your new image with the plugin removed
- - `docker run -p 80:80 -v baserow_data:/baserow/data my-customized-baserow:2.2.1`
+ - `docker run -p 80:80 -v baserow_data:/baserow/data my-customized-baserow:2.2.2`
9. If you fail to do this if you ever recreate the container, your custom image still
has the plugin installed and the new container will start up again with the plugin
re-installed.
@@ -207,7 +207,7 @@ associated data permanently.
restart as the environment variable will still contain the old plugin. To do this you
must:
1. `docker stop baserow`
- 2. `docker run --rm -v baserow_data:/baserow/data baserow:2.2.1 uninstall-plugin plugin_name`
+ 2. `docker run --rm -v baserow_data:/baserow/data baserow:2.2.2 uninstall-plugin plugin_name`
3. Now the plugin has uninstalled itself and all associated data has been removed.
4. Finally, recreate your Baserow container by using the same `docker run` command
you launched it with, just make sure the plugin you uninstalled has been removed
@@ -222,7 +222,7 @@ check what plugins are currently installed.
docker run \
--rm \
-v baserow_data:/baserow/data \
- baserow:2.2.1 list-plugins
+ baserow:2.2.2 list-plugins
# or on a running container
diff --git a/e2e-tests/fixtures/e2e-db.dump b/e2e-tests/fixtures/e2e-db.dump
index 1af0563b1a..5a05f18fc4 100644
Binary files a/e2e-tests/fixtures/e2e-db.dump and b/e2e-tests/fixtures/e2e-db.dump differ
diff --git a/enterprise/backend/pyproject.toml b/enterprise/backend/pyproject.toml
index 44edc0c496..abe311da25 100644
--- a/enterprise/backend/pyproject.toml
+++ b/enterprise/backend/pyproject.toml
@@ -12,7 +12,7 @@ description = """Baserow is an open source no-code database tool and Airtable \
# mixed license
license = { file = "../LICENSE" }
requires-python = "==3.14.*"
-version = "2.2.1"
+version = "2.2.2"
classifiers = []
[project.urls]
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/agents.py b/enterprise/backend/src/baserow_enterprise/assistant/agents.py
index 1af2110615..4d49386d1b 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/agents.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/agents.py
@@ -5,6 +5,17 @@
from baserow_enterprise.assistant.prompts import AGENT_SYSTEM_PROMPT
from baserow_enterprise.assistant.tools.toolset import tool_manifest_line_compact
+FREE_LICENSE_TIER = "free"
+_CANONICAL_LICENSE_TIERS = {
+ FREE_LICENSE_TIER,
+ "premium",
+ "advanced",
+ "enterprise",
+}
+_LICENSE_TIER_ALIASES = {
+ "enterprise_without_support": "enterprise",
+}
+
main_agent: Agent[AssistantDeps, str] = Agent(
deps_type=AssistantDeps,
output_type=str,
@@ -14,6 +25,17 @@
)
+def _canonical_license_tier(license_tier: str) -> str:
+ """
+ Return the public license tier token that is safe to inject into the prompt.
+ """
+
+ normalized_tier = _LICENSE_TIER_ALIASES.get(license_tier, license_tier)
+ if normalized_tier in _CANONICAL_LICENSE_TIERS:
+ return normalized_tier
+ return FREE_LICENSE_TIER
+
+
@main_agent.instructions
def dynamic_ui_context(ctx) -> str:
"""Inject the UI context into the system prompt dynamically."""
@@ -31,6 +53,20 @@ def dynamic_mode(ctx) -> str:
return f"\n{ctx.deps.mode.value}"
+@main_agent.instructions
+def dynamic_license_tier(ctx) -> str:
+ """Inject the active workspace license tier and its paid features."""
+
+ lt = ctx.deps.license_tier
+ if lt is None:
+ return f"\n{FREE_LICENSE_TIER}"
+ features = ",".join(sorted(lt.features))
+ return (
+ f"\n{_canonical_license_tier(lt.type)}"
+ f"\n{features}"
+ )
+
+
@main_agent.instructions
def dynamic_current_task(ctx) -> str:
"""Pin the original user request as immutable context."""
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
index f5fbb5a423..9ec91bb701 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/assistant.py
@@ -1,6 +1,7 @@
import asyncio
from typing import Any, AsyncGenerator
+from django.contrib.auth.models import AbstractUser
from django.core.cache import cache
from django.utils import translation
@@ -22,6 +23,7 @@
from pydantic_ai.usage import UsageLimits
from baserow.api.sessions import get_client_undo_redo_action_group_id
+from baserow.core.models import Workspace
from baserow_enterprise.assistant.agents import main_agent, title_agent
from baserow_enterprise.assistant.deps import (
AgentMode,
@@ -46,6 +48,8 @@
)
from baserow_enterprise.assistant.tools.navigation.utils import unsafe_navigate_to
from baserow_enterprise.assistant.tools.registries import assistant_tool_registry
+from baserow_premium.api.user.user_data_types import ActiveLicensesDataType
+from baserow_premium.license.registries import LicenseType, license_type_registry
from .models import AssistantChat, AssistantChatMessage, AssistantChatPrediction
from .types import (
@@ -101,6 +105,37 @@ def set_assistant_cancellation_key(
cache.set(get_assistant_cancellation_key(chat_uuid), True, timeout=timeout)
+def _get_workspace_license_type(
+ user: AbstractUser, workspace: Workspace
+) -> LicenseType | None:
+ """
+ Pick the highest-``order`` ``LicenseType`` active for the user in the workspace,
+ reusing the same data the frontend consumes from ``ActiveLicensesDataType``. Returns
+ ``None`` when no license applies.
+
+ :param user: The user for whom to get the license type.
+ :param workspace: The workspace for which to get the license type.
+ :return: The active LicenseType with the highest order, or None if no license is
+ active.
+ """
+
+ try:
+ active = ActiveLicensesDataType().get_user_data(user, None)
+ names = set(active["instance_wide"]) | set(
+ active["per_workspace"].get(workspace.id, {})
+ )
+ return max(
+ (lt for lt in license_type_registry.get_all() if lt.type in names),
+ key=lambda lt: lt.order,
+ default=None,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to determine workspace license type for assistant context."
+ )
+ return None
+
+
def _extract_tool_thought(event: FunctionToolCallEvent) -> str | None:
"""Extract the chain-of-thought ``thought`` argument from a tool call
event, if present and non-empty."""
@@ -134,6 +169,7 @@ def __init__(self, chat: AssistantChat):
user=self._user,
workspace=self._workspace,
tool_helpers=self._tool_helpers,
+ license_tier=_get_workspace_license_type(self._user, self._workspace),
)
self._toolset, db_m, app_m, auto_m, explain_m = (
assistant_tool_registry.build_toolset(
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/deps.py b/enterprise/backend/src/baserow_enterprise/assistant/deps.py
index f4bb95db97..8f8223a6b6 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/deps.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/deps.py
@@ -13,6 +13,7 @@
from baserow_enterprise.assistant.tools.navigation.types import (
AnyNavigationRequestType,
)
+ from baserow_premium.license.registries import LicenseType
class AgentMode(str, Enum):
@@ -120,6 +121,7 @@ class AssistantDeps:
workspace: "Workspace"
tool_helpers: ToolHelpers
mode: AgentMode = AgentMode.DATABASE
+ license_tier: "LicenseType | None" = None
sources: list[str] = field(default_factory=list)
dynamic_tools: list[Tool] = field(default_factory=list)
database_manifest: str = ""
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
index da98c4816b..31b81e057b 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/prompts.py
@@ -41,6 +41,15 @@
"""
+GROUNDING = """\
+
+If you are not sure whether a Baserow feature, plan, limit, setting, or UI behavior exists, do not guess. Use `search_user_docs` first.
+If the docs do not confirm it, say you don't know. Never invent plan names, feature names, pricing, upgrade advice, or UI paths.
+The canonical plan names are Free, Premium, Advanced, and Enterprise. `` uses the lowercase equivalents (`free`, `premium`, `advanced`, `enterprise`); treat them as exact matches.
+`` is the exhaustive list of paid feature flags the current workspace has. Never claim a feature is available if it is not in ``. Use `search_user_docs` to explain what each feature does.
+
+"""
+
LIMITATIONS_AND_SOURCES = f"""\
Cannot create/modify/delete: user accounts, workspaces, dashboards, widgets, snapshots, webhooks, integrations, roles, permissions.
@@ -53,5 +62,6 @@
+ RULES
+ HANDLING_AMBIGUITY
+ BASEROW_KNOWLEDGE
+ + GROUNDING
+ LIMITATIONS_AND_SOURCES
)
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/telemetry.py b/enterprise/backend/src/baserow_enterprise/assistant/telemetry.py
index f1242c39a9..609b78fb7f 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/telemetry.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/telemetry.py
@@ -36,6 +36,7 @@
from uuid import uuid4
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
+from opentelemetry.sdk.trace.sampling import ALWAYS_ON
from opentelemetry.trace import SpanKind
from baserow.core.posthog import get_posthog_client
@@ -461,7 +462,8 @@ def setup_instrumentation():
from pydantic_ai import Agent, InstrumentationSettings
- tracer_provider = TracerProvider()
+ # Prevent environment OTEL_TRACES_SAMPLER config from dropping assistant traces.
+ tracer_provider = TracerProvider(sampler=ALWAYS_ON)
tracer_provider.add_span_processor(PosthogSpanProcessor())
Agent.instrument_all(
diff --git a/enterprise/backend/src/baserow_enterprise/assistant/tools/search_user_docs/tools.py b/enterprise/backend/src/baserow_enterprise/assistant/tools/search_user_docs/tools.py
index 79d360ff52..f4468d79cd 100644
--- a/enterprise/backend/src/baserow_enterprise/assistant/tools/search_user_docs/tools.py
+++ b/enterprise/backend/src/baserow_enterprise/assistant/tools/search_user_docs/tools.py
@@ -170,6 +170,9 @@ async def _search_user_docs_impl(
) -> dict[str, Any]:
"""Inner implementation of search_user_docs, separated for error handling."""
+ from baserow_enterprise.assistant.model_profiles import get_model_string
+ from baserow_enterprise.assistant.retrying_model import _resolve_model
+
@sync_to_async
def _search(question: str) -> list[KnowledgeBaseChunk]:
chunks = KnowledgeBaseHandler().search(question, 15)
@@ -198,41 +201,35 @@ def _search(question: str) -> list[KnowledgeBaseChunk]:
f"Question: {question}\n\n"
f"Documentation context (source URL -> content):\n{context}"
)
- from baserow_enterprise.assistant.model_profiles import get_model_string
- from baserow_enterprise.assistant.retrying_model import _resolve_model
agent_result = await search_docs_agent.run(
prompt, model=_resolve_model(get_model_string())
)
prediction = agent_result.output
+ # Force reliability to 0 if model says nothing was found.
+ nothing_found = "nothing found" in prediction.answer.lower()
+ reliability = 0.0 if nothing_found else prediction.reliability
+
sources = []
available_urls = {chunk.source_document.source_url for chunk in relevant_chunks}
- for url in prediction.sources:
- # somehow LLMs sometimes return sources as objects
- if isinstance(url, dict) and "url" in url:
- url = url["url"]
-
- if not isinstance(url, str):
- continue
-
- if url in available_urls and url not in sources:
- sources.append(url)
- if len(sources) >= 3:
- break
-
- # Only fallback to available URLs if reliability is high AND we have a
- # real answer. Don't populate sources if the model indicated no relevant
- # docs were found.
- nothing_found = "nothing found" in prediction.answer.lower()
- if not sources and prediction.reliability > 0.8 and not nothing_found:
- sources = list(available_urls)[:3]
+ if not nothing_found:
+ for url in prediction.sources:
+ # somehow LLMs sometimes return sources as objects
+ if isinstance(url, dict) and "url" in url:
+ url = url["url"]
- # Override reliability to 0 if the model explicitly said nothing was
- # found. The model sometimes returns high reliability for "nothing
- # found" answers, which is semantically incorrect - we want reliability
- # to reflect whether we actually found useful information.
- reliability = 0.0 if nothing_found else prediction.reliability
+ if not isinstance(url, str):
+ continue
+
+ if url in available_urls and url not in sources:
+ sources.append(url)
+ if len(sources) >= 3:
+ break
+
+ # Fallback to available URLs if the model didn't cite sources.
+ if not sources:
+ sources = list(available_urls)[:3]
if reliability >= 0.7:
reliability_note = (
@@ -242,7 +239,8 @@ def _search(question: str) -> list[KnowledgeBaseChunk]:
reliability_note = (
"PARTIAL MATCH: Some relevant information was found, but the "
"documentation may not fully cover this topic. Supplement with "
- "general knowledge but warn the user that details may be incomplete."
+ "general knowledge if you're confident it is accurate and up to date, "
+ "but warn the user that details may be incomplete."
)
else:
reliability_note = (
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/eval_utils.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/eval_utils.py
index e4d9963fca..125b58e523 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/eval_utils.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/eval_utils.py
@@ -15,6 +15,7 @@
from pydantic_ai.usage import UsageLimits
from baserow_enterprise.assistant.agents import main_agent
+from baserow_enterprise.assistant.assistant import _get_workspace_license_type
from baserow_enterprise.assistant.deps import AssistantDeps, ToolHelpers
from baserow_enterprise.assistant.tools.registries import assistant_tool_registry
from baserow_enterprise.assistant.types import (
@@ -209,6 +210,7 @@ def create_eval_assistant(user, workspace, max_iters=15, model=None):
user=user,
workspace=workspace,
tool_helpers=tool_helpers,
+ license_tier=_get_workspace_license_type(user, workspace),
)
# Build the single-agent toolset (navigation + core + database + automation)
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_search_user_docs.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_search_user_docs.py
index b3b7f7831e..ce44cb4637 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_search_user_docs.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/evals/test_eval_search_user_docs.py
@@ -111,6 +111,21 @@ def _require_knowledge_base(synced_knowledge_base):
["permission", "field", "read", "lock"],
id="field-permissions",
),
+ pytest.param(
+ "Which Baserow plan unlocks field-level permissions for a workspace?",
+ ["field-level-permissions", "permissions"],
+ ["plan", "field-level permissions", "field permissions", "enterprise"],
+ id="plan-for-field-level-permissions",
+ ),
+ pytest.param(
+ (
+ "I can't find the conditional options toggle for my single select field. "
+ "Should I upgrade, or is there another requirement?"
+ ),
+ ["single-select", "select-option", "fields"],
+ ["conditional", "single select", "plan", "upgrade"],
+ id="conditional-options-plan-question",
+ ),
pytest.param(
(
"How can I create a calendar that shows my tasks, but only the ones assigned to me."
@@ -121,9 +136,8 @@ def _require_knowledge_base(synced_knowledge_base):
),
pytest.param(
(
- "I'm trying to combine the first name and last name columns "
- "into one, but I want to make sure it's uppercase. Can you tell me how to "
- "write that formula?"
+ "What would a formula look like that combines a first name and last name field "
+ "into a full name field?"
),
["formula", "understanding-formulas"],
["concat", "upper", "formula"],
@@ -270,7 +284,7 @@ def test_search_user_docs(
hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}",
)
checks.check(
- f"returned at least one source URL for user docs",
+ "returned at least one source URL for user docs",
len(sources) >= 1,
hint=f"tools called: {[e.get('tool_name') for e in history if e.get('tool_name')]}",
)
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant.py
index 62e5e77d54..06a39393db 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant.py
@@ -7,13 +7,16 @@
from pydantic_ai.messages import PartStartEvent
from pydantic_ai.messages import TextPart as PaiTextPart
+from baserow_enterprise.assistant.agents import dynamic_license_tier
from baserow_enterprise.assistant.assistant import (
Assistant,
+ _get_workspace_license_type,
compact_message_history,
get_model_string,
)
from baserow_enterprise.assistant.deps import AssistantDeps
from baserow_enterprise.assistant.models import AssistantChat, AssistantChatMessage
+from baserow_enterprise.assistant.prompts import AGENT_SYSTEM_PROMPT
from baserow_enterprise.assistant.types import (
AiMessage,
AiMessageChunk,
@@ -308,6 +311,125 @@ def test_preserves_simple_conversations(self):
assert len(compacted) == 2
+@pytest.mark.django_db
+class TestAssistantLicenseTier:
+ @patch("baserow_enterprise.assistant.assistant._get_workspace_license_type")
+ def test_assistant_initializes_license_tier(
+ self, mock__get_workspace_license_type, enterprise_data_fixture
+ ):
+ user = enterprise_data_fixture.create_user()
+ workspace = enterprise_data_fixture.create_workspace(user=user)
+ chat = AssistantChat.objects.create(
+ user=user, workspace=workspace, title="Test Chat"
+ )
+ license_type = MagicMock(type="premium", features=["premium"])
+ mock__get_workspace_license_type.return_value = license_type
+
+ assistant = Assistant(chat)
+
+ mock__get_workspace_license_type.assert_called_once_with(user, workspace)
+ assert assistant._deps.license_tier is license_type
+
+ def test_dynamic_license_tier_injects_type_and_features(self):
+ ctx = MagicMock()
+ ctx.deps.license_tier = MagicMock(type="advanced", features=["sso", "rbac"])
+
+ assert dynamic_license_tier(ctx) == (
+ "\nadvanced\nrbac,sso"
+ )
+
+ def test_dynamic_license_tier_normalizes_internal_enterprise_type(self):
+ ctx = MagicMock()
+ ctx.deps.license_tier = MagicMock(
+ type="enterprise_without_support", features=["sso", "rbac"]
+ )
+
+ assert dynamic_license_tier(ctx) == (
+ "\nenterprise\nrbac,sso"
+ )
+
+ def test_dynamic_license_tier_renders_free_for_unknown_type(self):
+ ctx = MagicMock()
+ ctx.deps.license_tier = MagicMock(type="unknown", features=["sso", "rbac"])
+
+ assert dynamic_license_tier(ctx) == (
+ "\nfree\nrbac,sso"
+ )
+
+ def test_dynamic_license_tier_renders_free_when_no_license(self):
+ ctx = MagicMock()
+ ctx.deps.license_tier = None
+
+ assert dynamic_license_tier(ctx) == "\nfree"
+
+ def test_agent_system_prompt_includes_grounding_guardrail(self):
+ assert "Use `search_user_docs` first" in AGENT_SYSTEM_PROMPT
+ assert "Never invent plan names" in AGENT_SYSTEM_PROMPT
+
+
+@pytest.mark.django_db
+class TestGetWorkspaceLicenseType:
+ _PATCH_PATH = (
+ "baserow_enterprise.assistant.assistant.ActiveLicensesDataType.get_user_data"
+ )
+
+ def _call(self, data, workspace_id=1):
+ with patch(self._PATCH_PATH, return_value=data):
+ return _get_workspace_license_type(MagicMock(), MagicMock(id=workspace_id))
+
+ def test_returns_none_without_active_licenses(self):
+ assert self._call({"instance_wide": {}, "per_workspace": {}}) is None
+
+ def test_returns_instance_wide_license(self):
+ result = self._call({"instance_wide": {"premium": True}, "per_workspace": {}})
+ assert result is not None
+ assert result.type == "premium"
+
+ def test_returns_per_workspace_license(self):
+ result = self._call(
+ {"instance_wide": {}, "per_workspace": {42: {"advanced": True}}},
+ workspace_id=42,
+ )
+ assert result is not None
+ assert result.type == "advanced"
+
+ def test_ignores_licenses_from_other_workspaces(self):
+ assert (
+ self._call(
+ {"instance_wide": {}, "per_workspace": {99: {"advanced": True}}},
+ workspace_id=42,
+ )
+ is None
+ )
+
+ def test_picks_highest_order_from_combined_set(self):
+ result = self._call(
+ {
+ "instance_wide": {"premium": True},
+ "per_workspace": {1: {"advanced": True}},
+ }
+ )
+ assert result is not None
+ # PremiumLicenseType.order=10, AdvancedLicenseType.order=75
+ assert result.type == "advanced"
+
+ def test_skips_license_names_not_in_registry(self):
+ result = self._call(
+ {
+ "instance_wide": {"bogus_tier": True, "premium": True},
+ "per_workspace": {},
+ }
+ )
+ assert result is not None
+ assert result.type == "premium"
+
+ def test_returns_none_when_only_unknown_names(self):
+ assert (
+ self._call({"instance_wide": {"bogus_tier": True}, "per_workspace": {}})
+ is None
+ )
+
+
@pytest.mark.django_db
class TestAssistantMessagePersistence:
"""Test that messages are persisted correctly during streaming."""
diff --git a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_search_docs_tools.py b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_search_docs_tools.py
index dcc77492f9..a9d5b9b6fa 100644
--- a/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_search_docs_tools.py
+++ b/enterprise/backend/tests/baserow_enterprise_tests/assistant/test_assistant_search_docs_tools.py
@@ -1,10 +1,11 @@
import os
-from unittest.mock import patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from baserow_enterprise.assistant.tools.search_user_docs.tools import (
_TOOL_QUERY_RE,
+ SearchDocsResult,
search_user_docs,
)
@@ -84,6 +85,52 @@ async def test_search_user_docs_handles_empty_results(data_fixture):
assert "Nothing found" in result["answer"]
+@pytest.mark.django_db
+@pytest.mark.asyncio
+async def test_search_user_docs_does_not_add_sources_for_nothing_found_prediction(
+ data_fixture,
+):
+ user = data_fixture.create_user()
+ workspace = data_fixture.create_workspace(user=user)
+ ctx = make_test_ctx(user, workspace)
+ chunk = MagicMock(content="Some unrelated documentation.")
+ chunk.source_document = MagicMock(source_url="https://example.com/docs")
+
+ with (
+ patch(
+ "baserow_enterprise.assistant.tools.search_user_docs.tools.KnowledgeBaseHandler"
+ ) as mock_handler_cls,
+ patch(
+ "baserow_enterprise.assistant.tools.search_user_docs.tools.search_docs_agent.run",
+ new_callable=AsyncMock,
+ ) as mock_run,
+ patch(
+ "baserow_enterprise.assistant.model_profiles.get_model_string",
+ return_value="test/model",
+ ),
+ patch(
+ "baserow_enterprise.assistant.retrying_model._resolve_model",
+ return_value=MagicMock(),
+ ),
+ ):
+ mock_handler_cls.return_value.search.return_value = [chunk]
+ mock_run.return_value = MagicMock(
+ output=SearchDocsResult(
+ answer="Nothing found in the documentation.",
+ reliability=1.0,
+ sources=["https://example.com/docs"],
+ )
+ )
+
+ result = await search_user_docs(
+ ctx, question="Does Baserow support imaginary widgets?", thought="user asks"
+ )
+
+ assert result["reliability"] == 0.0
+ assert result["sources"] == []
+ assert ctx.deps.sources == []
+
+
@pytest.mark.django_db
@pytest.mark.asyncio
async def test_search_user_docs_handles_error(data_fixture):
diff --git a/heroku.Dockerfile b/heroku.Dockerfile
index 6b334692cf..f5d38bff90 100644
--- a/heroku.Dockerfile
+++ b/heroku.Dockerfile
@@ -1,4 +1,4 @@
-ARG FROM_IMAGE=baserow/baserow:2.2.1
+ARG FROM_IMAGE=baserow/baserow:2.2.2
# This is pinned as version pinning is done by the CI setting FROM_IMAGE.
# hadolint ignore=DL3006
FROM $FROM_IMAGE AS image_base
diff --git a/premium/backend/pyproject.toml b/premium/backend/pyproject.toml
index cb82b2b1f2..30dea3795a 100644
--- a/premium/backend/pyproject.toml
+++ b/premium/backend/pyproject.toml
@@ -12,7 +12,7 @@ description = """Baserow is an open source no-code database tool and Airtable \
# mixed license
license = { file = "../LICENSE" }
requires-python = "==3.14.*"
-version = "2.2.1"
+version = "2.2.2"
classifiers = []
[project.urls]
diff --git a/web-frontend/docker/docker-entrypoint.sh b/web-frontend/docker/docker-entrypoint.sh
index 39eb8e7ec2..b05cba1bd3 100755
--- a/web-frontend/docker/docker-entrypoint.sh
+++ b/web-frontend/docker/docker-entrypoint.sh
@@ -2,7 +2,7 @@
# Bash strict mode: http://redsymbol.net/articles/unofficial-bash-strict-mode/
set -euo pipefail
-export BASEROW_VERSION="2.2.1"
+export BASEROW_VERSION="2.2.2"
BASEROW_WEBFRONTEND_PORT="${BASEROW_WEBFRONTEND_PORT:-3000}"
show_help() {
diff --git a/web-frontend/package.json b/web-frontend/package.json
index 2ab87e7551..b8563286a4 100644
--- a/web-frontend/package.json
+++ b/web-frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "baserow",
- "version": "2.2.1",
+ "version": "2.2.2",
"private": true,
"type": "module",
"scripts": {