-
-
Notifications
You must be signed in to change notification settings - Fork 311
feat: event to load webpages in memory #3033
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
0007704
9d368d6
2da7271
7ded8a7
20170a8
1b03e5c
96cb059
c40324a
8f52d47
1083d18
3cce159
b1cfc9f
b7bdb27
d167f10
f07e0f3
495864f
8ceedca
453888e
f9bff0b
c0432ad
51ca59a
0e9b37b
cfd4bab
0c43dfd
f9b93e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Weak default secret passes the emptiness guardMedium Severity
Additional Locations (2) |
||
| 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': [ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unused imports added to context_processors moduleLow Severity Several newly added top-level imports are unused: |
||
|
|
||
| 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,20 @@ def large_data(request): | |
| @user_only | ||
| def body_flags(request): | ||
| return {"EMBED": "embed" in request.GET} | ||
|
|
||
|
|
||
| @user_only | ||
| def chatbot_user_token(request): | ||
| if not request.user.is_authenticated: | ||
| return {"chatbot_user_token": None, "chatbot_enabled": False} | ||
| if not CHATBOT_USER_ID_SECRET: | ||
| return {"chatbot_user_token": None, "chatbot_enabled": False} | ||
| profile = UserProfile(user_obj=request.user) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Expensive UserProfile created on every authenticated page loadMedium Severity The |
||
| if not getattr(profile, "experiments", False): | ||
| return {"chatbot_user_token": None, "chatbot_enabled": False} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Chatbot authorization bypass after permission revocationMedium Severity The |
||
| 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, | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing
CHATBOT_API_BASE_URLdefault causes AttributeError crashHigh Severity
The context processor accesses
settings.CHATBOT_API_BASE_URLat line 132, but this setting is only added tolocal_settings_example.py, not to the basesefaria/settings.py. WhileCHATBOT_USER_ID_SECRETis correctly added to the base settings file,CHATBOT_API_BASE_URLis not. If a deployment has alocal_settings.pythat doesn't define this setting, the context processor will crash withAttributeErrorfor users with experiments enabled.Additional Locations (1)
sefaria/system/context_processors.py#L131-L132