From 13b04ca3788012e9a95830094ccfa43732c1c460 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 10:42:57 +0200 Subject: [PATCH 01/37] Add the public channel platform behind flag_public_channel --- api-schemas/export.yml | 2 ++ api-schemas/v1.yml | 2 ++ api-schemas/v2.yml | 4 +++ .../0034_alter_experimentchannel_platform.py | 35 ++++++++++++++++++ apps/channels/models.py | 16 +++++++++ apps/channels/registry.py | 1 + apps/channels/tests/test_models.py | 36 +++++++++++++++++++ apps/teams/flags.py | 2 ++ 8 files changed, 98 insertions(+) create mode 100644 apps/channels/migrations/0034_alter_experimentchannel_platform.py diff --git a/api-schemas/export.yml b/api-schemas/export.yml index 396d388b46..51e3646e34 100644 --- a/api-schemas/export.yml +++ b/api-schemas/export.yml @@ -5027,6 +5027,7 @@ components: - evaluations - embedded_widget - email + - public type: string description: |- * `telegram` - Telegram @@ -5040,6 +5041,7 @@ components: * `evaluations` - Evaluations * `embedded_widget` - Chat Widget & API * `email` - Email + * `public` - Public link PricingRuleDetail: type: object properties: diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index 7bb670e398..d1417ef1ec 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -1812,6 +1812,7 @@ components: - evaluations - embedded_widget - email + - public type: string description: |- * `telegram` - Telegram @@ -1825,6 +1826,7 @@ components: * `evaluations` - Evaluations * `embedded_widget` - Chat Widget & API * `email` - Email + * `public` - Public link SessionModelUsage: type: object properties: diff --git a/api-schemas/v2.yml b/api-schemas/v2.yml index 76c8ac6889..eedcb5a562 100644 --- a/api-schemas/v2.yml +++ b/api-schemas/v2.yml @@ -675,6 +675,7 @@ paths: - commcare_connect - embedded_widget - email + - public type: string minLength: 1 description: |- @@ -690,6 +691,7 @@ paths: * `commcare_connect` - commcare_connect * `embedded_widget` - embedded_widget * `email` - email + * `public` - public - in: query name: start schema: @@ -2406,6 +2408,7 @@ components: - evaluations - embedded_widget - email + - public type: string description: |- * `telegram` - Telegram @@ -2419,6 +2422,7 @@ components: * `evaluations` - Evaluations * `embedded_widget` - Chat Widget & API * `email` - Email + * `public` - Public link PromptVariable: type: object description: A template variable rather than a resource id -- there is no `value` diff --git a/apps/channels/migrations/0034_alter_experimentchannel_platform.py b/apps/channels/migrations/0034_alter_experimentchannel_platform.py new file mode 100644 index 0000000000..febe5fff1e --- /dev/null +++ b/apps/channels/migrations/0034_alter_experimentchannel_platform.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.16 on 2026-08-26 08:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bot_channels", "0033_experimentchannel_credential_mode_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="experimentchannel", + name="platform", + field=models.CharField( + choices=[ + ("telegram", "Telegram"), + ("web", "Web"), + ("whatsapp", "WhatsApp"), + ("facebook", "Facebook"), + ("sureadhere", "SureAdhere"), + ("api", "API"), + ("slack", "Slack"), + ("commcare_connect", "CommCare Connect"), + ("evaluations", "Evaluations"), + ("embedded_widget", "Chat Widget & API"), + ("email", "Email"), + ("public", "Public link"), + ], + default="telegram", + max_length=32, + ), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 0843834b26..b0a65b89bb 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -40,12 +40,18 @@ class ChannelPlatform(models.TextChoices): EVALUATIONS = "evaluations", "Evaluations" EMBEDDED_WIDGET = "embedded_widget", "Chat Widget & API" EMAIL = "email", "Email" + PUBLIC = "public", "Public link" @classmethod def team_global_platforms(cls): """These platforms should only ever have one channel per team""" return [cls.API, cls.WEB, cls.EVALUATIONS] + @classmethod + def widget_platforms(cls) -> list["ChannelPlatform"]: + """Platforms the chat widget serves through the Chat API with an embed key.""" + return [cls.EMBEDDED_WIDGET, cls.PUBLIC] + @classmethod def for_dropdown(cls, used_platforms, team) -> dict[Self, bool]: """Returns a dictionary of available platforms for this team. Available platforms will have a `True` value""" @@ -79,6 +85,12 @@ def for_dropdown(cls, used_platforms, team) -> dict[Self, bool]: else: platform_availability[cls.EMAIL] = True + flag = Flag.get("flag_public_channel") + if flag.is_active_for_team(team): + platform_availability[cls.PUBLIC] = True + else: + platform_availability.pop(cls.PUBLIC, None) + # Platforms already used should not be displayed for platform in used_platforms: platform_availability.pop(platform) @@ -110,6 +122,8 @@ def extra_form(self, **kwargs): return forms.EmbeddedWidgetChannelForm(**kwargs) case self.EMAIL: return forms.EmailChannelForm(**kwargs) + case self.PUBLIC: + return forms.PublicChannelForm(**kwargs) return None @property @@ -134,6 +148,8 @@ def channel_identifier_key(self) -> str | None: return "widget_token" case self.EMAIL: return "email_address" + case self.PUBLIC: + return "widget_token" return None @staticmethod diff --git a/apps/channels/registry.py b/apps/channels/registry.py index 3fd1665404..f7885ee128 100644 --- a/apps/channels/registry.py +++ b/apps/channels/registry.py @@ -28,6 +28,7 @@ ChannelPlatform.SLACK: SlackChannel, ChannelPlatform.COMMCARE_CONNECT: CommCareConnectChannel, ChannelPlatform.EMBEDDED_WIDGET: ApiChannel, + ChannelPlatform.PUBLIC: ApiChannel, ChannelPlatform.EMAIL: EmailChannel, } diff --git a/apps/channels/tests/test_models.py b/apps/channels/tests/test_models.py index 50f15756a8..38a52481d0 100644 --- a/apps/channels/tests/test_models.py +++ b/apps/channels/tests/test_models.py @@ -281,3 +281,39 @@ def test_webhook_url_for_telegram_channel(): assert str(channel.external_id) in url assert url.startswith("https://") + + +@pytest.mark.django_db() +class TestPublicChannelPlatform: + """PUBLIC is a widget platform, one per chatbot, offered only behind flag_public_channel.""" + + @pytest.fixture() + def public_flag_enabled(self, experiment): + flag = Flag.objects.create(name="flag_public_channel") + flag.teams.add(experiment.team) + flag.flush() + return flag + + def test_widget_platforms_are_the_two_widget_served_platforms(self): + assert ChannelPlatform.widget_platforms() == [ChannelPlatform.EMBEDDED_WIDGET, ChannelPlatform.PUBLIC] + + def test_public_hidden_when_flag_off(self, experiment): + platforms = ChannelPlatform.for_dropdown(used_platforms=set(), team=experiment.team) + assert ChannelPlatform.PUBLIC not in platforms + + def test_public_available_when_flag_on(self, experiment, public_flag_enabled): + platforms = ChannelPlatform.for_dropdown(used_platforms=set(), team=experiment.team) + assert platforms[ChannelPlatform.PUBLIC] is True + + def test_public_hidden_once_used(self, experiment, public_flag_enabled): + platforms = ChannelPlatform.for_dropdown(used_platforms={ChannelPlatform.PUBLIC}, team=experiment.team) + assert ChannelPlatform.PUBLIC not in platforms + + def test_public_identifier_key_is_the_widget_token(self): + assert ChannelPlatform.PUBLIC.channel_identifier_key == "widget_token" + + @pytest.mark.xfail(strict=True, reason="PublicChannelForm lands in task 9") + def test_public_extra_form_is_the_public_channel_form(self, experiment): + from apps.channels.forms import PublicChannelForm # noqa: PLC0415 # ty: ignore[unresolved-import] + + assert isinstance(ChannelPlatform.PUBLIC.extra_form(experiment=experiment), PublicChannelForm) diff --git a/apps/teams/flags.py b/apps/teams/flags.py index 8b46079a8a..8b9b580389 100644 --- a/apps/teams/flags.py +++ b/apps/teams/flags.py @@ -49,6 +49,8 @@ class Flags(FlagInfo, Enum): CHAT_WIDGET = ("flag_chat_widget", "Use the embedded chat widget on the full-page web chat (POC)") + PUBLIC_CHANNEL = ("flag_public_channel", "Public link channel served by the chat widget", "", [], False) + TESTING_CUSTOM_ACTIONS = ( "flag_custom_actions_test_endpoints", "Testing endpoints for custom actions (internal use only)", From bea119edbd2a150633534b2b046e73246fd0985f Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 10:47:58 +0200 Subject: [PATCH 02/37] Treat the public channel as a widget platform --- apps/api/tests/test_widget_auth_level.py | 8 ++++++++ apps/channels/models.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/api/tests/test_widget_auth_level.py b/apps/api/tests/test_widget_auth_level.py index 16f2da5614..3e2bdc89d9 100644 --- a/apps/api/tests/test_widget_auth_level.py +++ b/apps/api/tests/test_widget_auth_level.py @@ -259,3 +259,11 @@ def test_deleting_the_channel_revokes_a_riding_along_embed_key(api_client, exper ) def test_migration_level_for_version(widget_version, expected): assert _migration._level_for_version(widget_version) == expected + + +@pytest.mark.django_db() +def test_public_channel_carries_the_default_session_token_level(experiment): + channel = ExperimentChannelFactory.create( + experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": WIDGET_TOKEN} + ) + assert channel.widget_auth_level == WidgetAuthLevel.SESSION_TOKEN diff --git a/apps/channels/models.py b/apps/channels/models.py index b0a65b89bb..309674f4fb 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -361,18 +361,18 @@ def is_disabled(self) -> bool: @property def widget_update_status(self) -> widget_versions.WidgetUpdateStatus | None: - if self.platform_enum != ChannelPlatform.EMBEDDED_WIDGET: + if self.platform_enum not in ChannelPlatform.widget_platforms(): return None return widget_versions.get_widget_update_status(self.widget_version) @property def widget_auth_level(self) -> "WidgetAuthLevel | None": - """The required auth level for embedded widget channels, or None for other platforms. + """The required auth level for widget channels (embedded widget and public link), or None for other platforms. - `required_auth_level` is only meaningful for EMBEDDED_WIDGET channels; every other + `required_auth_level` is only meaningful for widget platforms; every other platform returns None so callers fall back to their non-widget behaviour. """ - if self.platform_enum != ChannelPlatform.EMBEDDED_WIDGET: + if self.platform_enum not in ChannelPlatform.widget_platforms(): return None return WidgetAuthLevel(self.required_auth_level) @@ -382,6 +382,8 @@ def min_widget_version(self) -> str | None: None for non-widget channels or a NONE-level widget channel (no floor). """ + if self.platform_enum not in ChannelPlatform.widget_platforms(): + return None level = self.widget_auth_level if level is None: return None @@ -390,6 +392,8 @@ def min_widget_version(self) -> str | None: @property def pending_min_widget_version(self) -> str | None: """Minimum widget version the pending auth level will require, if a bump is pending.""" + if self.platform_enum not in ChannelPlatform.widget_platforms(): + return None if self.pending_auth_level is None: return None return widget_versions.min_version_for_level(self.pending_auth_level) From 694c48d8777ee331d3a80ad31ae95b358c9d3311 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 10:55:23 +0200 Subject: [PATCH 03/37] Pin the public channel to the canonical host through one origin rule --- apps/api/authentication.py | 27 +++- apps/api/permissions.py | 12 +- apps/api/tests/test_public_channel_origin.py | 133 +++++++++++++++++++ apps/web/meta.py | 5 + 4 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 apps/api/tests/test_public_channel_origin.py diff --git a/apps/api/authentication.py b/apps/api/authentication.py index 8aa0dcba4b..e1b7e85935 100644 --- a/apps/api/authentication.py +++ b/apps/api/authentication.py @@ -10,6 +10,7 @@ from apps.experiments.models import Experiment from apps.oauth.permissions import validated_machine_token from apps.teams.utils import set_current_team +from apps.web.meta import canonical_hostname def chatbot_id_from_body(request) -> str | None: @@ -66,7 +67,7 @@ def authenticate(self, request): try: experiment_channel = ExperimentChannel.objects.select_related("experiment", "team").get( experiment__public_id=experiment_id, - platform=ChannelPlatform.EMBEDDED_WIDGET, + platform__in=ChannelPlatform.widget_platforms(), extra_data__widget_token=embed_key, deleted=False, ) @@ -101,6 +102,21 @@ def authenticate_header(self, request): return "X-Embed-Key" +def channel_origin_allowed(request, channel: ExperimentChannel) -> bool: + """Whether the request's Origin (or Referer) may use `channel`. + + An embedded widget lists the domains it may be embedded on. A public link runs on the OCS + host only, so its rule is the canonical Site hostname; hostname to hostname, so ports do + not matter. Same-origin GETs carry no Origin header, which is why Referer is the fallback. + """ + origin_domain = extract_domain_from_headers(request) + if not origin_domain: + return False + if channel.platform == ChannelPlatform.PUBLIC: + return origin_domain.lower() == canonical_hostname() + return validate_domain(origin_domain, channel.extra_data.get("allowed_domains", [])) + + def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) -> bool: """Whether this request's X-Embed-Key proves access to `channel`. @@ -117,7 +133,7 @@ def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) -> embed_key = request.headers.get("X-Embed-Key") if not embed_key or channel is None: return False - if channel.platform != ChannelPlatform.EMBEDDED_WIDGET: + if channel.platform not in ChannelPlatform.widget_platforms(): return False # Callers that reach a channel by FK traversal (`session.experiment_channel`) bypass the # default manager's `deleted=False`, so deleting a widget would otherwise not revoke its key. @@ -126,10 +142,7 @@ def embed_key_authorizes_channel(request, channel: ExperimentChannel | None) -> if embed_key != channel.extra_data.get("widget_token"): return False - origin_domain = extract_domain_from_headers(request) - if not origin_domain: - return False - return validate_domain(origin_domain, channel.extra_data.get("allowed_domains", [])) + return channel_origin_allowed(request, channel) def get_embed_key_channel(request, experiment) -> ExperimentChannel | None: @@ -145,7 +158,7 @@ def get_embed_key_channel(request, experiment) -> ExperimentChannel | None: ExperimentChannel.objects.select_related("experiment", "team") .filter( experiment=experiment, - platform=ChannelPlatform.EMBEDDED_WIDGET, + platform__in=ChannelPlatform.widget_platforms(), extra_data__widget_token=embed_key, ) .first() diff --git a/apps/api/permissions.py b/apps/api/permissions.py index 76b1477cb5..53043571b4 100644 --- a/apps/api/permissions.py +++ b/apps/api/permissions.py @@ -12,10 +12,10 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission, DjangoModelPermissions, IsAuthenticated from rest_framework_api_key.permissions import KeyParser -from apps.api.authentication import embed_key_authorizes_channel, oauth_resolved_channel +from apps.api.authentication import channel_origin_allowed, embed_key_authorizes_channel, oauth_resolved_channel from apps.api.session_tokens import session_token_expired, validate_session_token from apps.channels.models import ExperimentChannel, WidgetAuthLevel -from apps.channels.utils import extract_domain_from_headers, get_experiment_session_cached, validate_domain +from apps.channels.utils import get_experiment_session_cached from apps.oauth.permissions import is_client_credentials_request from apps.teams.helpers import get_team_membership_for_request, set_request_attrs from apps.teams.utils import set_current_team @@ -98,13 +98,7 @@ def has_permission(self, request, view): # ever runs. return True - origin_domain = extract_domain_from_headers(request) - if not origin_domain: - return False - - experiment_channel = request.auth - allowed_domains = experiment_channel.extra_data.get("allowed_domains", []) - return validate_domain(origin_domain, allowed_domains) + return channel_origin_allowed(request, request.auth) class SessionAccessPermission(BasePermission): diff --git a/apps/api/tests/test_public_channel_origin.py b/apps/api/tests/test_public_channel_origin.py new file mode 100644 index 0000000000..6693dcacce --- /dev/null +++ b/apps/api/tests/test_public_channel_origin.py @@ -0,0 +1,133 @@ +"""The public channel is pinned to the OCS canonical host (spec D3). + +Both origin call sites go through `channel_origin_allowed`: the permission class when the embed +key authenticated the request, and `embed_key_authorizes_channel` when a Django session cookie +authenticated first and the key merely rode along. +""" + +import pytest +from django.contrib.sites.models import Site +from django.test import RequestFactory +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.api.authentication import channel_origin_allowed, embed_key_authorizes_channel +from apps.channels.models import ChannelPlatform +from apps.experiments.models import ExperimentSession +from apps.utils.factories.channels import ExperimentChannelFactory +from apps.utils.factories.experiment import ExperimentFactory +from apps.utils.factories.user import UserFactory + +TOKEN = "public_token_1234567890123456789012" +CANONICAL = "ocs.example.com" + + +@pytest.fixture(autouse=True) +def _canonical_site(db): + Site.objects.filter(id=1).update(domain=f"{CANONICAL}:8443", name="OCS") + Site.objects.clear_cache() + yield + Site.objects.clear_cache() + + +@pytest.fixture() +def public_channel(team_with_users): + experiment = ExperimentFactory.create(team=team_with_users, consent_form=None) + experiment.create_new_version(make_default=True) + return ExperimentChannelFactory.create( + team=team_with_users, experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": TOKEN} + ) + + +def _request(origin=None, referer=None): + headers = {} + if origin: + headers["HTTP_ORIGIN"] = origin + if referer: + headers["HTTP_REFERER"] = referer + return RequestFactory().post("/api/chat/start/", **headers) + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + ("origin", "referer", "allowed"), + [ + pytest.param(f"https://{CANONICAL}", None, True, id="canonical-origin"), + pytest.param(f"https://{CANONICAL}:8443", None, True, id="port-ignored"), + pytest.param(f"https://{CANONICAL.upper()}", None, True, id="case-insensitive"), + pytest.param(None, f"https://{CANONICAL}/c/{TOKEN}/", True, id="referer-fallback"), + pytest.param("https://evil.example.org", None, False, id="foreign-origin"), + pytest.param(f"https://sub.{CANONICAL}", None, False, id="subdomain-refused"), + pytest.param(None, None, False, id="no-origin"), + ], +) +def test_public_channel_origin_rule(public_channel, origin, referer, allowed): + assert channel_origin_allowed(_request(origin, referer), public_channel) is allowed + + +@pytest.mark.django_db() +def test_embedded_channel_still_uses_its_domain_list(experiment): + channel = ExperimentChannelFactory.create( + experiment=experiment, + platform=ChannelPlatform.EMBEDDED_WIDGET, + extra_data={"widget_token": TOKEN, "allowed_domains": ["partner.example.com"]}, + ) + assert channel_origin_allowed(_request("https://partner.example.com"), channel) is True + assert channel_origin_allowed(_request(f"https://{CANONICAL}"), channel) is False + + +@pytest.mark.django_db() +def test_embed_key_authorizes_a_public_channel_from_the_canonical_origin(public_channel): + request = _request(f"https://{CANONICAL}") + request.META["HTTP_X_EMBED_KEY"] = TOKEN + assert embed_key_authorizes_channel(request, public_channel) is True + foreign = _request("https://evil.example.org") + foreign.META["HTTP_X_EMBED_KEY"] = TOKEN + assert embed_key_authorizes_channel(foreign, public_channel) is False + + +def _start(client, experiment, body=None, **extra): + return client.post( + reverse("api:chat:start-session"), + data={"chatbot_id": experiment.public_id, "session_data": {"source": "widget"}, **(body or {})}, + format="json", + **extra, + ) + + +@pytest.mark.django_db() +def test_anonymous_start_from_the_canonical_origin_lands_on_the_public_channel(public_channel): + response = _start( + APIClient(), public_channel.experiment, HTTP_X_EMBED_KEY=TOKEN, HTTP_ORIGIN=f"https://{CANONICAL}" + ) + assert response.status_code == 201, response.content + session = ExperimentSession.objects.get(external_id=response.json()["session_id"]) + assert session.experiment_channel == public_channel + assert session.participant.platform == "public" + + +@pytest.mark.django_db() +def test_anonymous_start_from_a_foreign_origin_is_refused(public_channel): + response = _start( + APIClient(), public_channel.experiment, HTTP_X_EMBED_KEY=TOKEN, HTTP_ORIGIN="https://evil.example.org" + ) + assert response.status_code == 403 + + +@pytest.mark.django_db() +@pytest.mark.parametrize("member", [pytest.param(True, id="team-member"), pytest.param(False, id="non-member")]) +def test_logged_in_user_on_the_page_lands_on_the_public_channel(public_channel, member): + team = public_channel.team + user = team.members.first() if member else UserFactory.create() + client = APIClient() + client.force_login(user) + response = _start( + client, + public_channel.experiment, + {"participant_remote_id": user.email}, + HTTP_X_EMBED_KEY=TOKEN, + HTTP_ORIGIN=f"https://{CANONICAL}", + ) + assert response.status_code == 201, response.content + session = ExperimentSession.objects.get(external_id=response.json()["session_id"]) + assert session.experiment_channel == public_channel diff --git a/apps/web/meta.py b/apps/web/meta.py index aab3ae3f70..945c29aa42 100644 --- a/apps/web/meta.py +++ b/apps/web/meta.py @@ -16,6 +16,11 @@ def get_server_root(is_secure: bool = settings.USE_HTTPS_IN_ABSOLUTE_URLS) -> st return f"{get_protocol(is_secure)}://{Site.objects.get_current().domain}" +def canonical_hostname() -> str: + """The hostname OCS is served from, for origin checks: the Site domain without a port.""" + return Site.objects.get_current().domain.split(":")[0].lower() + + def absolute_url(relative_url: str, is_secure: bool = settings.USE_HTTPS_IN_ABSOLUTE_URLS): """ Returns the complete absolute url for a given path - for use in emails or API integrations. From dbbac8d2875661ef9128b624a7da472b21db6955 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:00:58 +0200 Subject: [PATCH 04/37] Refuse public link starts without a published version --- api-schemas/v1.yml | 17 ++++ apps/api/tests/test_public_channel_start.py | 105 ++++++++++++++++++++ apps/api/views/chat.py | 44 +++++++- 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 apps/api/tests/test_public_channel_start.py diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index d1417ef1ec..5ebed54061 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -234,6 +234,12 @@ paths: schema: $ref: '#/components/schemas/ChatAccessDenied' description: '' + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ChatStartSessionRefused' + description: '' /api/experiments/: get: operationId: experiment_list @@ -1174,6 +1180,17 @@ components: * `processing` - Processing * `completed` - Completed * `error` - Error + ChatStartSessionRefused: + type: object + properties: + error: + type: string + code: + type: string + description: '`no_published_version` or `consent_unavailable`.' + required: + - code + - error ChatStartSessionRequest: type: object properties: diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py new file mode 100644 index 0000000000..02a968dbd9 --- /dev/null +++ b/apps/api/tests/test_public_channel_start.py @@ -0,0 +1,105 @@ +"""Start-session guards for the public channel (spec D4). + +The embed key is in page source, so the API enforces: only a published version is served, and a +consent-form chatbot has no live link until the consent work (step 3) ships. +""" + +import pytest +from django.contrib.sites.models import Site +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.channels.models import ChannelPlatform +from apps.experiments.models import ExperimentSession +from apps.utils.factories.channels import ExperimentChannelFactory +from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory + +TOKEN = "public_token_1234567890123456789012" +CANONICAL = "ocs.example.com" +ORIGIN = f"https://{CANONICAL}" + + +@pytest.fixture(autouse=True) +def _canonical_site(db): + Site.objects.filter(id=1).update(domain=CANONICAL) + Site.objects.clear_cache() + yield + Site.objects.clear_cache() + + +def _public_channel(team, *, consent=False, publish=True): + experiment = ExperimentFactory.create( + team=team, consent_form=ConsentFormFactory.create(team=team) if consent else None + ) + if publish: + experiment.create_new_version(make_default=True) + return ExperimentChannelFactory.create( + team=team, experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": TOKEN} + ) + + +def _start(client, experiment, **body): + return client.post( + reverse("api:chat:start-session"), + data={"chatbot_id": experiment.public_id, "session_data": {"source": "widget"}, **body}, + format="json", + HTTP_X_EMBED_KEY=TOKEN, + HTTP_ORIGIN=ORIGIN, + ) + + +@pytest.mark.django_db() +def test_published_public_chatbot_starts_with_a_session_token(team_with_users): + channel = _public_channel(team_with_users) + response = _start(APIClient(), channel.experiment) + assert response.status_code == 201, response.content + body = response.json() + assert body["session_token"] + session = ExperimentSession.objects.get(external_id=body["session_id"]) + assert session.session_token_required is True + assert session.participant.platform == "public" + + +@pytest.mark.django_db() +def test_unpublished_public_chatbot_refuses_with_409(team_with_users): + channel = _public_channel(team_with_users, publish=False) + response = _start(APIClient(), channel.experiment) + assert response.status_code == 409 + assert response.json()["code"] == "no_published_version" + assert not ExperimentSession.objects.filter(experiment_channel=channel).exists() + + +@pytest.mark.django_db() +def test_consent_form_chatbot_refuses_with_409_until_step_3(team_with_users): + channel = _public_channel(team_with_users, consent=True) + response = _start(APIClient(), channel.experiment) + assert response.status_code == 409 + assert response.json()["code"] == "consent_unavailable" + + +@pytest.mark.django_db() +def test_anonymous_version_number_is_refused(team_with_users): + channel = _public_channel(team_with_users) + response = _start(APIClient(), channel.experiment, version_number=1) + assert response.status_code == 403 + + +@pytest.mark.django_db() +def test_team_member_may_start_an_unpublished_public_chatbot(team_with_users): + channel = _public_channel(team_with_users, publish=False) + client = APIClient() + user = team_with_users.members.first() + client.force_login(user) + response = _start(client, channel.experiment, participant_remote_id=user.email) + assert response.status_code == 201, response.content + + +@pytest.mark.django_db() +def test_disabled_public_channel_refuses_before_the_published_check(team_with_users): + channel = _public_channel(team_with_users, publish=False) + channel.enabled = False + channel.disabled_message = "Back soon" + channel.save() + response = _start(APIClient(), channel.experiment) + assert response.status_code == 403 + assert "Back soon" in response.json()["error"] diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index e788ec6939..756bfa3760 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -40,7 +40,7 @@ from apps.api.throttling import ChatAPIRateThrottle from apps.channels.api_channel import ApiChannel from apps.channels.datamodels import Attachment -from apps.channels.models import CredentialMode, ExperimentChannel, WidgetAuthLevel +from apps.channels.models import ChannelPlatform, CredentialMode, ExperimentChannel, WidgetAuthLevel from apps.channels.utils import get_experiment_session_cached from apps.channels.widget_versions import ( WIDGET_VERSION_HEADER, @@ -50,6 +50,7 @@ ) from apps.chat.models import Chat, ChatAttachment, ChatMessage, ChatMessageType from apps.chat.utils import safe_link_url +from apps.chatbots.version_resolver import NoPublishedVersion, VersionSelectionRule, resolve_chatbot_version from apps.experiments.models import Experiment, Participant, ParticipantData from apps.experiments.task_utils import get_message_task_response from apps.experiments.tasks import get_response_for_webchat_task @@ -345,6 +346,35 @@ def _channel_disabled_response(experiment_channel) -> Response | None: return Response({"error": detail}, status=status.HTTP_403_FORBIDDEN) +NO_PUBLISHED_VERSION = {"error": "This chatbot has no published version", "code": "no_published_version"} +CONSENT_UNAVAILABLE = { + "error": "This chatbot requires consent, which the public link cannot collect yet", + "code": "consent_unavailable", +} + + +def _is_team_member(request, experiment) -> bool: + return request.user.is_authenticated and experiment.team.members.filter(id=request.user.id).exists() + + +def _public_channel_refusal(request, experiment, experiment_channel) -> Response | None: + """A 409 when a public link cannot serve a visitor, else None. + + Public visitors only ever reach the published version, and a consent-form chatbot has no + live link until consent moves into the widget. Team members are exempt so they can try the + page before publishing. + """ + if experiment_channel.platform != ChannelPlatform.PUBLIC or _is_team_member(request, experiment): + return None + try: + published = resolve_chatbot_version(experiment, VersionSelectionRule.LATEST_PUBLISHED) + except NoPublishedVersion: + return Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) + if published.consent_form_id: + return Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) + return None + + def _get_requested_version(experiment, version_number): """The explicitly requested version, or None to use the working version.""" if version_number is None or version_number == Experiment.DEFAULT_VERSION_NUMBER: @@ -397,6 +427,15 @@ def _resolve_experiment_channel(request, team, session_data, embed_key_channel, "code": serializers.CharField(help_text="Always `chat_access_denied`."), }, ), + # Public-channel admission: no published version yet, or the chatbot has a consent form + # the public link cannot collect (`no_published_version` / `consent_unavailable`). + 409: inline_serializer( + "ChatStartSessionRefused", + { + "error": serializers.CharField(), + "code": serializers.CharField(help_text="`no_published_version` or `consent_unavailable`."), + }, + ), }, # auth=["{}"], examples=[ @@ -498,6 +537,9 @@ def chat_start_session(request): if disabled := _channel_disabled_response(experiment_channel): return disabled + if refusal := _public_channel_refusal(request, experiment, experiment_channel): + return refusal + if request.user.is_authenticated: user = request.user participant_id = user.email From 34196acd00e6cdb8b604917cbddbf9affbc82312 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:09:11 +0200 Subject: [PATCH 05/37] Serve public link sessions from the published version on every request --- apps/api/tests/test_public_channel_start.py | 70 +++++++++++++++++++++ apps/api/views/chat.py | 24 ++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index 02a968dbd9..75558eb076 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -4,11 +4,16 @@ consent-form chatbot has no live link until the consent work (step 3) ships. """ +from unittest import mock + import pytest from django.contrib.sites.models import Site +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse +from field_audit.models import AuditAction from rest_framework.test import APIClient +from apps.api.views import chat as chat_views from apps.channels.models import ChannelPlatform from apps.experiments.models import ExperimentSession from apps.utils.factories.channels import ExperimentChannelFactory @@ -103,3 +108,68 @@ def test_disabled_public_channel_refuses_before_the_published_check(team_with_us response = _start(APIClient(), channel.experiment) assert response.status_code == 403 assert "Back soon" in response.json()["error"] + + +def _send(client, session_id, token, text="hi"): + return client.post( + reverse("api:chat:send-message", kwargs={"session_id": session_id}), + data={"message": text}, + format="json", + HTTP_X_SESSION_TOKEN=token, + HTTP_ORIGIN=ORIGIN, + ) + + +def _upload(client, session_id, token): + return client.post( + reverse("api:chat:upload-file", kwargs={"session_id": session_id}), + data={"files": [SimpleUploadedFile("note.txt", b"hello", content_type="text/plain")]}, + format="multipart", + HTTP_X_SESSION_TOKEN=token, + HTTP_ORIGIN=ORIGIN, + ) + + +def _unpublish(experiment): + experiment.versions.update(is_default_version=False, audit_action=AuditAction.AUDIT) + + +@pytest.mark.django_db() +def test_send_refuses_once_the_published_version_is_gone(team_with_users): + channel = _public_channel(team_with_users) + client = APIClient() + started = _start(client, channel.experiment).json() + _unpublish(channel.experiment) + response = _send(client, started["session_id"], started["session_token"]) + assert response.status_code == 409 + assert response.json()["code"] == "no_published_version" + + +@pytest.mark.django_db() +def test_upload_refuses_once_the_published_version_is_gone(team_with_users): + channel = _public_channel(team_with_users) + client = APIClient() + started = _start(client, channel.experiment).json() + _unpublish(channel.experiment) + response = _upload(client, started["session_id"], started["session_token"]) + assert response.status_code == 409 + assert response.json()["code"] == "no_published_version" + + +@pytest.mark.django_db() +def test_send_on_a_live_public_session_uses_the_published_version(team_with_users, monkeypatch): + channel = _public_channel(team_with_users) + seen = {} + + def fake_delay(*args, **kwargs): + seen["args"] = args + seen["kwargs"] = kwargs + return mock.Mock(task_id="public-send-test-task") + + monkeypatch.setattr(chat_views.get_response_for_webchat_task, "delay", fake_delay) + client = APIClient() + started = _start(client, channel.experiment).json() + response = _send(client, started["session_id"], started["session_token"]) + assert response.status_code == 202, response.content + published = channel.experiment.versions.get(is_default_version=True) + assert seen["kwargs"]["experiment_id"] == published.id diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 756bfa3760..b4cdc65f74 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -163,6 +163,10 @@ def chat_upload_file(request, session_id): if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) + + _, refusal = _public_session_version(session) + if refusal: + return refusal files = request.FILES.getlist("files") if not files: return Response({"error": "No files provided"}, status=status.HTTP_400_BAD_REQUEST) @@ -375,6 +379,18 @@ def _public_channel_refusal(request, experiment, experiment_channel) -> Response return None +def _public_session_version(session) -> tuple[Experiment | None, Response | None]: + """The version a request on `session` runs against, or a 409 for a public session whose + published version has gone. Other channels keep the published-or-working fallback.""" + channel = session.experiment_channel + if channel is None or channel.platform != ChannelPlatform.PUBLIC: + return session.experiment_version, None + try: + return resolve_chatbot_version(session.experiment, VersionSelectionRule.LATEST_PUBLISHED), None + except NoPublishedVersion: + return None, Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) + + def _get_requested_version(experiment, version_number): """The explicitly requested version, or None to use the working version.""" if version_number is None or version_number == Experiment.DEFAULT_VERSION_NUMBER: @@ -677,9 +693,13 @@ def chat_send_message(request, session_id): except Experiment.DoesNotExist: raise NotFound(f"Experiment with version {version_number} not found") from None else: - experiment_version = session.experiment_version + experiment_version, refusal = _public_session_version(session) + if refusal: + return refusal else: - experiment_version = session.experiment_version + experiment_version, refusal = _public_session_version(session) + if refusal: + return refusal attachment_data = [] if attachment_ids: From a0939e0d2a5a09df7c18dddff9c7edccf32dc1a8 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:12:48 +0200 Subject: [PATCH 06/37] Pin the keyless fallback refusal for public sessions --- apps/api/tests/test_widget_auth_level.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/api/tests/test_widget_auth_level.py b/apps/api/tests/test_widget_auth_level.py index 3e2bdc89d9..c3fcc0abee 100644 --- a/apps/api/tests/test_widget_auth_level.py +++ b/apps/api/tests/test_widget_auth_level.py @@ -267,3 +267,17 @@ def test_public_channel_carries_the_default_session_token_level(experiment): experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": WIDGET_TOKEN} ) assert channel.widget_auth_level == WidgetAuthLevel.SESSION_TOKEN + + +@pytest.mark.django_db() +def test_public_session_without_token_requirement_is_still_refused(api_client, experiment): + """A public session carries SESSION_TOKEN by default, so the keyless fallback in + SessionAccessPermission can never admit it, even if the row was misconfigured.""" + channel = ExperimentChannelFactory.create( + experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": WIDGET_TOKEN} + ) + session = ExperimentSessionFactory.create( + experiment=experiment, experiment_channel=channel, session_token_required=False + ) + response = api_client.get(poll_url(session), HTTP_ORIGIN="https://example.com") + assert response.status_code == 403 From b85c13e77af694e28528fd748b18f17b426b9926 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:16:21 +0200 Subject: [PATCH 07/37] Throttle public link starts per visitor IP --- apps/api/tests/test_throttling.py | 22 ++++++++++++++++++++++ apps/api/throttling.py | 6 +++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/test_throttling.py b/apps/api/tests/test_throttling.py index be77e8b019..90e1729874 100644 --- a/apps/api/tests/test_throttling.py +++ b/apps/api/tests/test_throttling.py @@ -258,3 +258,25 @@ def test_chat_api_throttle_buckets_an_oauth_caller_on_its_channel(experiment): assert response.status_code == 201, response.json() assert [call.args[1:] for call in checked.call_args_list] == [("channel", str(channel.pk))] + + +@pytest.mark.django_db() +def test_chat_api_throttle_keys_a_public_start_on_the_visitor_ip(experiment): + channel = ExperimentChannelFactory.create( + experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": "tok"} + ) + request = RequestFactory().post("/api/chat/start/", REMOTE_ADDR="203.0.113.9") + request.auth = channel + view = _view_stub() + assert ChatAPIRateThrottle().identity(request, view) == ("ip", "203.0.113.9") + + +@pytest.mark.django_db() +def test_chat_api_throttle_still_keys_an_embed_start_on_the_channel(experiment): + channel = ExperimentChannelFactory.create( + experiment=experiment, platform=ChannelPlatform.EMBEDDED_WIDGET, extra_data={"widget_token": "tok"} + ) + request = RequestFactory().post("/api/chat/start/", REMOTE_ADDR="203.0.113.9") + request.auth = channel + view = _view_stub() + assert ChatAPIRateThrottle().identity(request, view) == ("channel", str(channel.pk)) diff --git a/apps/api/throttling.py b/apps/api/throttling.py index 123fbc4182..c1d0481777 100644 --- a/apps/api/throttling.py +++ b/apps/api/throttling.py @@ -7,7 +7,7 @@ from rest_framework.throttling import BaseThrottle from apps.api.models import UserAPIKey -from apps.channels.models import ExperimentChannel +from apps.channels.models import ChannelPlatform, ExperimentChannel from apps.oauth.models import OAuth2AccessToken from apps.utils.rate_limit import check, client_ip, is_exempt @@ -68,5 +68,9 @@ def identity(self, request, view) -> tuple[str, str]: return "session", str(session_id) auth = getattr(request, "auth", None) if isinstance(auth, ExperimentChannel): + if auth.platform == ChannelPlatform.PUBLIC: + # A shared link cannot pool every visitor in one bucket: any holder could lock + # the rest out, and each admitted start creates a participant and a session. + return "ip", client_ip(request) return "channel", str(auth.pk) return "ip", client_ip(request) From cad54a7d6e32065cdf1cecf5973b1f2e0d17a5c6 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:20:43 +0200 Subject: [PATCH 08/37] Regenerate a public link token and end its live sessions --- apps/channels/models.py | 34 ++++++++++ apps/channels/tests/test_public_channel.py | 74 ++++++++++++++++++++++ apps/chatbots/public_link.py | 8 +++ config/urls.py | 2 + 4 files changed, 118 insertions(+) create mode 100644 apps/channels/tests/test_public_channel.py create mode 100644 apps/chatbots/public_link.py diff --git a/apps/channels/models.py b/apps/channels/models.py index 309674f4fb..095da38d0c 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -1,3 +1,4 @@ +import secrets import uuid from datetime import timedelta from typing import TYPE_CHECKING, Self, cast @@ -376,6 +377,39 @@ def widget_auth_level(self) -> "WidgetAuthLevel | None": return None return WidgetAuthLevel(self.required_auth_level) + @property + def public_url(self) -> str: + """The shareable page for a public link channel.""" + return absolute_url(reverse("public_link", args=[self.extra_data["widget_token"]])) + + def regenerate_widget_token(self) -> str: + """Replace the embed key and end every live session that was started with the old one. + + A token-required session is admitted on its session token alone, so without the second + step a session started before regeneration would run for the rest of its token lifetime. + """ + new_token = secrets.token_urlsafe(24) + self.extra_data = {**self.extra_data, "widget_token": new_token} + self.save(update_fields=["extra_data"]) + self.end_live_sessions() + return new_token + + def end_live_sessions(self) -> int: + """Mark every non-complete session on this channel COMPLETE. Returns how many.""" + from apps.experiments.models import ( # noqa: PLC0415 - circular: experiments.models imports channels.models + ExperimentSession, + SessionStatus, + ) + + ended = 0 + now = timezone.now() + for session in ExperimentSession.objects.filter(experiment_channel=self).exclude(status=SessionStatus.COMPLETE): + session.status = SessionStatus.COMPLETE + session.ended_at = now + session.save(update_fields=["status", "ended_at"]) + ended += 1 + return ended + @property def min_widget_version(self) -> str | None: """Minimum widget version required by this channel's current auth level. diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py new file mode 100644 index 0000000000..c05420eb0e --- /dev/null +++ b/apps/channels/tests/test_public_channel.py @@ -0,0 +1,74 @@ +"""Regenerating a public link revokes it (spec D2): the old token stops new starts at once, +and every live session on the channel is ended so a token-required session cannot keep running +for the rest of its token lifetime.""" + +import pytest +from django.urls import reverse + +from apps.channels.models import ChannelPlatform +from apps.experiments.models import ExperimentSession, SessionStatus +from apps.utils.factories.channels import ExperimentChannelFactory +from apps.utils.factories.experiment import ExperimentSessionFactory + +TOKEN = "public_token_1234567890123456789012" + + +@pytest.fixture() +def public_channel(experiment): + return ExperimentChannelFactory.create( + team=experiment.team, experiment=experiment, platform=ChannelPlatform.PUBLIC, extra_data={"widget_token": TOKEN} + ) + + +@pytest.mark.django_db() +def test_public_url_is_the_absolute_token_route(public_channel): + assert public_channel.public_url.endswith(reverse("public_link", args=[TOKEN])) + assert public_channel.public_url.startswith("http") + + +@pytest.mark.django_db() +def test_regenerate_replaces_the_token(public_channel): + new_token = public_channel.regenerate_widget_token() + public_channel.refresh_from_db() + assert new_token != TOKEN + assert len(new_token) == 32 + assert public_channel.extra_data["widget_token"] == new_token + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + "status", + [ + pytest.param(SessionStatus.ACTIVE, id="active"), + pytest.param(SessionStatus.SETUP, id="setup"), + pytest.param(SessionStatus.PENDING, id="pending"), + ], +) +def test_regenerate_ends_live_sessions(public_channel, status): + session = ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=status + ) + other = ExperimentSessionFactory.create(experiment=public_channel.experiment, status=SessionStatus.ACTIVE) + + public_channel.regenerate_widget_token() + + session.refresh_from_db() + other.refresh_from_db() + assert session.is_complete is True + assert session.ended_at is not None + assert other.status == SessionStatus.ACTIVE + + +@pytest.mark.django_db() +def test_end_live_sessions_reports_the_count_and_skips_complete_ones(public_channel): + ExperimentSessionFactory.create_batch( + 2, experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.ACTIVE + ) + ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.COMPLETE + ) + assert public_channel.end_live_sessions() == 2 + complete_count = ExperimentSession.objects.filter( + experiment_channel=public_channel, status=SessionStatus.COMPLETE + ).count() + assert complete_count == 3 diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py new file mode 100644 index 0000000000..f75b6fa4c5 --- /dev/null +++ b/apps/chatbots/public_link.py @@ -0,0 +1,8 @@ +from django.http import HttpResponseNotFound + +from apps.web.waf import WafRule, waf_allow + + +@waf_allow(WafRule.NoUserAgent_HEADER) +def public_link_page(request, token: str): + return HttpResponseNotFound() diff --git a/config/urls.py b/config/urls.py index 90cc374953..86e6dfc4a0 100644 --- a/config/urls.py +++ b/config/urls.py @@ -25,6 +25,7 @@ from django.views.generic import RedirectView, TemplateView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView +from apps.chatbots.public_link import public_link_page from apps.oauth.urls import team_urlpatterns as oauth_team_urls from apps.oauth.views import TeamScopedAuthorizationView from apps.slack.urls import slack_global_urls @@ -86,6 +87,7 @@ name="django.contrib.sitemaps.views.sitemap", ), path("a//", include(team_urlpatterns)), + path("c//", public_link_page, name="public_link"), path("notifications/", include("apps.ocs_notifications.urls")), path("", include("apps.sso.urls")), # must be before allauth urls since it uses the same paths path("accounts/", include("allauth.urls")), # MFA URLs included automatically From e39ad9936403f9d7398b337d78d3a3a36a46bc15 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:28:05 +0200 Subject: [PATCH 09/37] Add the public link channel form with a regenerate action --- apps/channels/forms.py | 79 +++++++++++++++++++++ apps/channels/tests/test_models.py | 4 +- apps/channels/tests/test_public_channel.py | 53 ++++++++++++++ templates/channels/widgets/public_link.html | 24 +++++++ 4 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 templates/channels/widgets/public_link.html diff --git a/apps/channels/forms.py b/apps/channels/forms.py index 4e97c9978f..f230f22a7d 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.forms import SimpleArrayField # ty: ignore[unresolved-import] from django.core.exceptions import ValidationError +from django.urls import reverse from telebot import TeleBot, apihelper from apps.channels.const import SLACK_ALL_CHANNELS @@ -751,6 +752,84 @@ def post_save(self, channel: ExperimentChannel): self.success_message = "Channel saved successfully" +class PublicLinkParams(forms.Widget): + template_name = "channels/widgets/public_link.html" + + def __init__(self, channel: ExperimentChannel): + super().__init__() + self.channel = channel + + def format_value(self, value): + return "" if value is None else value + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["widget"]["public_url"] = self.channel.public_url + context["widget"]["edit_url"] = reverse( + "channels:channel_edit_dialog", + args=[self.channel.team.slug, self.channel.experiment_id, self.channel.id], + ) + return context + + +def _lines_field(label: str, help_text: str, placeholder: str) -> SimpleArrayField: + return SimpleArrayField( + forms.CharField(max_length=500), + delimiter="\n", + required=False, + label=label, + help_text=help_text, + widget=forms.Textarea( + attrs={"rows": 3, "class": "textarea textarea-bordered w-full", "placeholder": placeholder} + ), + ) + + +class PublicChannelForm(ExtraFormBase): + """Configuration for a public link. The link itself is the embed key, shown once a channel exists.""" + + welcome_messages = _lines_field( + "Welcome messages", + "Shown above the composer before the visitor sends anything. One message per line.", + "Hi! Ask me about opening hours or how to book.", + ) + starter_questions = _lines_field( + "Starter questions", + "Buttons the visitor can tap to send a first message. One question per line.", + "What are your opening hours?", + ) + widget_token = forms.CharField(required=False, widget=forms.HiddenInput()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.initial = dict(self.initial) + self._previous_token = self.channel.extra_data.get("widget_token") if self.channel else None + if self._previous_token: + self.initial["widget_token"] = self._previous_token + self.fields["widget_token"].widget = PublicLinkParams(channel=self.channel) + self.fields["widget_token"].label = "" + + def clean(self): + cleaned_data = super().clean() + cleaned_data.setdefault("welcome_messages", []) + cleaned_data.setdefault("starter_questions", []) + regenerate = self.data.get("regenerate_link") == "1" + if self._previous_token and not regenerate: + cleaned_data["widget_token"] = self._previous_token + else: + cleaned_data["widget_token"] = secrets.token_urlsafe(24) + return cleaned_data + + def post_save(self, channel: ExperimentChannel): + if self._previous_token and channel.extra_data.get("widget_token") != self._previous_token: + ended = channel.end_live_sessions() + self.success_message = ( + f"Link regenerated. The old link no longer works and {ended} live conversation(s) were ended." + ) + else: + self.success_message = "Channel saved successfully" + + class EmailChannelForm(ExtraFormBase): email_address = forms.EmailField( label="Email Address", diff --git a/apps/channels/tests/test_models.py b/apps/channels/tests/test_models.py index 38a52481d0..66ff102e9b 100644 --- a/apps/channels/tests/test_models.py +++ b/apps/channels/tests/test_models.py @@ -2,6 +2,7 @@ from django.test import override_settings from django.urls import reverse +from apps.channels.forms import PublicChannelForm from apps.channels.models import ChannelPlatform, ExperimentChannel from apps.channels.webhooks import TelegramWebhookManager from apps.chat.models import Chat, ChatMessage, ChatMessageType @@ -312,8 +313,5 @@ def test_public_hidden_once_used(self, experiment, public_flag_enabled): def test_public_identifier_key_is_the_widget_token(self): assert ChannelPlatform.PUBLIC.channel_identifier_key == "widget_token" - @pytest.mark.xfail(strict=True, reason="PublicChannelForm lands in task 9") def test_public_extra_form_is_the_public_channel_form(self, experiment): - from apps.channels.forms import PublicChannelForm # noqa: PLC0415 # ty: ignore[unresolved-import] - assert isinstance(ChannelPlatform.PUBLIC.extra_form(experiment=experiment), PublicChannelForm) diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index c05420eb0e..7f6f1aa241 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -2,9 +2,12 @@ and every live session on the channel is ended so a token-required session cannot keep running for the rest of its token lifetime.""" +from unittest.mock import Mock + import pytest from django.urls import reverse +from apps.channels.forms import PublicChannelForm from apps.channels.models import ChannelPlatform from apps.experiments.models import ExperimentSession, SessionStatus from apps.utils.factories.channels import ExperimentChannelFactory @@ -72,3 +75,53 @@ def test_end_live_sessions_reports_the_count_and_skips_complete_ones(public_chan experiment_channel=public_channel, status=SessionStatus.COMPLETE ).count() assert complete_count == 3 + + +class TestPublicChannelForm: + def test_new_channel_gets_a_token_and_lists(self): + form = PublicChannelForm( + data={"welcome_messages": "Hello\nHow can I help?", "starter_questions": "Opening hours"}, + experiment=Mock(), + ) + assert form.is_valid(), form.errors + assert len(form.cleaned_data["widget_token"]) == 32 + assert form.cleaned_data["welcome_messages"] == ["Hello", "How can I help?"] + assert form.cleaned_data["starter_questions"] == ["Opening hours"] + + def test_lists_are_optional(self): + form = PublicChannelForm(data={}, experiment=Mock()) + assert form.is_valid(), form.errors + assert form.cleaned_data["welcome_messages"] == [] + assert form.cleaned_data["starter_questions"] == [] + + def test_existing_token_is_preserved(self): + channel = Mock() + channel.extra_data = {"widget_token": TOKEN, "welcome_messages": [], "starter_questions": []} + form = PublicChannelForm(data={}, channel=channel, experiment=Mock()) + assert form.is_valid(), form.errors + assert form.cleaned_data["widget_token"] == TOKEN + + def test_regenerate_mints_a_new_token(self): + channel = Mock() + channel.extra_data = {"widget_token": TOKEN} + form = PublicChannelForm(data={"regenerate_link": "1"}, channel=channel, experiment=Mock()) + assert form.is_valid(), form.errors + assert form.cleaned_data["widget_token"] != TOKEN + assert len(form.cleaned_data["widget_token"]) == 32 + + +@pytest.mark.django_db() +def test_saving_a_regenerated_form_ends_live_sessions(public_channel): + live = ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.ACTIVE + ) + form = PublicChannelForm( + data={"regenerate_link": "1"}, channel=public_channel, experiment=public_channel.experiment + ) + assert form.is_valid(), form.errors + public_channel.extra_data = form.cleaned_data + public_channel.save() + form.post_save(public_channel) + live.refresh_from_db() + assert live.is_complete + assert "ended" in form.success_message diff --git a/templates/channels/widgets/public_link.html b/templates/channels/widgets/public_link.html new file mode 100644 index 0000000000..e9bb0e92ba --- /dev/null +++ b/templates/channels/widgets/public_link.html @@ -0,0 +1,24 @@ +
+
+

