diff --git a/helm-chart/sefaria/templates/configmap/local-settings-file.yaml b/helm-chart/sefaria/templates/configmap/local-settings-file.yaml index 35120f6cc9..4140e6151e 100644 --- a/helm-chart/sefaria/templates/configmap/local-settings-file.yaml +++ b/helm-chart/sefaria/templates/configmap/local-settings-file.yaml @@ -140,6 +140,8 @@ data: CSRF_COOKIE_SAMESITE = os.getenv("CSRF_COOKIE_SAMESITE", "Lax") SECRET_KEY = os.getenv("SECRET_KEY") + CHATBOT_USER_ID_SECRET = os.getenv("CHATBOT_USER_ID_SECRET", 'secret') + CHATBOT_API_BASE_URL = os.getenv("CHATBOT_API_BASE_URL", "https://chat-dev.sefaria.org/api") EMAIL_BACKEND = 'anymail.backends.mandrill.EmailBackend' DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") diff --git a/reader/admin.py b/reader/admin.py new file mode 100644 index 0000000000..a4ef05faf5 --- /dev/null +++ b/reader/admin.py @@ -0,0 +1,63 @@ +from django import forms +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.forms import UserChangeForm +from django.contrib.auth.models import User + +from reader.models import UserExperimentSettings, _set_user_experiments + + +@admin.register(UserExperimentSettings) +class UserExperimentSettingsAdmin(admin.ModelAdmin): + list_display = ("user_email", "experiments") + list_display_links = ("user_email",) + raw_id_fields = ("user",) + search_fields = ("user__email", "user__username", "user__first_name", "user__last_name") + list_filter = ("experiments",) + + def user_email(self, obj): + return obj.user.email + user_email.short_description = "Email" + user_email.admin_order_field = "user__email" + + +class UserExperimentsChangeForm(UserChangeForm): + experiments = forms.BooleanField(required=False, label="Experiments") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + settings = UserExperimentSettings.objects.filter(user=self.instance).first() + self.fields["experiments"].initial = bool(settings and settings.experiments) + + def save(self, commit=True): + user = super().save(commit=commit) + if commit: + _set_user_experiments(user, self.cleaned_data.get("experiments", False)) + else: + # Store the value to be set after the user is saved + self._experiments_value = self.cleaned_data.get("experiments", False) + return user + + def _save_m2m(self): + super()._save_m2m() + # Set experiments after the user and all related objects are saved + if hasattr(self, '_experiments_value'): + _set_user_experiments(self.instance, self._experiments_value) + delattr(self, '_experiments_value') + + +class UserAdminWithExperiments(UserAdmin): + form = UserExperimentsChangeForm + fieldsets = UserAdmin.fieldsets + (("Experiments", {"fields": ("experiments",)}),) + + +def register_user_admin(): + try: + admin.site.unregister(User) + except admin.sites.NotRegistered: + pass + admin.site.register(User, UserAdminWithExperiments) + + +register_user_admin() diff --git a/reader/migrations/0001_initial.py b/reader/migrations/0001_initial.py new file mode 100644 index 0000000000..049843ea1c --- /dev/null +++ b/reader/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2026-02-01 14:27 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + def _ensure_user_pk(apps, schema_editor): + connection = schema_editor.connection + if connection.vendor != "postgresql": + return + + try: + app_label, model_name = settings.AUTH_USER_MODEL.split(".") + except ValueError: + return + + try: + user_model = apps.get_model(app_label, model_name) + except LookupError: + return + + table = user_model._meta.db_table + pk_column = user_model._meta.pk.column + + with connection.cursor() as cursor: + if table not in connection.introspection.table_names(cursor): + return + + cursor.execute( + "SELECT 1 FROM pg_constraint WHERE contype = 'p' AND conrelid = %s::regclass", + [table], + ) + if cursor.fetchone(): + return + + constraint_name = "%s_pkey" % table + qn = connection.ops.quote_name + cursor.execute( + "ALTER TABLE %s ADD CONSTRAINT %s PRIMARY KEY (%s)" + % (qn(table), qn(constraint_name), qn(pk_column)) + ) + + operations = [ + migrations.RunPython(_ensure_user_pk, reverse_code=migrations.RunPython.noop), + migrations.CreateModel( + name='UserExperimentSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('experiments', models.BooleanField(default=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='experiment_settings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User experiment settings', + 'verbose_name_plural': 'User experiment settings', + }, + ), + ] diff --git a/reader/migrations/__init__.py b/reader/migrations/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/reader/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/reader/models.py b/reader/models.py index 71a8362390..c96b8b4d51 100644 --- a/reader/models.py +++ b/reader/models.py @@ -1,3 +1,38 @@ +from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.db import models -# Create your models here. + +class UserExperimentSettings(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="experiment_settings") + experiments = models.BooleanField(default=True) + + class Meta: + verbose_name = "User experiment settings" + verbose_name_plural = "User experiment settings" + + def __str__(self): + return f"Experiments for user {self.user_id}" + + +def _get_user_experiments(user): + try: + return bool(user.experiment_settings.experiments) + except ObjectDoesNotExist: + return False + + +def _set_user_experiments(user, value): + settings, _ = UserExperimentSettings.objects.get_or_create(user=user) + settings.experiments = bool(value) + settings.save(update_fields=["experiments"]) + + +if not hasattr(User, "experiments"): + User.add_to_class("experiments", property(_get_user_experiments, _set_user_experiments)) + + +def user_has_experiments(user): + if not user or not getattr(user, "is_authenticated", False): + return False + return UserExperimentSettings.objects.filter(user=user, experiments=True).exists() diff --git a/reader/views.py b/reader/views.py index d481a2be7d..9cec67ef26 100644 --- a/reader/views.py +++ b/reader/views.py @@ -68,6 +68,7 @@ from sefaria.system.decorators import catch_error_as_json, sanitize_get_params, json_response_decorator from sefaria.system.exceptions import InputError, PartialRefInputError, BookNameError, NoVersionFoundError, DictionaryEntryNotFoundError from sefaria.system.cache import django_cache +from reader.models import user_has_experiments from sefaria.system.database import db from sefaria.helper.search import get_query_obj from sefaria.helper.crm.crm_mediator import CrmMediator @@ -3834,6 +3835,8 @@ def profile_api(request, slug=None): if not profileJSON: return jsonResponse({"error": "No post JSON."}) profileUpdate = json.loads(profileJSON) + if "experiments" in profileUpdate and not user_has_experiments(request.user): + profileUpdate.pop("experiments", None) profile = UserProfile(id=request.user.id) profile.update(profileUpdate) @@ -4168,9 +4171,11 @@ def account_settings(request): Page for managing a user's account settings. """ profile = UserProfile(id=request.user.id) + experiments_available = user_has_experiments(request.user) return render_template(request,'account_settings.html', {"headerMode": True}, { 'user': request.user, 'profile': profile, + 'experiments_available': experiments_available, 'lang_names_and_codes': zip([Locale(lang).languages[lang].capitalize() for lang in SITE_SETTINGS['SUPPORTED_TRANSLATION_LANGUAGES']], SITE_SETTINGS['SUPPORTED_TRANSLATION_LANGUAGES']), 'translation_language_preference': (profile is not None and profile.settings.get("translation_language_preference", None)) or request.COOKIES.get("translation_language_preference", None), "renderStatic": True diff --git a/requirements.txt b/requirements.txt index 18ed1cf5f1..82d1dae79a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Appium-Python-Client==1.2.0 Cerberus +cryptography==42.0.7 PyJWT==1.7.1 # pinned b/c current version 2.0.0 breaks simplejwt. waiting for 2.0.1 babel django-admin-sortable==2.1.13 @@ -45,7 +46,8 @@ google-auth==1.24.0 google-cloud-logging==1.15.1 google-cloud-storage==1.32.0 google-re2 -gunicorn==20.0.4 +gunicorn==23.0.0 +setuptools==69.5.1 html5lib==0.9999999 httplib2==0.18.1 ipython==7.34.* diff --git a/sefaria/local_settings_example.py b/sefaria/local_settings_example.py index bcaa8f3364..c2eb54305e 100644 --- a/sefaria/local_settings_example.py +++ b/sefaria/local_settings_example.py @@ -147,6 +147,7 @@ MANAGERS = ADMINS SECRET_KEY = 'insert your long random secret key here !' +CHATBOT_USER_ID_SECRET = 'insert your chatbot user id secret here' EMAIL_HOST = 'localhost' @@ -352,3 +353,7 @@ CSRF_COOKIE_SECURE = True # Set to True if using HTTPS CSRF_COOKIE_HTTPONLY = False # Must be False for CSRF tokens to work with JavaScript CSRF_COOKIE_SAMESITE = 'Lax' # Modern browsers require this + +CHATBOT_API_BASE_URL = os.getenv("CHATBOT_API_BASE_URL", "https://chat-dev.sefaria.org/api") +# Use the local Vite dev server script instead of the hosted UMD bundle. +CHATBOT_USE_LOCAL_SCRIPT = True diff --git a/sefaria/model/user_profile.py b/sefaria/model/user_profile.py index 378f26ef61..ea7361c5bd 100644 --- a/sefaria/model/user_profile.py +++ b/sefaria/model/user_profile.py @@ -396,6 +396,7 @@ def __init__(self, user_obj=None, id=None, slug=None, email=None, user_registrat # Fundraising self.is_sustainer = False + self.experiments = False # Update with saved profile doc in MongoDB profile = db.profiles.find_one({"id": id}) @@ -665,6 +666,7 @@ def to_mongo_dict(self): "version_preferences_by_corpus": self.version_preferences_by_corpus, "attr_time_stamps": self.attr_time_stamps, "is_sustainer": self.is_sustainer, + "experiments": self.experiments, "tag_order": getattr(self, "tag_order", None), "last_sync_web": self.last_sync_web, "profile_pic_url": self.profile_pic_url, @@ -705,6 +707,7 @@ def to_api_dict(self, basic=False): other_info = { "pinned_sheets": self.pinned_sheets, "is_sustainer": self.is_sustainer, + "experiments": self.experiments, } dictionary.update(other_info) return dictionary diff --git a/sefaria/settings.py b/sefaria/settings.py index 35770f59cf..9b1855c75d 100644 --- a/sefaria/settings.py +++ b/sefaria/settings.py @@ -75,6 +75,8 @@ def get_static_url(): # Make this unique, and don't share it with anybody. SECRET_KEY = '' +CHATBOT_USER_ID_SECRET = 'secret' +CHATBOT_USE_LOCAL_SCRIPT = False TEMPLATES = [ { @@ -99,6 +101,7 @@ def get_static_url(): "sefaria.system.context_processors.large_data", "sefaria.system.context_processors.body_flags", "sefaria.system.context_processors.base_props", + "sefaria.system.context_processors.chatbot_user_token", "sefaria.system.context_processors.module_context", ], 'loaders': [ diff --git a/sefaria/system/context_processors.py b/sefaria/system/context_processors.py index 2826744763..b2b3b22269 100644 --- a/sefaria/system/context_processors.py +++ b/sefaria/system/context_processors.py @@ -7,8 +7,15 @@ from functools import wraps from sefaria.settings import * +from django.conf import settings from sefaria.site.site_settings import SITE_SETTINGS from sefaria.model import library +from sefaria.model.user_profile import UserProfile, UserHistorySet, UserWrapper +from sefaria.utils import calendars +from sefaria.utils.util import short_to_long_lang_code +from sefaria.utils.chatbot import build_chatbot_user_token +from sefaria.utils.hebrew import hebrew_parasha_name +from reader.views import render_react_component, _get_user_calendar_params import structlog logger = structlog.get_logger(__name__) @@ -68,6 +75,7 @@ def global_settings(request): "OFFLINE": OFFLINE, "SITE_SETTINGS": SITE_SETTINGS, "CLIENT_SENTRY_DSN": CLIENT_SENTRY_DSN, + "CHATBOT_USE_LOCAL_SCRIPT": CHATBOT_USE_LOCAL_SCRIPT, } @@ -107,3 +115,35 @@ def large_data(request): @user_only def body_flags(request): return {"EMBED": "embed" in request.GET} + + +@user_only +def chatbot_user_token(request): + chatbot_version = request.GET.get("chatbot_version", "").strip() + + if not request.user.is_authenticated: + return { + "chatbot_user_token": None, + "chatbot_enabled": False, + "chatbot_version": chatbot_version, + } + if not CHATBOT_USER_ID_SECRET: + return { + "chatbot_user_token": None, + "chatbot_enabled": False, + "chatbot_version": chatbot_version, + } + profile = UserProfile(user_obj=request.user) + if not getattr(profile, "experiments", False): + return { + "chatbot_user_token": None, + "chatbot_enabled": False, + "chatbot_version": chatbot_version, + } + token = build_chatbot_user_token(request.user.id, CHATBOT_USER_ID_SECRET) + return { + "chatbot_user_token": token, + "chatbot_enabled": True, + "chatbot_api_base_url": settings.CHATBOT_API_BASE_URL, + "chatbot_version": chatbot_version, + } diff --git a/sefaria/utils/chatbot.py b/sefaria/utils/chatbot.py new file mode 100644 index 0000000000..5fb1ad86e2 --- /dev/null +++ b/sefaria/utils/chatbot.py @@ -0,0 +1,37 @@ +import base64 +import hashlib +import json +import os +from datetime import timedelta + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from django.utils import timezone + +DEFAULT_TTL_HOURS = 72 +NONCE_SIZE_BYTES = 12 + + +def _hash_user_id(user_id): + return hashlib.sha256(str(user_id).encode("utf-8")).hexdigest() + + +def _derive_key(secret): + return hashlib.sha256(secret.encode("utf-8")).digest() + + +def build_chatbot_user_token(user_id, secret, now=None, ttl_hours=DEFAULT_TTL_HOURS): + if not user_id or not secret: + return None + + expires_at = (now or timezone.now()) + timedelta(hours=ttl_hours) + payload = { + "id": _hash_user_id(user_id), + "expiration": expires_at.replace(microsecond=0).isoformat(), + } + payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + key = _derive_key(secret) + aesgcm = AESGCM(key) + nonce = os.urandom(NONCE_SIZE_BYTES) + encrypted = aesgcm.encrypt(nonce, payload_bytes, None) + token_bytes = nonce + encrypted + return base64.urlsafe_b64encode(token_bytes).decode("ascii") diff --git a/static/js/ReaderApp.jsx b/static/js/ReaderApp.jsx index 978f12aea1..c1a3dc2d20 100644 --- a/static/js/ReaderApp.jsx +++ b/static/js/ReaderApp.jsx @@ -204,6 +204,7 @@ class ReaderApp extends Component { // (because its set to capture, or the event going down the dom stage, and the listener is the document element- it should fire before other handlers. Specifically // handleInAppLinkClick that disables modifier keys such as cmd, alt, shift) document.addEventListener('click', this.handleInAppClickWithModifiers, {capture: true}); + document.addEventListener('sefaria:bootstrap-url', this.handleBootstrapUrlEvent); // Handle right-clicks on links with data-target-module to ensure correct domain document.addEventListener('contextmenu', this.handleModuleLinkRightClick); @@ -236,6 +237,7 @@ class ReaderApp extends Component { window.removeEventListener("resize", this.setPanelCap); window.removeEventListener("beforeprint", this.handlePrint); document.removeEventListener('copy', this.handleCopyEvent); + document.removeEventListener('sefaria:bootstrap-url', this.handleBootstrapUrlEvent); document.removeEventListener('contextmenu', this.handleModuleLinkRightClick); } componentDidUpdate(prevProps, prevState) { @@ -1161,6 +1163,179 @@ toggleSignUpModal(modalContentKind = SignUpModalKind.Default) { e.preventDefault(); } } + handleBootstrapUrlEvent(event) { + if (!event || !event.detail) { return; } + const detail = event.detail; + const url = (typeof detail === "string") ? detail : detail.url; + if (!url) { return; } + const replaceHistory = (typeof detail === "object") ? detail.replaceHistory : false; + this.bootstrapUrl(url, {replaceHistory: replaceHistory}); + } + bootstrapUrl(href, options) { + if (this.shouldAlertBeforeCloseEditor()) { + if (!this.alertUnsavedChangesConfirmed()) { + return true; + } + } + let url; + try { + url = new URL(href, window.location.href); + } catch { + return false; + } + const hostname = url.hostname || ""; + if (hostname && hostname !== window.location.hostname && hostname.indexOf("sefaria.org") === -1) { + return false; + } + const path = decodeURI(url.pathname); + const params = url.searchParams; + const ref = path.slice(1).replace(/%3F/g, '?'); + if (Sefaria.isRef(ref)) { + return this.bootstrapTextUrl(ref, params, options); + } + return this.openURL(path + url.search, true); + } + bootstrapTextUrl(ref, params, options) { + const opts = options || {}; + const lang = this._normalizePanelLanguage(params.get("lang"), true); + const withParam = params.get("with"); + const filter = this._parseWithParam(withParam); + const hasConnections = withParam !== null; + const {connectionsMode, connectionsCategory, webPagesFilter, filter: normalizedFilter} = + this._getConnectionsModeFromFilter(filter || []); + + const currVersions = { + en: this._parseVersionParam(params.get("ven")), + he: this._parseVersionParam(params.get("vhe")), + }; + const versionFilterParam = params.get("vside"); + const versionFilter = versionFilterParam ? [Sefaria.util.decodeVtitle(versionFilterParam)] : []; + + let settings = lang ? {language: lang} : null; + const aliyotParam = params.get("aliyot"); + if (aliyotParam !== null) { + settings = settings || {}; + settings.aliyotTorah = (parseInt(aliyotParam, 10) === 1) ? "aliyotOn" : "aliyotOff"; + } + settings = this._mergePanelSettings(settings); + + const humanRef = Sefaria.humanRef(ref); + const highlightedRefs = hasConnections ? [Sefaria.normRef(ref)] : []; + const showHighlight = hasConnections; + const basePanelProps = { + mode: (!this.props.multiPanel && hasConnections) ? "TextAndConnections" : "Text", + refs: [humanRef], + currVersions: currVersions, + filter: normalizedFilter || [], + recentFilters: normalizedFilter || [], + connectionsMode: connectionsMode || null, + connectionsCategory: connectionsCategory, + webPagesFilter: webPagesFilter, + versionFilter: versionFilter, + highlightedRefs: highlightedRefs, + showHighlight: showHighlight, + settings: settings, + selectedWords: params.get("lookup"), + sidebarSearchQuery: params.get("sbsq"), + selectedNamedEntity: params.get("namedEntity"), + selectedNamedEntityText: params.get("namedEntityText"), + }; + const basePanel = this.makePanelState(basePanelProps); + basePanel.currentlyVisibleRef = humanRef; + + const panels = [basePanel]; + if (hasConnections && this.props.multiPanel) { + const connectionsLang = this._getConnectionsPanelLanguage(params.get("lang2"), lang); + const connectionsSettings = this._mergePanelSettings({language: connectionsLang}); + const connectionsPanelProps = { + ...basePanelProps, + mode: "Connections", + settings: connectionsSettings, + connectionsMode: connectionsMode || (filter && filter.length ? "TextList" : null), + }; + const connectionsPanel = this.makePanelState(connectionsPanelProps); + panels.push(connectionsPanel); + } + + if (opts.replaceHistory) { + this.replaceHistory = true; + } + this.setState({panels: panels}); + if (opts.saveLastPlace !== false) { + this.saveLastPlace(basePanel, 1, hasConnections && this.props.multiPanel); + } + return true; + } + _normalizePanelLanguage(langParam, allowBilingual) { + if (!langParam) { return null; } + const normalized = langParam.toLowerCase(); + if (normalized === "bi" || normalized === "bilingual") { + return allowBilingual ? "bilingual" : null; + } else if (normalized === "en" || normalized === "english") { + return "english"; + } else if (normalized === "he" || normalized === "hebrew") { + return "hebrew"; + } + return null; + } + _getConnectionsPanelLanguage(lang2Param, baseLang) { + const lang2 = this._normalizePanelLanguage(lang2Param, false); + if (lang2) { return lang2; } + if (baseLang === "english" || baseLang === "hebrew") { return baseLang; } + return (Sefaria.interfaceLang === "hebrew") ? "hebrew" : "english"; + } + _parseWithParam(param) { + if (param === null || typeof param === "undefined") { return null; } + const normalized = param.replace(/_/g, " "); + let filter = normalized.split("+").map(x => x.trim()).filter(Boolean); + if (filter.length === 1 && filter[0] === "all") { + filter = []; + } + return filter; + } + _parseVersionParam(param) { + if (!param) { return null; } + const parts = param.split("|"); + return { + languageFamilyName: parts[0] || null, + versionTitle: parts[1] ? Sefaria.util.decodeVtitle(parts[1]) : null, + }; + } + _getConnectionsModeFromFilter(filter) { + if (!filter || !filter.length) { + return {connectionsMode: null, connectionsCategory: null, webPagesFilter: null, filter: filter}; + } + const sidebarModes = [ + "Sheets", "Notes", "About", "AboutSheet", "Navigation", "Translations", "Translation Open", "Version Open", + "WebPages", "extended notes", "Topics", "Torah Readings", "manuscripts", "Lexicon", "SidebarSearch", "Guide", + ]; + const first = filter[0]; + if (sidebarModes.includes(first)) { + return {connectionsMode: first, connectionsCategory: null, webPagesFilter: null, filter: []}; + } + if (first.endsWith(" ConnectionsList")) { + const cleaned = filter.map(x => x.replace(" ConnectionsList", "")); + return { + connectionsMode: "ConnectionsList", + connectionsCategory: cleaned.length === 1 ? cleaned[0] : null, + webPagesFilter: null, + filter: cleaned, + }; + } + if (first.startsWith("WebPage:")) { + return { + connectionsMode: "WebPagesList", + connectionsCategory: null, + webPagesFilter: first.replace("WebPage:", ""), + filter: filter, + }; + } + return {connectionsMode: "TextList", connectionsCategory: null, webPagesFilter: null, filter: filter}; + } + _mergePanelSettings(settings) { + if (!settings) { return null; } + return extend(Sefaria.util.clone(this.getDefaultPanelSettings()), settings); + } updateModuleLinkHref(link) { /* @@ -2058,7 +2233,7 @@ toggleSignUpModal(modalContentKind = SignUpModalKind.Default) { handleCopyEvent(e) { // Custom processing of Copy/Paste - const tagsToIgnore = ["INPUT", "TEXTAREA"]; + const tagsToIgnore = ["INPUT", "TEXTAREA", "LC-CHATBOT"]; if (tagsToIgnore.includes(e.srcElement.tagName)) { // If the selection is from an input or textarea tag, don't do anything special return diff --git a/templates/account_settings.html b/templates/account_settings.html index 5a903bbd98..fc9f7c57d2 100644 --- a/templates/account_settings.html +++ b/templates/account_settings.html @@ -105,6 +105,24 @@

{% endif %} + {% if experiments_available %} +
+ +
+ + +
+
+ {% endif %}