Public link

+
+ + +
+

Anyone with this link can chat with the published version of this chatbot.

+ + +
+
From 053fb33b8129b6af98c5097a61d8128827a99c19 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 11:33:09 +0200 Subject: [PATCH 10/37] Cover creating and regenerating a public link through the dialog --- apps/channels/tests/test_public_channel.py | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index 7f6f1aa241..a80e3e0059 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -5,11 +5,13 @@ from unittest.mock import Mock import pytest +from django.contrib.auth.models import Permission from django.urls import reverse from apps.channels.forms import PublicChannelForm from apps.channels.models import ChannelPlatform from apps.experiments.models import ExperimentSession, SessionStatus +from apps.teams.models import Flag from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ExperimentSessionFactory @@ -125,3 +127,71 @@ def test_saving_a_regenerated_form_ends_live_sessions(public_channel): live.refresh_from_db() assert live.is_complete assert "ended" in form.success_message + + +def _operator(client, team, codename): + user = team.members.first() + user.user_permissions.add(Permission.objects.get(codename=codename)) + client.force_login(user) + return user + + +@pytest.fixture() +def public_flag(experiment): + flag = Flag.objects.create(name="flag_public_channel") + flag.teams.add(experiment.team) + flag.flush() + return flag + + +@pytest.mark.django_db() +def test_create_dialog_makes_a_public_channel_with_a_token(client, experiment, public_flag): + _operator(client, experiment.team, "add_experimentchannel") + url = reverse("channels:channel_create_dialog", args=[experiment.team.slug, experiment.id, "public"]) + response = client.post( + url, + data={ + "name": "Public link", + "platform": "public", + "enabled": "on", + "welcome_messages": "Hello", + "starter_questions": "", + }, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200, response.content + channel = experiment.experimentchannel_set.get(platform=ChannelPlatform.PUBLIC) + assert len(channel.extra_data["widget_token"]) == 32 + assert channel.extra_data["welcome_messages"] == ["Hello"] + assert channel.public_url.encode() in response.content + + +@pytest.mark.django_db() +def test_create_dialog_refuses_without_the_flag(client, experiment): + _operator(client, experiment.team, "add_experimentchannel") + url = reverse("channels:channel_create_dialog", args=[experiment.team.slug, experiment.id, "public"]) + response = client.get(url) + assert response.status_code == 302 + + +@pytest.mark.django_db() +def test_edit_dialog_regenerates_and_ends_sessions(client, public_channel, public_flag): + _operator(client, public_channel.team, "change_experimentchannel") + live = ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.ACTIVE + ) + url = reverse( + "channels:channel_edit_dialog", + args=[public_channel.team.slug, public_channel.experiment_id, public_channel.id], + ) + response = client.post( + url, + data={"name": public_channel.name, "platform": "public", "enabled": "on", "regenerate_link": "1"}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200, response.content + public_channel.refresh_from_db() + live.refresh_from_db() + assert public_channel.extra_data["widget_token"] != TOKEN + assert live.is_complete + assert b"Link regenerated" in response.content From 19360e6f444955078979b2804c001f17d87e2d32 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 12:06:16 +0200 Subject: [PATCH 11/37] Serve the public link page with the widget in kiosk mode --- apps/chatbots/public_link.py | 87 ++++++++++++- apps/chatbots/tests/test_public_link_page.py | 126 +++++++++++++++++++ templates/chatbots/public_link.html | 34 +++++ templates/robots.txt | 1 + 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 apps/chatbots/tests/test_public_link_page.py create mode 100644 templates/chatbots/public_link.html diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index f75b6fa4c5..b5810996dd 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -1,8 +1,91 @@ -from django.http import HttpResponseNotFound +"""The public link page: the chat widget in kiosk mode, on the OCS host, for one chatbot. +The page never creates a session. The widget starts its own through the Chat API with the +channel's embed key, and the API enforces every refusal the page shows as a banner. +""" + +import json +from dataclasses import dataclass + +from django.http import Http404 +from django.template.response import TemplateResponse + +from apps.channels.models import ChannelPlatform, ExperimentChannel +from apps.chatbots.version_resolver import NoPublishedVersion, VersionSelectionRule, resolve_chatbot_version +from apps.experiments.models import Experiment +from apps.experiments.rate_limit_keys import public_chat_rate_limited +from apps.web.meta import canonical_hostname, get_server_root from apps.web.waf import WafRule, waf_allow +CSP = ( + "default-src 'self'; " + "script-src 'self' https://unpkg.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "object-src 'none'" +) + + +@dataclass(frozen=True) +class PageState: + code: str + banner: str | None + + @property + def live(self) -> bool: + return self.banner is None + + +def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | None]: + if channel.is_disabled: + return PageState("disabled", channel.disabled_message or "This chatbot is temporarily unavailable."), None + try: + published = resolve_chatbot_version(channel.experiment, VersionSelectionRule.LATEST_PUBLISHED) + except NoPublishedVersion: + return PageState("no_published_version", "This chatbot is not published yet."), None + if published.consent_form_id: + banner = "This chatbot needs your consent, which the public link cannot collect yet." + return PageState("consent_unavailable", banner), published + return PageState("live", None), published + @waf_allow(WafRule.NoUserAgent_HEADER) +@public_chat_rate_limited def public_link_page(request, token: str): - return HttpResponseNotFound() + if request.get_host().split(":")[0].lower() != canonical_hostname(): + raise Http404() + channel = ( + ExperimentChannel.objects.select_related("experiment", "team") + .filter(platform=ChannelPlatform.PUBLIC, extra_data__widget_token=token) + .first() + ) + if channel is None: + raise Http404() + + state, published = _page_state(channel) + shown = published or channel.experiment + user = request.user if request.user.is_authenticated else None + response = TemplateResponse( + request, + "chatbots/public_link.html", + { + "channel": channel, + "state": state, + "chatbot_name": shown.name, + "chatbot_description": shown.description, + "public_id": channel.experiment.public_id, + "token": token, + "api_base_url": get_server_root(), + "welcome_json": json.dumps(channel.extra_data.get("welcome_messages", [])), + "starters_json": json.dumps(channel.extra_data.get("starter_questions", [])), + "user": user, + }, + ) + response["X-Robots-Tag"] = "noindex" + response["Referrer-Policy"] = "origin" + response["Content-Security-Policy"] = CSP + return response diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py new file mode 100644 index 0000000000..820c8e24c2 --- /dev/null +++ b/apps/chatbots/tests/test_public_link_page.py @@ -0,0 +1,126 @@ +"""The public page (spec D5): a kiosk widget on the OCS host, one state banner per refusal.""" + +import pytest +from django.contrib.sites.models import Site +from django.core.cache import cache as default_cache +from django.core.cache import caches +from django.test import override_settings +from django.urls import reverse + +from apps.channels.models import ChannelPlatform +from apps.utils.factories.channels import ExperimentChannelFactory +from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory + +TOKEN = "public_token_1234567890123456789012" +CANONICAL = "ocs.example.com" + + +@pytest.fixture(autouse=True) +def _canonical_site(db, settings): + Site.objects.filter(id=1).update(domain=CANONICAL) + Site.objects.clear_cache() + settings.ALLOWED_HOSTS = [CANONICAL, "other.example.com", "testserver"] + yield + Site.objects.clear_cache() + + +def _channel(team, *, consent=False, publish=True, enabled=True): + experiment = ExperimentFactory.create( + team=team, name="Clinic bot", consent_form=ConsentFormFactory.create(team=team) if consent else None + ) + if publish: + experiment.create_new_version(make_default=True) + return ExperimentChannelFactory.create( + team=team, + experiment=experiment, + platform=ChannelPlatform.PUBLIC, + enabled=enabled, + disabled_message="Back soon", + extra_data={"widget_token": TOKEN, "welcome_messages": ["Hi there"], "starter_questions": ["Hours?"]}, + ) + + +def _get(client, token=TOKEN, host=CANONICAL): + return client.get(reverse("public_link", args=[token]), HTTP_HOST=host) + + +@pytest.mark.django_db() +def test_live_page_renders_the_kiosk_widget(client, team_with_users): + _channel(team_with_users) + response = _get(client) + assert response.status_code == 200 + html = response.content.decode() + assert 'mode="kiosk"' in html + assert f'embed-key="{TOKEN}"' in html + assert 'persistent-session="tab"' in html + assert f'api-base-url="https://{CANONICAL}"' in html or f'api-base-url="http://{CANONICAL}"' in html + assert "Hi there" in html + assert "Hours?" in html + assert "Clinic bot" in html + assert "unpkg.com/open-chat-studio-widget" in html + assert response["X-Robots-Tag"] == "noindex" + assert response["Referrer-Policy"] == "origin" + assert "unpkg.com" in response["Content-Security-Policy"] + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + ("kwargs", "banner"), + [ + pytest.param({"enabled": False}, "Back soon", id="disabled"), + pytest.param({"publish": False}, "not published", id="no-published-version"), + pytest.param({"consent": True}, "consent", id="consent-unavailable"), + ], +) +def test_refused_states_render_a_banner_and_a_disabled_widget(client, team_with_users, kwargs, banner): + _channel(team_with_users, **kwargs) + response = _get(client) + assert response.status_code == 200 + html = response.content.decode() + assert banner.lower() in html.lower() + assert 'disabled="true"' in html + + +@pytest.mark.django_db() +def test_unknown_token_is_404(client, team_with_users): + _channel(team_with_users) + assert _get(client, token="nope_nope_nope_nope_nope_nope_nop").status_code == 404 + + +@pytest.mark.django_db() +def test_non_canonical_host_is_404(client, team_with_users): + _channel(team_with_users) + assert _get(client, host="other.example.com").status_code == 404 + + +@pytest.mark.django_db() +def test_deleted_channel_is_404(client, team_with_users): + channel = _channel(team_with_users) + channel.soft_delete() + assert _get(client).status_code == 404 + + +@pytest.mark.django_db() +def test_logged_in_visitor_gets_a_user_id(client, team_with_users): + _channel(team_with_users) + user = team_with_users.members.first() + client.force_login(user) + html = _get(client).content.decode() + assert f'user-id="{user.email}"' in html + + +@pytest.mark.django_db() +@override_settings(RATE_LIMITS={"public_chat": {"rate": "2/5m", "fail_open": True}}, RATE_LIMIT_ENFORCE=True) +def test_page_is_throttled_per_ip(client, team_with_users): + caches["rate_limit"].clear() + default_cache.clear() + _channel(team_with_users) + _get(client) + _get(client) + assert _get(client).status_code == 429 + + +@pytest.mark.django_db() +def test_robots_disallows_the_public_prefix(client): + response = client.get("/robots.txt") + assert b"Disallow: /c/" in response.content diff --git a/templates/chatbots/public_link.html b/templates/chatbots/public_link.html new file mode 100644 index 0000000000..107ff35bdc --- /dev/null +++ b/templates/chatbots/public_link.html @@ -0,0 +1,34 @@ +{% extends "web/base.html" %} +{% load chat_widget_tags %} +{% block page_title %}{{ chatbot_name }}{% endblock %} + +{% block top_nav %}{% endblock %} + +{% block body %} +
+
+

{{ chatbot_name }}

+ {% if chatbot_description %}

{{ chatbot_description }}

{% endif %} +
+ {% if not state.live %} + + {% endif %} +
+ +
+
+ +{% endblock body %} diff --git a/templates/robots.txt b/templates/robots.txt index 7af4d28bed..2682c9c319 100644 --- a/templates/robots.txt +++ b/templates/robots.txt @@ -1,2 +1,3 @@ User-Agent: * Disallow: {% url 'admin:index' %} # disable crawling django admin +Disallow: /c/ From a9dff0c22b4528bfddbf89160e59dcf7f031faed Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 12:14:08 +0200 Subject: [PATCH 12/37] Serve the public link page without the app chrome --- apps/chatbots/public_link.py | 4 +- apps/chatbots/tests/test_public_link_page.py | 1 + templates/chatbots/public_link.html | 73 +++++++++++--------- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index b5810996dd..2f4efd67ac 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -20,9 +20,9 @@ CSP = ( "default-src 'self'; " "script-src 'self' https://unpkg.com; " - "style-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; " + "font-src 'self' data: https://cdnjs.cloudflare.com; " "img-src 'self' data: https:; " - "font-src 'self' data:; " "connect-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 820c8e24c2..8de752f5c7 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -61,6 +61,7 @@ def test_live_page_renders_the_kiosk_widget(client, team_with_users): assert response["X-Robots-Tag"] == "noindex" assert response["Referrer-Policy"] == "origin" assert "unpkg.com" in response["Content-Security-Policy"] + assert "cdnjs.cloudflare.com" in response["Content-Security-Policy"] @pytest.mark.django_db() diff --git a/templates/chatbots/public_link.html b/templates/chatbots/public_link.html index 107ff35bdc..c8b61a6ad5 100644 --- a/templates/chatbots/public_link.html +++ b/templates/chatbots/public_link.html @@ -1,34 +1,41 @@ -{% extends "web/base.html" %} -{% load chat_widget_tags %} -{% block page_title %}{{ chatbot_name }}{% endblock %} - -{% block top_nav %}{% endblock %} - -{% block body %} -
-
-

{{ chatbot_name }}

- {% if chatbot_description %}

{{ chatbot_description }}

{% endif %} -
- {% if not state.live %} -
+ + + From 16dc0dd0aa062add1889e4d693676fc47143248f Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 12:25:39 +0200 Subject: [PATCH 13/37] Show a copy chip for the public link on the chatbot home --- apps/chatbots/tests/test_public_link_page.py | 19 +++++++++++++++++++ .../chatbots/components/channel_buttons.html | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 8de752f5c7..7e3e7bdeab 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -8,6 +8,7 @@ from django.urls import reverse from apps.channels.models import ChannelPlatform +from apps.teams.models import Flag from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory @@ -24,6 +25,14 @@ def _canonical_site(db, settings): Site.objects.clear_cache() +@pytest.fixture(autouse=True) +def _public_channel_flag_enabled(db, team_with_users): + flag = Flag.objects.create(name="flag_public_channel") + flag.teams.add(team_with_users) + flag.flush() + return flag + + def _channel(team, *, consent=False, publish=True, enabled=True): experiment = ExperimentFactory.create( team=team, name="Clinic bot", consent_form=ConsentFormFactory.create(team=team) if consent else None @@ -125,3 +134,13 @@ def test_page_is_throttled_per_ip(client, team_with_users): def test_robots_disallows_the_public_prefix(client): response = client.get("/robots.txt") assert b"Disallow: /c/" in response.content + + +@pytest.mark.django_db() +def test_chatbot_home_shows_a_copy_chip_for_the_public_link(client, team_with_users): + channel = _channel(team_with_users) + client.force_login(team_with_users.members.first()) + url = reverse("chatbots:single_chatbot_home", args=[team_with_users.slug, channel.experiment_id]) + html = client.get(url, HTTP_HOST=CANONICAL).content.decode() + assert channel.public_url in html + assert f'id="public-link-{channel.id}"' in html diff --git a/templates/chatbots/components/channel_buttons.html b/templates/chatbots/components/channel_buttons.html index 0d19839e45..d9856308f8 100644 --- a/templates/chatbots/components/channel_buttons.html +++ b/templates/chatbots/components/channel_buttons.html @@ -23,6 +23,15 @@ {% endwith %} {% endfor %} +{% for channel in channels %} + {% if channel.platform == "public" %} + {% with id_str=channel.id|stringformat:"s" %} + {% with link_id="public-link-"|add:id_str %} + {% include "generic/copy_chip.html" with copy_element_id=link_id copy_value=channel.public_url action_text="Copy link" extra_styles="btn-ghost" %} + {% endwith %} + {% endwith %} + {% endif %} +{% endfor %} {% if platforms %} {% endif %} - {% flag "flag_chat_widget" %} -
- -
- {% include "experiments/chat/end_chat_button.html" %} - {% else %} - {% include "experiments/chat/chat_ui.html" %} - {% endflag %} + {% include "experiments/chat/chat_ui.html" %} {% endblock body_wrapper %} From e2cbb32491968a02bb2dbfeb2a7e3c1d0bb9e70d Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 12:59:33 +0200 Subject: [PATCH 17/37] Keep the public page off localStorage until widget release A ships --- apps/chatbots/tests/test_public_link_page.py | 3 ++- templates/chatbots/public_link.html | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index a3ed98d9eb..7be8929841 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -52,7 +52,8 @@ def test_live_page_renders_the_kiosk_widget(client, team_with_users): html = response.content.decode() assert 'mode="kiosk"' in html assert f'embed-key="{TOKEN}"' in html - assert 'persistent-session="tab"' in html + assert 'persistent-session="false"' in html + assert 'persistent-session="tab"' not in html assert f'api-base-url="https://{CANONICAL}"' in html or f'api-base-url="http://{CANONICAL}"' in html assert "Hi there" in html assert "Hours?" in html diff --git a/templates/chatbots/public_link.html b/templates/chatbots/public_link.html index c8b61a6ad5..b1dc5dbcc3 100644 --- a/templates/chatbots/public_link.html +++ b/templates/chatbots/public_link.html @@ -23,12 +23,13 @@

{{ chatbot_name }}

{% endif %}
+ {# "tab" once widget 0.12.0 is LATEST_VERSION: the 0.11.0 widget coerces any string to true #} Date: Wed, 26 Aug 2026 13:01:26 +0200 Subject: [PATCH 18/37] Let team members chat on an unpublished public link --- apps/api/tests/test_public_channel_start.py | 16 ++++++++++++++++ apps/api/views/chat.py | 14 +++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index 75558eb076..bd336b693b 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -156,6 +156,22 @@ def test_upload_refuses_once_the_published_version_is_gone(team_with_users): assert response.json()["code"] == "no_published_version" +@pytest.mark.django_db() +def test_team_member_can_send_on_an_unpublished_public_link(team_with_users, monkeypatch): + channel = _public_channel(team_with_users, publish=False) + user = team_with_users.members.first() + monkeypatch.setattr( + chat_views.get_response_for_webchat_task, "delay", lambda *a, **k: mock.Mock(task_id="member-preview") + ) + client = APIClient() + client.force_login(user) + started = _start(client, channel.experiment, participant_remote_id=user.email) + assert started.status_code == 201, started.content + body = started.json() + response = _send(client, body["session_id"], body["session_token"]) + assert response.status_code == 202, response.content + + @pytest.mark.django_db() def test_send_on_a_live_public_session_uses_the_published_version(team_with_users, monkeypatch): channel = _public_channel(team_with_users) diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index b4cdc65f74..c994d3a291 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -164,7 +164,7 @@ def chat_upload_file(request, session_id): if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) - _, refusal = _public_session_version(session) + _, refusal = _public_session_version(request, session) if refusal: return refusal files = request.FILES.getlist("files") @@ -379,12 +379,16 @@ def _public_channel_refusal(request, experiment, experiment_channel) -> Response return None -def _public_session_version(session) -> tuple[Experiment | None, Response | None]: +def _public_session_version(request, session) -> tuple[Experiment | None, Response | None]: """The version a request on `session` runs against, or a 409 for a public session whose - published version has gone. Other channels keep the published-or-working fallback.""" + published version has gone. Other channels keep the published-or-working fallback, and so + do team members on a public channel so they can preview an unpublished chatbot through its + page.""" channel = session.experiment_channel if channel is None or channel.platform != ChannelPlatform.PUBLIC: return session.experiment_version, None + if _is_team_member(request, session.experiment): + return session.experiment_version, None try: return resolve_chatbot_version(session.experiment, VersionSelectionRule.LATEST_PUBLISHED), None except NoPublishedVersion: @@ -693,11 +697,11 @@ def chat_send_message(request, session_id): except Experiment.DoesNotExist: raise NotFound(f"Experiment with version {version_number} not found") from None else: - experiment_version, refusal = _public_session_version(session) + experiment_version, refusal = _public_session_version(request, session) if refusal: return refusal else: - experiment_version, refusal = _public_session_version(session) + experiment_version, refusal = _public_session_version(request, session) if refusal: return refusal From 529a9e47d326eee334ccbeea952da8dbb13f1e97 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 13:02:18 +0200 Subject: [PATCH 19/37] Update the hosting docs for the public link --- docs/hosting/configuration.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/hosting/configuration.md b/docs/hosting/configuration.md index 89a398afc5..6aa70deb84 100644 --- a/docs/hosting/configuration.md +++ b/docs/hosting/configuration.md @@ -63,12 +63,18 @@ These apply whether the connection comes from `DATABASE_URL` or the variables ab | `RATE_LIMIT_ENFORCE` | `False` | When `False`, over-limit requests are served and logged as `rate_limit.would_block` (sampled after the first crossing, not every request); when `True`, they receive HTTP 429. The `channels` scope does not answer to this switch: it counts and logs in both states and never returns 429. | | `RATE_LIMIT_API` | `2000/5m` | Request limit for the `api` scope, format `count/window` with `s`/`m`/`h` units. Fails open: if the limiter's cache is unreachable, requests are served. | | `RATE_LIMIT_ADMIN_API` | `100/5m` | Request limit for the `admin_api` scope (the `/admin/api/*` autocomplete and provider-reporting endpoints). Keyed by authenticated user, then the provider-reporting token, then client IP, so anonymous traffic cannot spend a staff member's allowance. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing, or all anonymous callers behind a proxy share one bucket. Fails open: if the limiter's cache is unreachable, requests are served. | -| `RATE_LIMIT_CHAT_API` | `300/5m` | Request limit for the `chat_api` scope (the `/api/chat/*` endpoints; the embedded chat widget is the primary caller, but non-widget clients use these endpoints too). Keyed per chat session, then per widget channel on session creation since no session exists yet, then by client IP for legacy clients that present neither. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing behind a proxy, or those legacy callers all share one bucket. Sits apart from `RATE_LIMIT_API` so one busy conversation cannot spend the team's interactive API allowance. Fails open: if the limiter's cache is unreachable, requests are served. | -| `RATE_LIMIT_PUBLIC_CHAT` | `100/5m` | Request limit for the `public_chat` scope (the public web chat views). Applies in both `flag_chat_widget` states: the flag selects which frontend the chat page embeds, so with it on the in-conversation message traffic moves to the `chat_api` scope while page loads, session creation and the end/review/complete flow stay under this one. Keyed per chat session, then by client IP on the paths that create a session, since no session exists there yet. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing behind a proxy, or every visitor starting a conversation shares one bucket. The poll that runs while a reply is being composed is excluded from the scope, so a slow answer does not spend a conversation's allowance. Over-limit requests receive the site's error page rather than a JSON body, since these views are reached in a browser. Fails open: if the limiter's cache is unreachable, requests are served. | +| `RATE_LIMIT_CHAT_API` | `300/5m` | Request limit for the `chat_api` scope (the `/api/chat/*` endpoints; the embedded chat widget is the primary caller, but non-widget clients use these endpoints too). Session starts are keyed per widget channel, except public link channels, which are keyed per visitor IP; after start, per session. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing behind a proxy, or those legacy callers all share one bucket. Sits apart from `RATE_LIMIT_API` so one busy conversation cannot spend the team's interactive API allowance. Fails open: if the limiter's cache is unreachable, requests are served. | +| `RATE_LIMIT_PUBLIC_CHAT` | `100/5m` | Request limit for the `public_chat` scope. Applies to the legacy public chat pages and to the public link page `/c//`, keyed per visitor IP. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing behind a proxy, or every visitor starting a conversation shares one bucket. The poll that runs while a reply is being composed is excluded from the scope, so a slow answer does not spend a conversation's allowance. Over-limit requests receive the site's error page rather than a JSON body, since these views are reached in a browser. Fails open: if the limiter's cache is unreachable, requests are served. | | `RATE_LIMIT_CHANNELS` | `3000/5m` | Request limit for the `channels` scope (inbound channel deliveries: Telegram, Twilio, Meta Cloud API, Turn, SureAdhere, CommCare Connect, Slack). Keyed per chatbot channel: each delivery is counted inside its view, once it has resolved to a channel and passed the provider's signature check, so a delivery that resolves to no channel is not counted and no caller can spend another tenant's allowance. A Meta payload carrying several phone numbers is counted once per number, against each one's own channel. On Slack this covers the messages the bot answers (mentions, DMs and replies in an existing thread); other channel traffic resolves no chatbot channel and is not counted. This scope counts but never refuses, in both `RATE_LIMIT_ENFORCE` states: an over-limit delivery is logged as `rate_limit.would_block` and still processed, since refusing it would discard a participant's message rather than delay it. Traffic that never resolves to a channel is outside this scope entirely, and is bounded by the WAF rather than here. Fails open: if the limiter's cache is unreachable, deliveries are served. | | `RATE_LIMIT_CREDENTIALS` | `100/5m` | Request limit for the `credentials` scope (the OAuth client-credential endpoints at `/o/token/`, `/o/revoke_token/` and `/o/introspect/`, API requests whose key or bearer token is rejected, and the CommCare Connect key exchange at `/api/commcare_connect/generate_key`, which issues an outbound request to CommCare Connect before it knows whether the caller's token is valid). Keyed by client IP, because a caller failing authentication has no identity to key on. Set `RATE_LIMIT_TRUSTED_PROXY_COUNT` before enforcing behind a proxy, or every caller shares one bucket. The one scope that fails closed: where the others serve the request when the limiter's cache is unreachable, this one refuses it once enforcement is on, so that a counter nobody can read does not become a way to brute force credentials unobserved. Successful API requests are counted under `RATE_LIMIT_API` instead, so a working integration is never charged to this scope. | | `RATE_LIMIT_TRUSTED_PROXY_COUNT` | `0` | Number of trusted reverse proxies; required for correct client IPs behind a proxy or tunnel before enabling any IP-keyed scope. | +Per-IP keying reads the client address through `RATE_LIMIT_TRUSTED_PROXY_COUNT`; behind a proxy or load balancer set it, or every visitor shares one bucket. + +## Public link host + +The public link page and its API access are pinned to the hostname of the Django `Site` row (`Site.objects.get_current().domain`). If that domain is not the deployed host, public links 404 and their chat starts are refused with 403. The value is cached per process, so a change needs a restart. + ## Email One of the following email backends must be configured. Set `DJANGO_EMAIL_BACKEND` to choose: From 861614ca53d55beade188060e62506d7bab673a6 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 13:03:43 +0200 Subject: [PATCH 20/37] Tidy the public link helpers --- apps/api/permissions.py | 4 ++-- apps/channels/models.py | 20 +++++-------------- apps/channels/tests/test_public_channel.py | 13 ++---------- .../chatbots/components/channel_buttons.html | 2 +- 4 files changed, 10 insertions(+), 29 deletions(-) diff --git a/apps/api/permissions.py b/apps/api/permissions.py index 53043571b4..97b8482c6f 100644 --- a/apps/api/permissions.py +++ b/apps/api/permissions.py @@ -94,8 +94,8 @@ def has_permission(self, request, view): # Each credential validates its own origin, and ChatOAuthAuthentication has already # applied the rule for this one — including the case this check cannot express, where a # blank domain list declares the channel server-only and an originless request is the - # correct shape. The `if not origin_domain` line below would reject it before the view - # ever runs. + # correct shape. The origin rule now lives in `channel_origin_allowed`, which would + # reject an originless server-only request before the view runs. return True return channel_origin_allowed(request, request.auth) diff --git a/apps/channels/models.py b/apps/channels/models.py index badd675b68..e9b8d4df7e 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -1,4 +1,3 @@ -import secrets import uuid from datetime import timedelta from typing import TYPE_CHECKING, Self, cast @@ -379,20 +378,11 @@ def widget_auth_level(self) -> "WidgetAuthLevel | None": @property def public_url(self) -> str: - """The shareable page for a public link channel.""" - return absolute_url(reverse("public_link", args=[self.extra_data["widget_token"]])) - - def regenerate_widget_token(self) -> str: - """Replace the embed key and end every live session that was started with the old one. - - A token-required session is admitted on its session token alone, so without the second - step a session started before regeneration would run for the rest of its token lifetime. - """ - new_token = secrets.token_urlsafe(24) - self.extra_data = {**self.extra_data, "widget_token": new_token} - self.save(update_fields=["extra_data"]) - self.end_live_sessions() - return new_token + """The shareable page for a public link channel, or "" when it has no token yet.""" + token = self.extra_data.get("widget_token") + if not token: + return "" + return absolute_url(reverse("public_link", args=[token])) def end_live_sessions(self) -> int: """Mark every non-complete session on this channel COMPLETE. Returns how many.""" diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index a80e3e0059..5bd2f38f7b 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -31,15 +31,6 @@ def test_public_url_is_the_absolute_token_route(public_channel): assert public_channel.public_url.startswith("http") -@pytest.mark.django_db() -def test_regenerate_replaces_the_token(public_channel): - new_token = public_channel.regenerate_widget_token() - public_channel.refresh_from_db() - assert new_token != TOKEN - assert len(new_token) == 32 - assert public_channel.extra_data["widget_token"] == new_token - - @pytest.mark.django_db() @pytest.mark.parametrize( "status", @@ -49,13 +40,13 @@ def test_regenerate_replaces_the_token(public_channel): pytest.param(SessionStatus.PENDING, id="pending"), ], ) -def test_regenerate_ends_live_sessions(public_channel, status): +def test_end_live_sessions_ends_each_live_status(public_channel, status): session = ExperimentSessionFactory.create( experiment=public_channel.experiment, experiment_channel=public_channel, status=status ) other = ExperimentSessionFactory.create(experiment=public_channel.experiment, status=SessionStatus.ACTIVE) - public_channel.regenerate_widget_token() + public_channel.end_live_sessions() session.refresh_from_db() other.refresh_from_db() diff --git a/templates/chatbots/components/channel_buttons.html b/templates/chatbots/components/channel_buttons.html index 6be1a0c358..cc721c3e56 100644 --- a/templates/chatbots/components/channel_buttons.html +++ b/templates/chatbots/components/channel_buttons.html @@ -24,7 +24,7 @@ {% endfor %} {% for channel in channels %} - {% if channel.platform_enum == "public" %} + {% if channel.platform_enum == "public" and channel.public_url %} {% with id_str=channel.id|stringformat:"s" %} {% with link_id="public-link-"|add:id_str %} {% include "generic/copy_chip.html" with copy_element_id=link_id copy_value=channel.public_url action_text="Copy link" extra_styles="btn-ghost" show_value=0 %} From 9d2b1a92ac17f073ca430045f9fd4f832151794a Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 14:39:12 +0200 Subject: [PATCH 21/37] Harden the public link after adversarial review Re-check the consent form per request so a consent form published mid-session ends the live public session with 409 consent_unavailable, end live sessions when a public channel is disabled or deleted (only regenerate did), drop blank lines from the welcome and starter lists instead of failing validation (a trailing newline blocked regenerate), pass user-id and user-name to team members only, keep the widget live for team members on refused states, include public channels in the outdated widget report, and pin the API-level revocation behaviour with a test. --- apps/api/tests/test_public_channel_start.py | 28 +++++++++++ apps/api/views/chat.py | 11 +++-- apps/channels/forms.py | 14 +++++- .../commands/list_outdated_widget_versions.py | 2 +- apps/channels/models.py | 2 + .../test_list_outdated_widget_versions.py | 11 +++++ apps/channels/tests/test_public_channel.py | 47 +++++++++++++++++++ apps/chatbots/public_link.py | 5 +- apps/chatbots/tests/test_public_link_page.py | 19 ++++++++ templates/chatbots/public_link.html | 2 +- 10 files changed, 132 insertions(+), 9 deletions(-) diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index bd336b693b..97d6afc7ca 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -189,3 +189,31 @@ def fake_delay(*args, **kwargs): assert response.status_code == 202, response.content published = channel.experiment.versions.get(is_default_version=True) assert seen["kwargs"]["experiment_id"] == published.id + + +@pytest.mark.django_db() +def test_send_refuses_once_a_consent_form_is_published(team_with_users): + channel = _public_channel(team_with_users) + client = APIClient() + started = _start(client, channel.experiment).json() + working = channel.experiment + working.consent_form = ConsentFormFactory.create(team=team_with_users) + working.save() + working.create_new_version(make_default=True) + response = _send(client, started["session_id"], started["session_token"]) + assert response.status_code == 409 + assert response.json()["code"] == "consent_unavailable" + + +@pytest.mark.django_db() +def test_regeneration_revokes_the_old_key_and_the_live_session(team_with_users): + channel = _public_channel(team_with_users) + client = APIClient() + started = _start(client, channel.experiment).json() + channel.extra_data["widget_token"] = "public_token_regenerated_00000000000" + channel.save() + channel.end_live_sessions() + assert _start(client, channel.experiment).status_code == 401 + response = _send(client, started["session_id"], started["session_token"]) + assert response.status_code == 400 + assert "ended" in response.json()["error"] diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index c994d3a291..de192c47cc 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -381,18 +381,21 @@ def _public_channel_refusal(request, experiment, experiment_channel) -> Response def _public_session_version(request, session) -> tuple[Experiment | None, Response | None]: """The version a request on `session` runs against, or a 409 for a public session whose - published version has gone. Other channels keep the published-or-working fallback, and so - do team members on a public channel so they can preview an unpublished chatbot through its - page.""" + published version has gone or now carries a consent form. Other channels keep the + published-or-working fallback, and so do team members on a public channel so they can + preview an unpublished chatbot through its page.""" channel = session.experiment_channel if channel is None or channel.platform != ChannelPlatform.PUBLIC: return session.experiment_version, None if _is_team_member(request, session.experiment): return session.experiment_version, None try: - return resolve_chatbot_version(session.experiment, VersionSelectionRule.LATEST_PUBLISHED), None + published = resolve_chatbot_version(session.experiment, VersionSelectionRule.LATEST_PUBLISHED) except NoPublishedVersion: return None, Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) + if published.consent_form_id: + return None, Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) + return published, None def _get_requested_version(experiment, version_number): diff --git a/apps/channels/forms.py b/apps/channels/forms.py index f230f22a7d..f942515669 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -772,8 +772,16 @@ def get_context(self, name, value, attrs): return context +class LinesField(SimpleArrayField): + """One entry per non-blank line; blank and trailing lines are dropped rather than rejected.""" + + def to_python(self, value): + lines = [line.strip() for line in (value or "").splitlines()] + return super().to_python("\n".join(line for line in lines if line)) + + def _lines_field(label: str, help_text: str, placeholder: str) -> SimpleArrayField: - return SimpleArrayField( + return LinesField( forms.CharField(max_length=500), delimiter="\n", required=False, @@ -804,6 +812,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.initial = dict(self.initial) self._previous_token = self.channel.extra_data.get("widget_token") if self.channel else None + self._previously_enabled = bool(self.channel and self.channel.enabled) if self._previous_token: self.initial["widget_token"] = self._previous_token self.fields["widget_token"].widget = PublicLinkParams(channel=self.channel) @@ -826,6 +835,9 @@ def post_save(self, channel: ExperimentChannel): self.success_message = ( f"Link regenerated. The old link no longer works and {ended} live conversation(s) were ended." ) + elif self._previously_enabled and not channel.enabled: + ended = channel.end_live_sessions() + self.success_message = f"Channel disabled. {ended} live conversation(s) were ended." else: self.success_message = "Channel saved successfully" diff --git a/apps/channels/management/commands/list_outdated_widget_versions.py b/apps/channels/management/commands/list_outdated_widget_versions.py index d5ecb32799..a90315ab18 100644 --- a/apps/channels/management/commands/list_outdated_widget_versions.py +++ b/apps/channels/management/commands/list_outdated_widget_versions.py @@ -79,7 +79,7 @@ def _collect_rows(self, cutoff, team_slug=None, deprecated_only=False, include_u .values("count")[:1] ) channels = ( - ExperimentChannel.objects.filter(platform=ChannelPlatform.EMBEDDED_WIDGET) + ExperimentChannel.objects.filter(platform__in=ChannelPlatform.widget_platforms()) .select_related("experiment", "team") .annotate(session_count=session_count) .filter(session_count__gt=0) diff --git a/apps/channels/models.py b/apps/channels/models.py index e9b8d4df7e..9b7ba57b15 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -535,5 +535,7 @@ def get_webhook_manager(self) -> "WebhookManager | None": return None def soft_delete(self): + if self.platform == ChannelPlatform.PUBLIC: + self.end_live_sessions() self.deleted = True self.save() diff --git a/apps/channels/tests/test_list_outdated_widget_versions.py b/apps/channels/tests/test_list_outdated_widget_versions.py index ca16085ac9..0a0f6a7a12 100644 --- a/apps/channels/tests/test_list_outdated_widget_versions.py +++ b/apps/channels/tests/test_list_outdated_widget_versions.py @@ -221,3 +221,14 @@ def test_csv_output(): @pytest.mark.django_db() def test_no_results_message(): assert "No active chatbots are running an outdated widget version." in _run() + + +@pytest.mark.django_db() +def test_public_link_channels_are_reported_too(): + channel = _widget_channel("0.7.0", platform=ChannelPlatform.PUBLIC) + _add_session(channel) + + output = _run() + + assert channel.experiment.name in output + assert "0.7.0" in output diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index 5bd2f38f7b..37222ceb49 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -186,3 +186,50 @@ def test_edit_dialog_regenerates_and_ends_sessions(client, public_channel, publi assert public_channel.extra_data["widget_token"] != TOKEN assert live.is_complete assert b"Link regenerated" in response.content + + +def test_blank_lines_are_dropped_from_the_lists(): + form = PublicChannelForm( + data={"welcome_messages": "Hello\r\n\r\nWorld\r\n", "starter_questions": " \r\nHours?\r\n"}, + experiment=Mock(), + ) + assert form.is_valid(), form.errors + assert form.cleaned_data["welcome_messages"] == ["Hello", "World"] + assert form.cleaned_data["starter_questions"] == ["Hours?"] + + +@pytest.mark.django_db() +def test_disabling_through_the_dialog_ends_live_sessions(client, public_channel, public_flag): + _operator(client, public_channel.team, "change_experimentchannel") + live = ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.ACTIVE + ) + url = reverse( + "channels:channel_edit_dialog", + args=[public_channel.team.slug, public_channel.experiment_id, public_channel.id], + ) + response = client.post( + url, + data={"name": public_channel.name, "platform": "public", "disabled_message": "Back soon"}, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200, response.content + public_channel.refresh_from_db() + live.refresh_from_db() + assert public_channel.is_disabled + assert public_channel.extra_data["widget_token"] == TOKEN + assert live.is_complete + + +@pytest.mark.django_db() +def test_removing_a_public_channel_ends_live_sessions(client, public_channel): + _operator(client, public_channel.team, "delete_experimentchannel") + live = ExperimentSessionFactory.create( + experiment=public_channel.experiment, experiment_channel=public_channel, status=SessionStatus.ACTIVE + ) + url = reverse( + "channels:delete_channel", args=[public_channel.team.slug, public_channel.experiment_id, public_channel.id] + ) + assert client.post(url).status_code == 200 + live.refresh_from_db() + assert live.is_complete diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index 2f4efd67ac..716f5f9549 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -68,7 +68,7 @@ def public_link_page(request, token: str): state, published = _page_state(channel) shown = published or channel.experiment - user = request.user if request.user.is_authenticated else None + member = request.user.is_authenticated and channel.team.members.filter(id=request.user.id).exists() response = TemplateResponse( request, "chatbots/public_link.html", @@ -82,7 +82,8 @@ def public_link_page(request, token: str): "api_base_url": get_server_root(), "welcome_json": json.dumps(channel.extra_data.get("welcome_messages", [])), "starters_json": json.dumps(channel.extra_data.get("starter_questions", [])), - "user": user, + "user": request.user if member else None, + "widget_enabled": state.live or member, }, ) response["X-Robots-Tag"] = "noindex" diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 7be8929841..6596419ce9 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -10,6 +10,7 @@ from apps.channels.models import ChannelPlatform from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory +from apps.utils.factories.user import UserFactory TOKEN = "public_token_1234567890123456789012" CANONICAL = "ocs.example.com" @@ -136,3 +137,21 @@ def test_chatbot_home_shows_a_copy_chip_for_the_public_link(client, team_with_us html = client.get(url, HTTP_HOST=CANONICAL).content.decode() assert channel.public_url in html assert f'{{ chatbot_name }} persistent-session="false" welcome-messages="{{ welcome_json }}" starter-questions="{{ starters_json }}" - {% if not state.live %}disabled="true"{% endif %} + {% if not widget_enabled %}disabled="true"{% endif %} {% if user %}user-id="{{ user.email }}" user-name="{{ user.get_full_name }}"{% endif %} >
From e870967c382e7ee5990397efeb2d3ba8bb7d68f1 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 14:48:15 +0200 Subject: [PATCH 22/37] Flatten the send-version and dropdown flag branches Pull the version_number handling in chat_send_message into _send_version, share the published-version lookup between the two public-link helpers, gate email and public in for_dropdown through one flag helper, and assert the public page CSP by value rather than by substring. --- apps/api/views/chat.py | 79 ++++++++++---------- apps/channels/models.py | 25 ++++--- apps/chatbots/tests/test_public_link_page.py | 4 +- 3 files changed, 55 insertions(+), 53 deletions(-) diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index de192c47cc..06240539b9 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -361,22 +361,27 @@ def _is_team_member(request, experiment) -> bool: return request.user.is_authenticated and experiment.team.members.filter(id=request.user.id).exists() -def _public_channel_refusal(request, experiment, experiment_channel) -> Response | None: - """A 409 when a public link cannot serve a visitor, else None. +def _published_public_version(experiment) -> tuple[Experiment | None, Response | None]: + """The version a public visitor may chat with, or the 409 that refuses them. Public visitors only ever reach the published version, and a consent-form chatbot has no - live link until consent moves into the widget. Team members are exempt so they can try the - page before publishing. + live link until consent moves into the widget. """ - if experiment_channel.platform != ChannelPlatform.PUBLIC or _is_team_member(request, experiment): - return None try: published = resolve_chatbot_version(experiment, VersionSelectionRule.LATEST_PUBLISHED) except NoPublishedVersion: - return Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) + return None, Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) if published.consent_form_id: - return Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) - return None + return None, Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) + return published, None + + +def _public_channel_refusal(request, experiment, experiment_channel) -> Response | None: + """A 409 when a public link cannot serve a visitor, else None. Team members are exempt so + they can try the page before publishing.""" + if experiment_channel.platform != ChannelPlatform.PUBLIC or _is_team_member(request, experiment): + return None + return _published_public_version(experiment)[1] def _public_session_version(request, session) -> tuple[Experiment | None, Response | None]: @@ -389,13 +394,30 @@ def _public_session_version(request, session) -> tuple[Experiment | None, Respon return session.experiment_version, None if _is_team_member(request, session.experiment): return session.experiment_version, None + return _published_public_version(session.experiment) + + +def _send_version(request, session, version_number) -> tuple[Experiment | None, Response | None]: + """The version a send runs against: an explicitly requested one for team members, else the + session's own (subject to the public-link rule).""" + if version_number is None: + return _public_session_version(request, session) + if refusal := _version_number_refusal(request, session): + return None, refusal + if version_number == Experiment.DEFAULT_VERSION_NUMBER: + return _public_session_version(request, session) try: - published = resolve_chatbot_version(session.experiment, VersionSelectionRule.LATEST_PUBLISHED) - except NoPublishedVersion: - return None, Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) - if published.consent_form_id: - return None, Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) - return published, None + return session.experiment.get_version(version_number), None + except Experiment.DoesNotExist: + raise NotFound(f"Experiment with version {version_number} not found") from None + + +def _version_number_refusal(request, session) -> Response | None: + if not request.user.is_authenticated: + return Response({"error": "Version number requires authentication"}, status=status.HTTP_403_FORBIDDEN) + if not session.experiment.team.members.filter(id=request.user.id).exists(): + return Response({"error": "You do not have access to this chatbot"}, status=status.HTTP_403_FORBIDDEN) + return None def _get_requested_version(experiment, version_number): @@ -683,30 +705,9 @@ def chat_send_message(request, session_id): if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) - if version_number is not None: - if not request.user.is_authenticated: - return Response( - {"error": "Version number requires authentication"}, - status=status.HTTP_403_FORBIDDEN, - ) - if not session.experiment.team.members.filter(id=request.user.id).exists(): - return Response( - {"error": "You do not have access to this chatbot"}, - status=status.HTTP_403_FORBIDDEN, - ) - if version_number != Experiment.DEFAULT_VERSION_NUMBER: - try: - experiment_version = session.experiment.get_version(version_number) - except Experiment.DoesNotExist: - raise NotFound(f"Experiment with version {version_number} not found") from None - else: - experiment_version, refusal = _public_session_version(request, session) - if refusal: - return refusal - else: - experiment_version, refusal = _public_session_version(request, session) - if refusal: - return refusal + experiment_version, refusal = _send_version(request, session, version_number) + if refusal: + return refusal attachment_data = [] if attachment_ids: diff --git a/apps/channels/models.py b/apps/channels/models.py index 9b7ba57b15..231ac748a6 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -78,18 +78,8 @@ def for_dropdown(cls, used_platforms, team) -> dict[Self, bool]: elif settings.COMMCARE_CONNECT_ENABLED: platform_availability[cls.COMMCARE_CONNECT] = True - flag = Flag.get("flag_email_channel") - email_flag_enabled = flag.is_active_for_team(team) - if not email_flag_enabled or not settings.EMAIL_CHANNEL_ALLOWED_DOMAINS: - platform_availability.pop(cls.EMAIL, None) - else: - platform_availability[cls.EMAIL] = True - - flag = Flag.get("flag_public_channel") - if flag.is_active_for_team(team): - platform_availability[cls.PUBLIC] = True - else: - platform_availability.pop(cls.PUBLIC, None) + cls._gate_by_flag(platform_availability, team, cls.EMAIL, "flag_email_channel") + cls._gate_by_flag(platform_availability, team, cls.PUBLIC, "flag_public_channel") # Platforms already used should not be displayed for platform in used_platforms: @@ -97,6 +87,17 @@ def for_dropdown(cls, used_platforms, team) -> dict[Self, bool]: return cast(dict[Self, bool], platform_availability) + @classmethod + def _gate_by_flag(cls, platform_availability: dict, team, platform, flag_name: str) -> None: + """Offer `platform` only when its flag is on for the team (and, for email, domains are configured).""" + offered = Flag.get(flag_name).is_active_for_team(team) + if platform == cls.EMAIL: + offered = offered and bool(settings.EMAIL_CHANNEL_ALLOWED_DOMAINS) + if offered: + platform_availability[platform] = True + else: + platform_availability.pop(platform, None) + def form(self, experiment: Experiment): from apps.channels.forms import ChannelForm # noqa: PLC0415 - circular: channels.forms imports channels.models diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 6596419ce9..cce4d0ad11 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -8,6 +8,7 @@ from django.urls import reverse from apps.channels.models import ChannelPlatform +from apps.chatbots.public_link import CSP from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory from apps.utils.factories.user import UserFactory @@ -62,8 +63,7 @@ def test_live_page_renders_the_kiosk_widget(client, team_with_users): assert "unpkg.com/open-chat-studio-widget" in html assert response["X-Robots-Tag"] == "noindex" assert response["Referrer-Policy"] == "origin" - assert "unpkg.com" in response["Content-Security-Policy"] - assert "cdnjs.cloudflare.com" in response["Content-Security-Policy"] + assert response["Content-Security-Policy"] == CSP @pytest.mark.django_db() From aa95a6de297acee450c0aeba6a856e66301d9231 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 17:48:55 +0200 Subject: [PATCH 23/37] Import the session models at module level in channels.models The inline-imports check showed the local import in end_live_sessions hoists cleanly; Experiment was already imported from the same module at the top of the file. --- apps/channels/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index 231ac748a6..b04aa68dce 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -14,7 +14,7 @@ from apps.channels import widget_versions from apps.experiments import model_audit_fields from apps.experiments.exceptions import ChannelAlreadyUtilizedException -from apps.experiments.models import Experiment +from apps.experiments.models import Experiment, ExperimentSession, SessionStatus from apps.teams.models import BaseTeamModel, Flag from apps.web.meta import absolute_url @@ -387,11 +387,6 @@ def public_url(self) -> str: def end_live_sessions(self) -> int: """Mark every non-complete session on this channel COMPLETE. Returns how many.""" - from apps.experiments.models import ( # noqa: PLC0415 - circular: experiments.models imports channels.models - ExperimentSession, - SessionStatus, - ) - ended = 0 now = timezone.now() for session in ExperimentSession.objects.filter(experiment_channel=self).exclude(status=SessionStatus.COMPLETE): From c6d1b13faf1207a215a7fa4a9468f1b845245416 Mon Sep 17 00:00:00 2001 From: barry47products Date: Wed, 26 Aug 2026 18:04:29 +0200 Subject: [PATCH 24/37] Describe the published version on public starts and parse hosts as authorities The start response for a public visitor now serializes the published version rather than the working chatbot. Channel save and post_save run in one transaction so a failure while ending live sessions rolls the token change back. Hostnames are read with one authority-aware helper on both the Site domain and the request host, so IPv6 literals compare whole. --- apps/api/tests/test_public_channel_origin.py | 15 ++++++++++++ apps/api/tests/test_public_channel_start.py | 12 ++++++++++ apps/api/views/chat.py | 14 +++++++----- apps/channels/forms.py | 9 ++++---- apps/channels/tests/test_public_channel.py | 24 +++++++++++++++++++- apps/chatbots/public_link.py | 4 ++-- apps/chatbots/tests/test_public_link_page.py | 16 +++++++++++++ apps/web/meta.py | 12 +++++++++- 8 files changed, 92 insertions(+), 14 deletions(-) diff --git a/apps/api/tests/test_public_channel_origin.py b/apps/api/tests/test_public_channel_origin.py index 6693dcacce..d90cfc8951 100644 --- a/apps/api/tests/test_public_channel_origin.py +++ b/apps/api/tests/test_public_channel_origin.py @@ -131,3 +131,18 @@ def test_logged_in_user_on_the_page_lands_on_the_public_channel(public_channel, assert response.status_code == 201, response.content session = ExperimentSession.objects.get(external_id=response.json()["session_id"]) assert session.experiment_channel == public_channel + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + ("origin", "allowed"), + [ + pytest.param("https://[2001:db8::1]:8443", True, id="same-ipv6-host"), + pytest.param("https://[2001:db8::1]", True, id="same-ipv6-host-no-port"), + pytest.param("https://[2001:db8::2]", False, id="other-ipv6-host"), + ], +) +def test_public_channel_origin_rule_on_an_ipv6_site(public_channel, origin, allowed): + Site.objects.filter(id=1).update(domain="[2001:db8::1]:8443") + Site.objects.clear_cache() + assert channel_origin_allowed(_request(origin=origin), public_channel) is allowed diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index 97d6afc7ca..9c51d9783e 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -217,3 +217,15 @@ def test_regeneration_revokes_the_old_key_and_the_live_session(team_with_users): response = _send(client, started["session_id"], started["session_token"]) assert response.status_code == 400 assert "ended" in response.json()["error"] + + +@pytest.mark.django_db() +def test_start_response_describes_the_published_version(team_with_users): + channel = _public_channel(team_with_users) + working = channel.experiment + working.name = "Renamed draft" + working.save() + response = _start(APIClient(), working) + assert response.status_code == 201, response.content + assert response.json()["chatbot"]["name"] == working.versions.get(is_default_version=True).name + assert response.json()["chatbot"]["name"] != "Renamed draft" diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 06240539b9..3755098960 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -376,12 +376,12 @@ def _published_public_version(experiment) -> tuple[Experiment | None, Response | return published, None -def _public_channel_refusal(request, experiment, experiment_channel) -> Response | None: - """A 409 when a public link cannot serve a visitor, else None. Team members are exempt so - they can try the page before publishing.""" +def _public_channel_admission(request, experiment, experiment_channel) -> tuple[Experiment | None, Response | None]: + """The published version a public visitor is admitted to, or the 409 that refuses them. + (None, None) for other channels and for team members, who may try the page before publishing.""" if experiment_channel.platform != ChannelPlatform.PUBLIC or _is_team_member(request, experiment): - return None - return _published_public_version(experiment)[1] + return None, None + return _published_public_version(experiment) def _public_session_version(request, session) -> tuple[Experiment | None, Response | None]: @@ -582,8 +582,10 @@ def chat_start_session(request): if disabled := _channel_disabled_response(experiment_channel): return disabled - if refusal := _public_channel_refusal(request, experiment, experiment_channel): + published, refusal = _public_channel_admission(request, experiment, experiment_channel) + if refusal: return refusal + experiment_version = experiment_version or published if request.user.is_authenticated: user = request.user diff --git a/apps/channels/forms.py b/apps/channels/forms.py index f942515669..6d99fa0ff3 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.forms import SimpleArrayField # ty: ignore[unresolved-import] from django.core.exceptions import ValidationError +from django.db import transaction from django.urls import reverse from telebot import TeleBot, apihelper @@ -78,10 +79,10 @@ def save(self, commit=True): if self.extra_form and self.extra_form.is_valid(): config_data = self.extra_form.cleaned_data - instance = self.channel_form.save(self.experiment, config_data) - - if self.extra_form and hasattr(self.extra_form, "post_save"): - self.extra_form.post_save(channel=instance) + with transaction.atomic(): + instance = self.channel_form.save(self.experiment, config_data) + if self.extra_form and hasattr(self.extra_form, "post_save"): + self.extra_form.post_save(channel=instance) return instance diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index 37222ceb49..b3d7d1a5fd 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -9,7 +9,7 @@ from django.urls import reverse from apps.channels.forms import PublicChannelForm -from apps.channels.models import ChannelPlatform +from apps.channels.models import ChannelPlatform, ExperimentChannel from apps.experiments.models import ExperimentSession, SessionStatus from apps.teams.models import Flag from apps.utils.factories.channels import ExperimentChannelFactory @@ -233,3 +233,25 @@ def test_removing_a_public_channel_ends_live_sessions(client, public_channel): assert client.post(url).status_code == 200 live.refresh_from_db() assert live.is_complete + + +@pytest.mark.django_db() +def test_regeneration_rolls_back_when_ending_sessions_fails(client, public_channel, public_flag, monkeypatch): + _operator(client, public_channel.team, "change_experimentchannel") + + def boom(self): + raise RuntimeError("sessions unavailable") + + monkeypatch.setattr(ExperimentChannel, "end_live_sessions", boom) + url = reverse( + "channels:channel_edit_dialog", + args=[public_channel.team.slug, public_channel.experiment_id, public_channel.id], + ) + with pytest.raises(RuntimeError): + client.post( + url, + data={"name": public_channel.name, "platform": "public", "enabled": "on", "regenerate_link": "1"}, + HTTP_HX_REQUEST="true", + ) + public_channel.refresh_from_db() + assert public_channel.extra_data["widget_token"] == TOKEN diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index 716f5f9549..b233ba546a 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -14,7 +14,7 @@ from apps.chatbots.version_resolver import NoPublishedVersion, VersionSelectionRule, resolve_chatbot_version from apps.experiments.models import Experiment from apps.experiments.rate_limit_keys import public_chat_rate_limited -from apps.web.meta import canonical_hostname, get_server_root +from apps.web.meta import canonical_hostname, get_server_root, hostname_of from apps.web.waf import WafRule, waf_allow CSP = ( @@ -56,7 +56,7 @@ def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | Non @waf_allow(WafRule.NoUserAgent_HEADER) @public_chat_rate_limited def public_link_page(request, token: str): - if request.get_host().split(":")[0].lower() != canonical_hostname(): + if hostname_of(request.get_host()) != canonical_hostname(): raise Http404() channel = ( ExperimentChannel.objects.select_related("experiment", "team") diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index cce4d0ad11..df5044f95d 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -155,3 +155,19 @@ def test_team_member_gets_a_live_widget_on_an_unpublished_chatbot(client, team_w html = _get(client).content.decode() assert "not published" in html.lower() assert 'disabled="true"' not in html + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + ("host", "expected_status"), + [ + pytest.param("[2001:db8::1]:8000", 200, id="same-ipv6-host"), + pytest.param("[2001:db8::2]:8000", 404, id="other-ipv6-host"), + ], +) +def test_page_host_check_on_an_ipv6_site(client, team_with_users, settings, host, expected_status): + settings.ALLOWED_HOSTS = ["[2001:db8::1]", "[2001:db8::2]"] + Site.objects.filter(id=1).update(domain="[2001:db8::1]:8000") + Site.objects.clear_cache() + _channel(team_with_users) + assert _get(client, host=host).status_code == expected_status diff --git a/apps/web/meta.py b/apps/web/meta.py index 945c29aa42..4a7c3dec97 100644 --- a/apps/web/meta.py +++ b/apps/web/meta.py @@ -1,7 +1,17 @@ +from urllib.parse import urlsplit + from django.conf import settings from django.contrib.sites.models import Site +def hostname_of(authority: str) -> str: + """The lowercase hostname in a `host[:port]` authority; IPv6 literals lose their brackets.""" + try: + return urlsplit(f"//{authority}").hostname or "" + except ValueError: + return "" + + def get_protocol(is_secure: bool = settings.USE_HTTPS_IN_ABSOLUTE_URLS) -> str: """ Returns the default protocol for the server ("http" or "https"). @@ -18,7 +28,7 @@ def get_server_root(is_secure: bool = settings.USE_HTTPS_IN_ABSOLUTE_URLS) -> st def canonical_hostname() -> str: """The hostname OCS is served from, for origin checks: the Site domain without a port.""" - return Site.objects.get_current().domain.split(":")[0].lower() + return hostname_of(Site.objects.get_current().domain) def absolute_url(relative_url: str, is_secure: bool = settings.USE_HTTPS_IN_ABSOLUTE_URLS): From f1280290d008337f9d6e763a41ee852d25ff76f1 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 12:45:45 +0200 Subject: [PATCH 25/37] Start logged-in non-members on the public channel as visitors The public page withholds user-id from anyone outside the team, so the widget sends a generated remote id while the browser still carries the OCS session cookie. The start view compared that id to the user's email and refused with 400. A non-member on the public channel now takes the anonymous path, matching what the page told the widget. --- apps/api/tests/test_public_channel_origin.py | 29 +++++++++++++++++--- apps/api/views/chat.py | 16 ++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/api/tests/test_public_channel_origin.py b/apps/api/tests/test_public_channel_origin.py index d90cfc8951..2b736429f8 100644 --- a/apps/api/tests/test_public_channel_origin.py +++ b/apps/api/tests/test_public_channel_origin.py @@ -115,10 +115,8 @@ def test_anonymous_start_from_a_foreign_origin_is_refused(public_channel): @pytest.mark.django_db() -@pytest.mark.parametrize("member", [pytest.param(True, id="team-member"), pytest.param(False, id="non-member")]) -def test_logged_in_user_on_the_page_lands_on_the_public_channel(public_channel, member): - team = public_channel.team - user = team.members.first() if member else UserFactory.create() +def test_logged_in_team_member_on_the_page_lands_on_the_public_channel(public_channel): + user = public_channel.team.members.first() client = APIClient() client.force_login(user) response = _start( @@ -131,6 +129,29 @@ def test_logged_in_user_on_the_page_lands_on_the_public_channel(public_channel, assert response.status_code == 201, response.content session = ExperimentSession.objects.get(external_id=response.json()["session_id"]) assert session.experiment_channel == public_channel + assert session.participant.identifier == user.email + + +@pytest.mark.django_db() +def test_logged_in_non_member_on_the_page_starts_as_a_public_visitor(public_channel): + """The page withholds user-id from non-members, so the widget sends a generated id while the + browser still carries the OCS session cookie. The start treats them as any other visitor.""" + user = UserFactory.create() + client = APIClient() + client.force_login(user) + response = _start( + client, + public_channel.experiment, + {"participant_remote_id": "ocs:1724750000000_abc123"}, + HTTP_X_EMBED_KEY=TOKEN, + HTTP_ORIGIN=f"https://{CANONICAL}", + ) + assert response.status_code == 201, response.content + session = ExperimentSession.objects.get(external_id=response.json()["session_id"]) + assert session.experiment_channel == public_channel + assert session.participant.user is None + assert session.participant.identifier != user.email + assert session.participant.platform == "public" @pytest.mark.django_db() diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 3755098960..531ba56cde 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -376,10 +376,17 @@ def _published_public_version(experiment) -> tuple[Experiment | None, Response | return published, None -def _public_channel_admission(request, experiment, experiment_channel) -> tuple[Experiment | None, Response | None]: +def _is_public_visitor(request, experiment, experiment_channel) -> bool: + """A caller on the public channel who is not a team member. The public page withholds + user-id from them, so they chat as an anonymous visitor even when the browser carries an + OCS session cookie; team members keep their identity so they can preview before publishing.""" + return experiment_channel.platform == ChannelPlatform.PUBLIC and not _is_team_member(request, experiment) + + +def _public_channel_admission(public_visitor: bool, experiment) -> tuple[Experiment | None, Response | None]: """The published version a public visitor is admitted to, or the 409 that refuses them. (None, None) for other channels and for team members, who may try the page before publishing.""" - if experiment_channel.platform != ChannelPlatform.PUBLIC or _is_team_member(request, experiment): + if not public_visitor: return None, None return _published_public_version(experiment) @@ -582,12 +589,13 @@ def chat_start_session(request): if disabled := _channel_disabled_response(experiment_channel): return disabled - published, refusal = _public_channel_admission(request, experiment, experiment_channel) + public_visitor = _is_public_visitor(request, experiment, experiment_channel) + published, refusal = _public_channel_admission(public_visitor, experiment) if refusal: return refusal experiment_version = experiment_version or published - if request.user.is_authenticated: + if request.user.is_authenticated and not public_visitor: user = request.user participant_id = user.email # Enforce this for authenticated users From 98ea43ed4a3bd256bd5d566cc6155ed5f5e99d1e Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 19:08:55 +0200 Subject: [PATCH 26/37] Keep the public link widget disabled for team members on a disabled channel The start API refuses a disabled channel before the team-member exemption, so the page must not offer a live composer either. Members still get the exemption for unpublished and consent-gated states. Claude-Session: https://claude.ai/code/session_01SjEDkJoMsjK21iEzaCixEZ --- apps/chatbots/public_link.py | 2 +- apps/chatbots/tests/test_public_link_page.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index b233ba546a..ee69f60c80 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -83,7 +83,7 @@ def public_link_page(request, token: str): "welcome_json": json.dumps(channel.extra_data.get("welcome_messages", [])), "starters_json": json.dumps(channel.extra_data.get("starter_questions", [])), "user": request.user if member else None, - "widget_enabled": state.live or member, + "widget_enabled": state.live or (member and state.code != "disabled"), }, ) response["X-Robots-Tag"] = "noindex" diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index df5044f95d..7df11b62ff 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -157,6 +157,15 @@ def test_team_member_gets_a_live_widget_on_an_unpublished_chatbot(client, team_w assert 'disabled="true"' not in html +@pytest.mark.django_db() +def test_team_member_gets_a_disabled_widget_on_a_disabled_channel(client, team_with_users): + _channel(team_with_users, enabled=False) + client.force_login(team_with_users.members.first()) + html = _get(client).content.decode() + assert "Back soon" in html + assert 'disabled="true"' in html + + @pytest.mark.django_db() @pytest.mark.parametrize( ("host", "expected_status"), From 12e6b94dca63130bafafe4dff543264e8bbac97c Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 19:08:56 +0200 Subject: [PATCH 27/37] Merge the bot_channels migration leaves after syncing with main Claude-Session: https://claude.ai/code/session_01SjEDkJoMsjK21iEzaCixEZ --- apps/channels/migrations/0035_merge_20260827_1703.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/channels/migrations/0035_merge_20260827_1703.py diff --git a/apps/channels/migrations/0035_merge_20260827_1703.py b/apps/channels/migrations/0035_merge_20260827_1703.py new file mode 100644 index 0000000000..9815eabd99 --- /dev/null +++ b/apps/channels/migrations/0035_merge_20260827_1703.py @@ -0,0 +1,12 @@ +# Generated by Django 5.2.16 on 2026-08-27 17:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("bot_channels", "0034_alter_experimentchannel_platform"), + ("bot_channels", "0034_notify_widget_version_release_0_12_0"), + ] + + operations = [] From 4f99fc07f94c4fdc5d6c26ed9e7e07ebc433853d Mon Sep 17 00:00:00 2001 From: barry47products Date: Fri, 28 Aug 2026 09:22:40 +0200 Subject: [PATCH 28/37] Keep a public link conversation to the tab that started it The 0.11.0 widget coerced any persistent-session string to true, so "false" stored the session in localStorage and a shared or kiosk browser handed the next visitor the previous one. Widget 0.12.0 is now LATEST_VERSION and parses "tab", which keeps the session in sessionStorage: it survives a reload and is cleared when the tab closes. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- apps/chatbots/tests/test_public_link_page.py | 3 +-- templates/chatbots/public_link.html | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 7df11b62ff..cf8f102e36 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -54,8 +54,7 @@ def test_live_page_renders_the_kiosk_widget(client, team_with_users): html = response.content.decode() assert 'mode="kiosk"' in html assert f'embed-key="{TOKEN}"' in html - assert 'persistent-session="false"' in html - assert 'persistent-session="tab"' not in html + assert 'persistent-session="tab"' in html assert f'api-base-url="https://{CANONICAL}"' in html or f'api-base-url="http://{CANONICAL}"' in html assert "Hi there" in html assert "Hours?" in html diff --git a/templates/chatbots/public_link.html b/templates/chatbots/public_link.html index 25942786c2..d93f88c87e 100644 --- a/templates/chatbots/public_link.html +++ b/templates/chatbots/public_link.html @@ -23,13 +23,12 @@

{{ chatbot_name }}

{% endif %}
- {# "tab" once widget 0.12.0 is LATEST_VERSION: the 0.11.0 widget coerces any string to true #} Date: Fri, 28 Aug 2026 09:34:02 +0200 Subject: [PATCH 29/37] Leave public link channels out of the outdated widget report A public link serves the widget bundled with the platform, so its version moves with the deploy and a team has nothing to upgrade. Reporting it gives whoever runs the command a row they cannot act on. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- .../management/commands/list_outdated_widget_versions.py | 2 +- apps/channels/tests/test_list_outdated_widget_versions.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/channels/management/commands/list_outdated_widget_versions.py b/apps/channels/management/commands/list_outdated_widget_versions.py index a90315ab18..d5ecb32799 100644 --- a/apps/channels/management/commands/list_outdated_widget_versions.py +++ b/apps/channels/management/commands/list_outdated_widget_versions.py @@ -79,7 +79,7 @@ def _collect_rows(self, cutoff, team_slug=None, deprecated_only=False, include_u .values("count")[:1] ) channels = ( - ExperimentChannel.objects.filter(platform__in=ChannelPlatform.widget_platforms()) + ExperimentChannel.objects.filter(platform=ChannelPlatform.EMBEDDED_WIDGET) .select_related("experiment", "team") .annotate(session_count=session_count) .filter(session_count__gt=0) diff --git a/apps/channels/tests/test_list_outdated_widget_versions.py b/apps/channels/tests/test_list_outdated_widget_versions.py index 0a0f6a7a12..c2e5445ae5 100644 --- a/apps/channels/tests/test_list_outdated_widget_versions.py +++ b/apps/channels/tests/test_list_outdated_widget_versions.py @@ -224,11 +224,13 @@ def test_no_results_message(): @pytest.mark.django_db() -def test_public_link_channels_are_reported_too(): +def test_public_link_channels_are_left_out(): + """A public link serves the widget bundled with the platform, so its version moves with + the deploy rather than with anything the team can upgrade.""" channel = _widget_channel("0.7.0", platform=ChannelPlatform.PUBLIC) _add_session(channel) output = _run() - assert channel.experiment.name in output - assert "0.7.0" in output + assert channel.experiment.name not in output + assert "No active chatbots are running an outdated widget version." in output From d0509ce597344e6261f12bb06c1c6725c4d97cef Mon Sep 17 00:00:00 2001 From: barry47products Date: Fri, 28 Aug 2026 09:40:49 +0200 Subject: [PATCH 30/37] Keep the widget version surfaces to the embedded widget widget_update_status, min_widget_version and pending_min_widget_version all describe a widget the host site loads and the team can upgrade. A public link serves the widget bundled with the platform, so its version follows the deploy and these three name something the team has no way to change. widget_auth_level stays on both widget platforms: the public channel authenticates with an embed key and session token, so apps.api.permissions and the chat views read it. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- apps/channels/models.py | 14 ++++++++++---- apps/channels/tests/test_widget_versions.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/channels/models.py b/apps/channels/models.py index b04aa68dce..e1369c2306 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -362,7 +362,13 @@ def is_disabled(self) -> bool: @property def widget_update_status(self) -> widget_versions.WidgetUpdateStatus | None: - if self.platform_enum not in ChannelPlatform.widget_platforms(): + """The update badge for an embedded widget, or None for every other platform. + + Only the embedded widget is loaded by the host site, so only its team can act on the + badge. A public link serves the widget bundled with the platform, whose version moves + with the deploy. + """ + if self.platform_enum != ChannelPlatform.EMBEDDED_WIDGET: return None return widget_versions.get_widget_update_status(self.widget_version) @@ -400,9 +406,9 @@ def end_live_sessions(self) -> int: def min_widget_version(self) -> str | None: """Minimum widget version required by this channel's current auth level. - None for non-widget channels or a NONE-level widget channel (no floor). + None for anything but an embedded widget, and for a NONE-level one (no floor). """ - if self.platform_enum not in ChannelPlatform.widget_platforms(): + if self.platform_enum != ChannelPlatform.EMBEDDED_WIDGET: return None level = self.widget_auth_level if level is None: @@ -412,7 +418,7 @@ def min_widget_version(self) -> str | None: @property def pending_min_widget_version(self) -> str | None: """Minimum widget version the pending auth level will require, if a bump is pending.""" - if self.platform_enum not in ChannelPlatform.widget_platforms(): + if self.platform_enum != ChannelPlatform.EMBEDDED_WIDGET: return None if self.pending_auth_level is None: return None diff --git a/apps/channels/tests/test_widget_versions.py b/apps/channels/tests/test_widget_versions.py index 5576dae8fb..8133e4e8cd 100644 --- a/apps/channels/tests/test_widget_versions.py +++ b/apps/channels/tests/test_widget_versions.py @@ -7,7 +7,7 @@ from field_audit.models import AuditAction, AuditEvent from apps.channels.forms import WidgetParams -from apps.channels.models import ChannelPlatform +from apps.channels.models import ChannelPlatform, WidgetAuthLevel from apps.channels.widget_versions import ( LATEST_VERSION, UNKNOWN_WIDGET_VERSION, @@ -241,6 +241,16 @@ def test_non_widget_channel(self): channel = ExperimentChannelFactory() # telegram assert channel.widget_update_status is None + def test_public_link_channel(self): + """A public link serves the widget bundled with the platform. Its version follows the + deploy, so a badge, a minimum version and a pending minimum all name something the team + has no way to change.""" + channel = ExperimentChannelFactory(platform=ChannelPlatform.PUBLIC, widget_version="0.1.0") + channel.pending_auth_level = WidgetAuthLevel.SESSION_TOKEN + assert channel.widget_update_status is None + assert channel.min_widget_version is None + assert channel.pending_min_widget_version is None + @pytest.mark.django_db() def test_widget_params_context_includes_version_info(widget_channel): From 45d27478625648aeaab66e44ea51ceeac2cf30c6 Mon Sep 17 00:00:00 2001 From: barry47products Date: Fri, 28 Aug 2026 10:14:31 +0200 Subject: [PATCH 31/37] Tell a signed-in viewer why a public link 404s on the wrong host A public link served from a non-canonical host returned a bare 404, which reads the same as a mistyped token, so a misconfigured deployment gives whoever is testing it nothing to work from. Follows the TeamAccessDenied convention: a tagged Http404 subclass that 404.html recognises through the exception context variable. A signed-in viewer is named both hosts; an anonymous visitor still gets the bare 404, so nothing about the deployment reaches the public and the request is still refused before any lookup. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- apps/chatbots/public_link.py | 10 +++++++- apps/chatbots/tests/test_public_link_page.py | 26 ++++++++++++++++++++ templates/404.html | 10 ++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index ee69f60c80..03a2d5636c 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -30,6 +30,14 @@ ) +class PublicLinkWrongHost(Http404): + """'Tagged' 404 that lets 404.html name the host public links are served from. + + A link tried on the wrong host is indistinguishable from a mistyped token without it. + Only a signed-in viewer is told the difference. See 404.html. + """ + + @dataclass(frozen=True) class PageState: code: str @@ -57,7 +65,7 @@ def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | Non @public_chat_rate_limited def public_link_page(request, token: str): if hostname_of(request.get_host()) != canonical_hostname(): - raise Http404() + raise PublicLinkWrongHost() if request.user.is_authenticated else Http404() channel = ( ExperimentChannel.objects.select_related("experiment", "team") .filter(platform=ChannelPlatform.PUBLIC, extra_data__widget_token=token) diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index cf8f102e36..8e3870bb08 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -95,6 +95,32 @@ def test_non_canonical_host_is_404(client, team_with_users): assert _get(client, host="other.example.com").status_code == 404 +@pytest.mark.django_db() +def test_non_canonical_host_names_both_hosts_for_a_signed_in_user(client, team_with_users): + """A link tried on the wrong host looks the same as a typo without this. Signing in is + what separates someone debugging the deployment from a passing visitor.""" + _channel(team_with_users) + client.force_login(team_with_users.members.first()) + + response = _get(client, host="other.example.com") + + assert response.status_code == 404 + html = response.content.decode() + assert "Public links are served from" in html + assert "other.example.com" in html + assert CANONICAL in html + + +@pytest.mark.django_db() +def test_non_canonical_host_tells_an_anonymous_visitor_nothing(client, team_with_users): + _channel(team_with_users) + + response = _get(client, host="other.example.com") + + assert response.status_code == 404 + assert "Public links are served from" not in response.content.decode() + + @pytest.mark.django_db() def test_deleted_channel_is_404(client, team_with_users): channel = _channel(team_with_users) diff --git a/templates/404.html b/templates/404.html index a15b1551fc..583ce5e1f6 100644 --- a/templates/404.html +++ b/templates/404.html @@ -9,6 +9,16 @@

{% translate "Shucks. We couldn't find that." %}

{% translate "If you think this page should exist, double-check that you are signed in as the right person." %}

+ {% if request.user.is_authenticated and exception == "PublicLinkWrongHost" %} +
+
+

+ Public links are served from {{ server_url }}. + You reached this one on {{ request.get_host }}. +

+
+
+ {% endif %} {% if request.user.is_superuser and exception == "TeamAccessDenied" and request.resolver_match.kwargs.team_slug %}
From 971b841f04b8aa5a481292256156c8379adaedd1 Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 07:44:07 +0200 Subject: [PATCH 32/37] Name the published chatbot on a switched-off public link _page_state returned early on a disabled channel without resolving the published version, so the view fell back to the working version for the page title and description. A team that renamed its draft after publishing had that name shown to anonymous visitors whenever the channel was switched off, which contradicts the published-version-only rule the start API enforces. Resolving the published version before any refusal keeps every state naming the chatbot a visitor could have reached. The existing tests could not catch this: the helper renames before create_new_version, so draft and published names were always identical. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- apps/chatbots/public_link.py | 11 ++++++++-- apps/chatbots/tests/test_public_link_page.py | 21 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index 03a2d5636c..465eabaf5f 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -49,11 +49,18 @@ def live(self) -> bool: def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | None]: - if channel.is_disabled: - return PageState("disabled", channel.disabled_message or "This chatbot is temporarily unavailable."), None + """The banner a visitor sees, and the version whose name and description the page shows. + + The published version is resolved before any refusal so that every state names the chatbot + the visitor could have reached. A draft that has since been renamed stays internal. + """ try: published = resolve_chatbot_version(channel.experiment, VersionSelectionRule.LATEST_PUBLISHED) except NoPublishedVersion: + published = None + if channel.is_disabled: + return PageState("disabled", channel.disabled_message or "This chatbot is temporarily unavailable."), published + if published is None: return PageState("no_published_version", "This chatbot is not published yet."), None if published.consent_form_id: banner = "This chatbot needs your consent, which the public link cannot collect yet." diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 8e3870bb08..5bf1d7f6e2 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -83,6 +83,27 @@ def test_refused_states_render_a_banner_and_a_disabled_widget(client, team_with_ assert 'disabled="true"' in html +@pytest.mark.django_db() +@pytest.mark.parametrize( + "enabled", + [pytest.param(True, id="live"), pytest.param(False, id="disabled")], +) +def test_the_page_names_the_published_chatbot_not_the_draft(client, team_with_users, enabled): + """A draft moves on after publishing. Visitors only ever reach the published version, so a + later rename stays internal whether the channel is serving or switched off.""" + channel = _channel(team_with_users, enabled=enabled) + working = channel.experiment + working.name = "Internal rename" + working.description = "Notes for the team" + working.save() + + html = _get(client).content.decode() + + assert "Internal rename" not in html + assert "Notes for the team" not in html + assert "Clinic bot" in html + + @pytest.mark.django_db() def test_unknown_token_is_404(client, team_with_users): _channel(team_with_users) From a4d86fbc97c9722e039a9521f89b2cb6c1f546c9 Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 07:44:50 +0200 Subject: [PATCH 33/37] Assert the rendered host sentence rather than a bare substring CodeQL flagged the bare "other.example.com in html" check as incomplete URL substring sanitization. It is a test assertion rather than sanitization, so nothing was exploitable, but a substring that loose also passes when the host appears anywhere on the page. Asserting the surrounding markup pins the host to the sentence meant to carry it, and deriving the canonical root from get_server_root() keeps the assertion correct whichever way USE_HTTPS_IN_ABSOLUTE_URLS is set. Claude-Session: https://claude.ai/code/session_014quZadopDrfAzty9X1Sm5D --- apps/chatbots/tests/test_public_link_page.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index 5bf1d7f6e2..b39f1290f5 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -12,16 +12,18 @@ from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory from apps.utils.factories.user import UserFactory +from apps.web.meta import get_server_root TOKEN = "public_token_1234567890123456789012" CANONICAL = "ocs.example.com" +OTHER_HOST = "other.example.com" @pytest.fixture(autouse=True) def _canonical_site(db, settings): Site.objects.filter(id=1).update(domain=CANONICAL) Site.objects.clear_cache() - settings.ALLOWED_HOSTS = [CANONICAL, "other.example.com", "testserver"] + settings.ALLOWED_HOSTS = [CANONICAL, OTHER_HOST, "testserver"] yield Site.objects.clear_cache() @@ -113,7 +115,7 @@ def test_unknown_token_is_404(client, team_with_users): @pytest.mark.django_db() def test_non_canonical_host_is_404(client, team_with_users): _channel(team_with_users) - assert _get(client, host="other.example.com").status_code == 404 + assert _get(client, host=OTHER_HOST).status_code == 404 @pytest.mark.django_db() @@ -123,20 +125,19 @@ def test_non_canonical_host_names_both_hosts_for_a_signed_in_user(client, team_w _channel(team_with_users) client.force_login(team_with_users.members.first()) - response = _get(client, host="other.example.com") + response = _get(client, host=OTHER_HOST) assert response.status_code == 404 html = response.content.decode() - assert "Public links are served from" in html - assert "other.example.com" in html - assert CANONICAL in html + assert f'You reached this one on {OTHER_HOST}' in html + assert f'Public links are served from {get_server_root()}' in html @pytest.mark.django_db() def test_non_canonical_host_tells_an_anonymous_visitor_nothing(client, team_with_users): _channel(team_with_users) - response = _get(client, host="other.example.com") + response = _get(client, host=OTHER_HOST) assert response.status_code == 404 assert "Public links are served from" not in response.content.decode() From 19e4d3021a2ace121a20d8691f85cf39b6358362 Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 08:36:44 +0200 Subject: [PATCH 34/37] Stop gating the public link on a consent form The start-session and send paths refused a public visitor with a 409 when the published version carried a consent form, and the page showed a matching banner. No other channel reads consent_form in the Chat API, so the public link was the only surface applying it, and a chatbot carrying its team's form would have had a link that never opened. The page banner goes with the API refusal. Keeping it would show a visitor a refusal on a page whose widget the API now serves. 409 on start-session is no_published_version only, and the schema description follows. Tests cover the new behaviour on both paths: a consent-form chatbot starts a session and receives a session token, a send continues after a consent form is published mid-session, and the page renders the kiosk widget with no banner. Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK --- api-schemas/v1.yml | 2 +- apps/api/tests/test_public_channel_start.py | 20 ++++++++++++-------- apps/api/views/chat.py | 19 +++++-------------- apps/chatbots/public_link.py | 3 --- apps/chatbots/tests/test_public_link_page.py | 10 +++++++++- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index 3e722abf29..b6219e6e8c 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -1187,7 +1187,7 @@ components: type: string code: type: string - description: '`no_published_version` or `consent_unavailable`.' + description: Always `no_published_version`. required: - code - error diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index 9c51d9783e..f4e618d335 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -1,7 +1,6 @@ """Start-session guards for the public channel (spec D4). -The embed key is in page source, so the API enforces: only a published version is served, and a -consent-form chatbot has no live link until the consent work (step 3) ships. +The embed key is in page source, so the API serves only the published version. """ from unittest import mock @@ -75,11 +74,14 @@ def test_unpublished_public_chatbot_refuses_with_409(team_with_users): @pytest.mark.django_db() -def test_consent_form_chatbot_refuses_with_409_until_step_3(team_with_users): +def test_consent_form_chatbot_starts_like_any_other(team_with_users): + """A consent form does not gate the public link. No other channel collects consent through + the Chat API, and most chatbots carry their team's form, so gating here would leave them + with a link that never opens.""" channel = _public_channel(team_with_users, consent=True) response = _start(APIClient(), channel.experiment) - assert response.status_code == 409 - assert response.json()["code"] == "consent_unavailable" + assert response.status_code == 201, response.content + assert response.json()["session_token"] @pytest.mark.django_db() @@ -192,8 +194,11 @@ def fake_delay(*args, **kwargs): @pytest.mark.django_db() -def test_send_refuses_once_a_consent_form_is_published(team_with_users): +def test_send_continues_once_a_consent_form_is_published(team_with_users, monkeypatch): channel = _public_channel(team_with_users) + monkeypatch.setattr( + chat_views.get_response_for_webchat_task, "delay", lambda *a, **k: mock.Mock(task_id="consent-send-test") + ) client = APIClient() started = _start(client, channel.experiment).json() working = channel.experiment @@ -201,8 +206,7 @@ def test_send_refuses_once_a_consent_form_is_published(team_with_users): working.save() working.create_new_version(make_default=True) response = _send(client, started["session_id"], started["session_token"]) - assert response.status_code == 409 - assert response.json()["code"] == "consent_unavailable" + assert response.status_code == 202, response.content @pytest.mark.django_db() diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 651c2ecafa..63e9acd27d 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -360,10 +360,6 @@ def _channel_disabled_response(experiment_channel) -> Response | None: NO_PUBLISHED_VERSION = {"error": "This chatbot has no published version", "code": "no_published_version"} -CONSENT_UNAVAILABLE = { - "error": "This chatbot requires consent, which the public link cannot collect yet", - "code": "consent_unavailable", -} def _is_team_member(request, experiment) -> bool: @@ -373,15 +369,12 @@ def _is_team_member(request, experiment) -> bool: def _published_public_version(experiment) -> tuple[Experiment | None, Response | None]: """The version a public visitor may chat with, or the 409 that refuses them. - Public visitors only ever reach the published version, and a consent-form chatbot has no - live link until consent moves into the widget. + Public visitors only ever reach the published version. """ try: published = resolve_chatbot_version(experiment, VersionSelectionRule.LATEST_PUBLISHED) except NoPublishedVersion: return None, Response(NO_PUBLISHED_VERSION, status=status.HTTP_409_CONFLICT) - if published.consent_form_id: - return None, Response(CONSENT_UNAVAILABLE, status=status.HTTP_409_CONFLICT) return published, None @@ -402,9 +395,8 @@ def _public_channel_admission(public_visitor: bool, experiment) -> tuple[Experim def _public_session_version(request, session) -> tuple[Experiment | None, Response | None]: """The version a request on `session` runs against, or a 409 for a public session whose - published version has gone or now carries a consent form. Other channels keep the - published-or-working fallback, and so do team members on a public channel so they can - preview an unpublished chatbot through its page.""" + published version has gone. Other channels keep the published-or-working fallback, and so do + team members on a public channel so they can preview an unpublished chatbot through its page.""" channel = session.experiment_channel if channel is None or channel.platform != ChannelPlatform.PUBLIC: return session.experiment_version, None @@ -488,13 +480,12 @@ def _resolve_experiment_channel(request, team, session_data, embed_key_channel, "code": serializers.CharField(help_text="Always `chat_access_denied`."), }, ), - # Public-channel admission: no published version yet, or the chatbot has a consent form - # the public link cannot collect (`no_published_version` / `consent_unavailable`). + # Public-channel admission: the chatbot has no published version yet. 409: inline_serializer( "ChatStartSessionRefused", { "error": serializers.CharField(), - "code": serializers.CharField(help_text="`no_published_version` or `consent_unavailable`."), + "code": serializers.CharField(help_text="Always `no_published_version`."), }, ), }, diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index 465eabaf5f..4bb04f5396 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -62,9 +62,6 @@ def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | Non return PageState("disabled", channel.disabled_message or "This chatbot is temporarily unavailable."), published if published is None: return PageState("no_published_version", "This chatbot is not published yet."), None - if published.consent_form_id: - banner = "This chatbot needs your consent, which the public link cannot collect yet." - return PageState("consent_unavailable", banner), published return PageState("live", None), published diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index b39f1290f5..a7cba5c55c 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -73,7 +73,6 @@ def test_live_page_renders_the_kiosk_widget(client, team_with_users): [ pytest.param({"enabled": False}, "Back soon", id="disabled"), pytest.param({"publish": False}, "not published", id="no-published-version"), - pytest.param({"consent": True}, "consent", id="consent-unavailable"), ], ) def test_refused_states_render_a_banner_and_a_disabled_widget(client, team_with_users, kwargs, banner): @@ -85,6 +84,15 @@ def test_refused_states_render_a_banner_and_a_disabled_widget(client, team_with_ assert 'disabled="true"' in html +@pytest.mark.django_db() +def test_a_consent_form_chatbot_serves_the_kiosk_widget(client, team_with_users): + """A consent form does not gate the public link, so the page carries no banner for it.""" + _channel(team_with_users, consent=True) + html = _get(client).content.decode() + assert 'mode="kiosk"' in html + assert 'disabled="true"' not in html + + @pytest.mark.django_db() @pytest.mark.parametrize( "enabled", From aaac6feeea3d588d226149e0c99a18918a89dfaf Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 09:33:01 +0200 Subject: [PATCH 35/37] Share the public channel flag fixture across the channels tests Two copies of the same fixture, one per test module, each creating the flag row and adding the team. They move to the package conftest alongside the other shared channel fixtures. The docstring records why waffle's override_flag does not replace this: the platform gate reads Flag.is_active_for_team, which matches on the team M2M and never on everyone, the only field override_flag sets. A test decorated with it would assert the opposite of what it means. Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK --- apps/channels/tests/conftest.py | 14 ++++++++++++++ apps/channels/tests/test_models.py | 11 ++--------- apps/channels/tests/test_public_channel.py | 9 --------- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/channels/tests/conftest.py b/apps/channels/tests/conftest.py index c1206e9c65..cbb6899f3d 100644 --- a/apps/channels/tests/conftest.py +++ b/apps/channels/tests/conftest.py @@ -7,6 +7,7 @@ from apps.channels.models import ChannelPlatform from apps.service_providers.models import MessagingProviderType +from apps.teams.models import Flag from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.service_provider_factories import MessagingProviderFactory @@ -105,3 +106,16 @@ def meta_cloud_api_whatsapp_channel(meta_cloud_api_provider): experiment__team=meta_cloud_api_provider.team, extra_data={"number": "+15551234567", "phone_number_id": "12345"}, ) + + +@pytest.fixture() +def public_flag(experiment): + """Turns on `flag_public_channel` for the experiment's team. + + The platform gate reads `Flag.is_active_for_team`, which matches on the team M2M and never + on `everyone`, so `override_flag` leaves the flag off here. + """ + flag = Flag.objects.create(name="flag_public_channel") + flag.teams.add(experiment.team) + flag.flush() + return flag diff --git a/apps/channels/tests/test_models.py b/apps/channels/tests/test_models.py index 32e511a946..2048f995e1 100644 --- a/apps/channels/tests/test_models.py +++ b/apps/channels/tests/test_models.py @@ -288,13 +288,6 @@ def test_webhook_url_for_telegram_channel(): class TestPublicChannelPlatform: """PUBLIC is a widget platform, one per chatbot, offered only behind flag_public_channel.""" - @pytest.fixture() - def public_flag_enabled(self, experiment): - flag = Flag.objects.create(name="flag_public_channel") - flag.teams.add(experiment.team) - flag.flush() - return flag - def test_widget_platforms_are_the_two_widget_served_platforms(self): assert ChannelPlatform.widget_platforms() == [ChannelPlatform.EMBEDDED_WIDGET, ChannelPlatform.PUBLIC] @@ -302,11 +295,11 @@ def test_public_hidden_when_flag_off(self, experiment): platforms = ChannelPlatform.for_dropdown(used_platforms=set(), team=experiment.team) assert ChannelPlatform.PUBLIC not in platforms - def test_public_available_when_flag_on(self, experiment, public_flag_enabled): + def test_public_available_when_flag_on(self, experiment, public_flag): platforms = ChannelPlatform.for_dropdown(used_platforms=set(), team=experiment.team) assert platforms[ChannelPlatform.PUBLIC] is True - def test_public_hidden_once_used(self, experiment, public_flag_enabled): + def test_public_hidden_once_used(self, experiment, public_flag): platforms = ChannelPlatform.for_dropdown(used_platforms={ChannelPlatform.PUBLIC}, team=experiment.team) assert ChannelPlatform.PUBLIC not in platforms diff --git a/apps/channels/tests/test_public_channel.py b/apps/channels/tests/test_public_channel.py index b3d7d1a5fd..691419e2f4 100644 --- a/apps/channels/tests/test_public_channel.py +++ b/apps/channels/tests/test_public_channel.py @@ -11,7 +11,6 @@ from apps.channels.forms import PublicChannelForm from apps.channels.models import ChannelPlatform, ExperimentChannel from apps.experiments.models import ExperimentSession, SessionStatus -from apps.teams.models import Flag from apps.utils.factories.channels import ExperimentChannelFactory from apps.utils.factories.experiment import ExperimentSessionFactory @@ -127,14 +126,6 @@ def _operator(client, team, codename): return user -@pytest.fixture() -def public_flag(experiment): - flag = Flag.objects.create(name="flag_public_channel") - flag.teams.add(experiment.team) - flag.flush() - return flag - - @pytest.mark.django_db() def test_create_dialog_makes_a_public_channel_with_a_token(client, experiment, public_flag): _operator(client, experiment.team, "add_experimentchannel") From 6b0c6aef87bbe828107e99255a0fda449a1b5e44 Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 09:46:26 +0200 Subject: [PATCH 36/37] Drop an unverified claim from the consent test docstring The docstring said most chatbots carry their team's consent form. Nothing establishes that: ChatbotForm never sets consent_form, so a chatbot created through the UI starts without one. The point the test makes does not need a frequency claim. Gating the public link on a consent form would refuse any chatbot that carries one, and no other channel collects consent through the Chat API. Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK --- apps/api/tests/test_public_channel_start.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/tests/test_public_channel_start.py b/apps/api/tests/test_public_channel_start.py index f4e618d335..639599137e 100644 --- a/apps/api/tests/test_public_channel_start.py +++ b/apps/api/tests/test_public_channel_start.py @@ -76,8 +76,8 @@ def test_unpublished_public_chatbot_refuses_with_409(team_with_users): @pytest.mark.django_db() def test_consent_form_chatbot_starts_like_any_other(team_with_users): """A consent form does not gate the public link. No other channel collects consent through - the Chat API, and most chatbots carry their team's form, so gating here would leave them - with a link that never opens.""" + the Chat API, so gating here would refuse any chatbot that carries a form, leaving it with + a link that never opens.""" channel = _public_channel(team_with_users, consent=True) response = _start(APIClient(), channel.experiment) assert response.status_code == 201, response.content From ceccdb76a1e5067f1a25ac2f7587bf46391099fa Mon Sep 17 00:00:00 2001 From: barry47products Date: Mon, 31 Aug 2026 10:21:54 +0200 Subject: [PATCH 37/37] Withhold an unpublished draft's name from public link visitors The page fell back to the working version whenever no published version existed, so a builder who shared a link and then renamed the draft to something internal had that name rendered in the title and heading for anyone holding the token. Two paths reached it: a channel that has never been published, and one that is both disabled and never published. Anonymous visitors now get a placeholder name and no description. Team members keep the draft's name, since they use this page to preview before publishing and the placeholder would hide what they came to look at. The docstring on _page_state claimed every state names the chatbot the visitor could have reached. That was stronger than the code could deliver on a branch with no published row, so it now says what actually holds. Claude-Session: https://claude.ai/code/session_01GGtbSvKvUsS1i8mvwiQBuK --- apps/chatbots/public_link.py | 17 +++++---- apps/chatbots/tests/test_public_link_page.py | 37 ++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/chatbots/public_link.py b/apps/chatbots/public_link.py index 4bb04f5396..31443ce557 100644 --- a/apps/chatbots/public_link.py +++ b/apps/chatbots/public_link.py @@ -17,6 +17,8 @@ from apps.web.meta import canonical_hostname, get_server_root, hostname_of from apps.web.waf import WafRule, waf_allow +PLACEHOLDER_NAME = "Chatbot" + CSP = ( "default-src 'self'; " "script-src 'self' https://unpkg.com; " @@ -49,10 +51,11 @@ def live(self) -> bool: def _page_state(channel: ExperimentChannel) -> tuple[PageState, Experiment | None]: - """The banner a visitor sees, and the version whose name and description the page shows. + """The banner a visitor sees, and the published version, or None when there is none. - The published version is resolved before any refusal so that every state names the chatbot - the visitor could have reached. A draft that has since been renamed stays internal. + The published version is resolved before any refusal so that a state which has one names it, + rather than the draft the team has moved on to. A draft that has since been renamed stays + internal, and so does one that has never been published at all. """ try: published = resolve_chatbot_version(channel.experiment, VersionSelectionRule.LATEST_PUBLISHED) @@ -79,16 +82,18 @@ def public_link_page(request, token: str): raise Http404() state, published = _page_state(channel) - shown = published or channel.experiment member = request.user.is_authenticated and channel.team.members.filter(id=request.user.id).exists() + # With no published version there is no name a visitor is entitled to. Members previewing the + # page before publishing keep the draft's, since the placeholder would hide what they came for. + shown = published or (channel.experiment if member else None) response = TemplateResponse( request, "chatbots/public_link.html", { "channel": channel, "state": state, - "chatbot_name": shown.name, - "chatbot_description": shown.description, + "chatbot_name": shown.name if shown else PLACEHOLDER_NAME, + "chatbot_description": shown.description if shown else "", "public_id": channel.experiment.public_id, "token": token, "api_base_url": get_server_root(), diff --git a/apps/chatbots/tests/test_public_link_page.py b/apps/chatbots/tests/test_public_link_page.py index a7cba5c55c..6dfa12a638 100644 --- a/apps/chatbots/tests/test_public_link_page.py +++ b/apps/chatbots/tests/test_public_link_page.py @@ -114,6 +114,43 @@ def test_the_page_names_the_published_chatbot_not_the_draft(client, team_with_us assert "Clinic bot" in html +@pytest.mark.django_db() +@pytest.mark.parametrize( + "kwargs", + [ + pytest.param({"publish": False}, id="never-published"), + pytest.param({"publish": False, "enabled": False}, id="never-published-and-disabled"), + ], +) +def test_an_unpublished_draft_is_not_named_to_a_visitor(client, team_with_users, kwargs): + """With no published version there is no name a visitor is entitled to, so the page uses a + placeholder rather than falling back to the draft the builder is still working on.""" + channel = _channel(team_with_users, **kwargs) + working = channel.experiment + working.name = "Internal rename" + working.description = "Notes for the team" + working.save() + + html = _get(client).content.decode() + + assert "Internal rename" not in html + assert "Notes for the team" not in html + assert '

Chatbot

' in html + + +@pytest.mark.django_db() +def test_a_team_member_still_sees_the_draft_name_before_publishing(client, team_with_users): + """Members preview the page before publishing, so the placeholder would hide what they came + to look at.""" + channel = _channel(team_with_users, publish=False) + working = channel.experiment + working.name = "Internal rename" + working.save() + client.force_login(team_with_users.members.first()) + + assert "Internal rename" in _get(client).content.decode() + + @pytest.mark.django_db() def test_unknown_token_is_404(client, team_with_users): _channel(team_with_users)