diff --git a/Dockerfiles/app.Dockerfile b/Dockerfiles/app.Dockerfile index aec7b78c..fb3d5487 100644 --- a/Dockerfiles/app.Dockerfile +++ b/Dockerfiles/app.Dockerfile @@ -5,8 +5,7 @@ LABEL authors="fureunoir" ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - PATH="/root/.local/bin:$PATH" + DEBIAN_FRONTEND=noninteractive WORKDIR /app @@ -33,18 +32,16 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/*; \ pip install --upgrade pip -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH" +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh -RUN uv venv /opt/schon-python ENV VIRTUAL_ENV=/opt/schon-python ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python -ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH" +ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH" COPY pyproject.toml pyproject.toml COPY uv.lock uv.lock -RUN set -eux; \ +RUN uv venv /opt/schon-python && \ uv sync --extra worker --extra openai --locked COPY ./scripts/Docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh @@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/app-entrypoint.sh COPY . . -ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"] \ No newline at end of file +RUN groupadd --system --gid 1000 schon && \ + useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \ + mkdir -p /app/static /app/media && \ + chown -R schon:schon /app /opt/schon-python + +USER schon + +ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"] diff --git a/Dockerfiles/beat.Dockerfile b/Dockerfiles/beat.Dockerfile index 9c5313d6..7fb675a3 100644 --- a/Dockerfiles/beat.Dockerfile +++ b/Dockerfiles/beat.Dockerfile @@ -5,8 +5,7 @@ LABEL authors="fureunoir" ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - PATH="/root/.local/bin:$PATH" + DEBIAN_FRONTEND=noninteractive WORKDIR /app @@ -33,17 +32,16 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/*; \ pip install --upgrade pip -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH" -RUN uv venv /opt/schon-python +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh + ENV VIRTUAL_ENV=/opt/schon-python ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python -ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH" +ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH" COPY pyproject.toml pyproject.toml COPY uv.lock uv.lock -RUN set -eux; \ +RUN uv venv /opt/schon-python && \ uv sync --extra worker --extra openai --locked COPY ./scripts/Docker/beat-entrypoint.sh /usr/local/bin/beat-entrypoint.sh @@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/beat-entrypoint.sh COPY . . -ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"] \ No newline at end of file +RUN groupadd --system --gid 1000 schon && \ + useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \ + mkdir -p /app/media && \ + chown -R schon:schon /app /opt/schon-python + +USER schon + +ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"] diff --git a/Dockerfiles/stock_updater.Dockerfile b/Dockerfiles/stock_updater.Dockerfile index 95b5bc86..eb71f811 100644 --- a/Dockerfiles/stock_updater.Dockerfile +++ b/Dockerfiles/stock_updater.Dockerfile @@ -5,8 +5,7 @@ LABEL authors="fureunoir" ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - PATH="/root/.local/bin:$PATH" + DEBIAN_FRONTEND=noninteractive WORKDIR /app @@ -33,17 +32,16 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/*; \ pip install --upgrade pip -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH" -RUN uv venv /opt/schon-python +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh + ENV VIRTUAL_ENV=/opt/schon-python ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python -ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH" +ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH" COPY pyproject.toml pyproject.toml COPY uv.lock uv.lock -RUN set -eux; \ +RUN uv venv /opt/schon-python && \ uv sync --extra worker --extra openai --locked COPY ./scripts/Docker/stock-updater-entrypoint.sh /usr/local/bin/stock-updater-entrypoint.sh @@ -51,4 +49,11 @@ RUN chmod +x /usr/local/bin/stock-updater-entrypoint.sh COPY . . -ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"] \ No newline at end of file +RUN groupadd --system --gid 1000 schon && \ + useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \ + mkdir -p /app/media && \ + chown -R schon:schon /app /opt/schon-python + +USER schon + +ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"] diff --git a/Dockerfiles/worker.Dockerfile b/Dockerfiles/worker.Dockerfile index f8d40bb8..66be7d2d 100644 --- a/Dockerfiles/worker.Dockerfile +++ b/Dockerfiles/worker.Dockerfile @@ -5,8 +5,7 @@ LABEL authors="fureunoir" ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ LANG=C.UTF-8 \ - DEBIAN_FRONTEND=noninteractive \ - PATH="/root/.local/bin:$PATH" + DEBIAN_FRONTEND=noninteractive WORKDIR /app @@ -33,18 +32,16 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/*; \ pip install --upgrade pip -RUN curl -LsSf https://astral.sh/uv/install.sh | sh -ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH" +RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh -RUN uv venv /opt/schon-python ENV VIRTUAL_ENV=/opt/schon-python ENV UV_PROJECT_ENVIRONMENT=/opt/schon-python -ENV PATH="/opt/schon-python/bin:/root/.local/bin:/root/.cargo/bin:$PATH" +ENV PATH="/opt/schon-python/bin:/usr/local/bin:$PATH" COPY pyproject.toml pyproject.toml COPY uv.lock uv.lock -RUN set -eux; \ +RUN uv venv /opt/schon-python && \ uv sync --extra worker --extra openai --locked COPY ./scripts/Docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh @@ -52,4 +49,11 @@ RUN chmod +x /usr/local/bin/worker-entrypoint.sh COPY . . -ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"] \ No newline at end of file +RUN groupadd --system --gid 1000 schon && \ + useradd --system --uid 1000 --gid schon --shell /bin/bash --create-home schon && \ + mkdir -p /app/media && \ + chown -R schon:schon /app /opt/schon-python + +USER schon + +ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index 9b254912..0ea90efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: restart: always volumes: - .:/app + - static-data:/app/static + - media-data:/app/media ports: - "8000:8000" env_file: @@ -131,6 +133,7 @@ services: restart: always volumes: - .:/app + - media-data:/app/media env_file: - .env environment: @@ -159,6 +162,7 @@ services: restart: always volumes: - .:/app + - media-data:/app/media env_file: - .env environment: @@ -187,6 +191,7 @@ services: restart: always volumes: - .:/app + - media-data:/app/media env_file: - .env environment: @@ -221,3 +226,5 @@ volumes: redis-data: es-data: prometheus-data: + static-data: + media-data: diff --git a/engine/core/admin.py b/engine/core/admin.py index 684fc11f..b6b0ee2c 100644 --- a/engine/core/admin.py +++ b/engine/core/admin.py @@ -1046,16 +1046,10 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin): "country", "user", ) - list_filter = ( - "country", - "region", - ) - search_fields = ( - "street", - "city", - "postal_code", - "user__email", - ) + # country and region are encrypted — DB-level filtering is not possible + list_filter = () + # street, city, postal_code are encrypted — DB-level search is not possible + search_fields = ("user__email",) readonly_fields = ( "uuid", "modified", diff --git a/engine/core/migrations/0057_encrypt_address_fields.py b/engine/core/migrations/0057_encrypt_address_fields.py new file mode 100644 index 00000000..d94c1301 --- /dev/null +++ b/engine/core/migrations/0057_encrypt_address_fields.py @@ -0,0 +1,186 @@ +import base64 + +import encrypted_fields.fields +from cryptography.fernet import Fernet, MultiFernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from django.db import migrations, models + + +def _make_fernet(settings): + keys = [] + salt_keys = ( + settings.SALT_KEY + if isinstance(settings.SALT_KEY, list) + else [settings.SALT_KEY] + ) + for secret_key in [settings.SECRET_KEY] + list( + getattr(settings, "SECRET_KEY_FALLBACKS", []) + ): + for salt_key in salt_keys: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt_key.encode("utf-8"), + iterations=100_000, + backend=default_backend(), + ) + keys.append( + base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8"))) + ) + return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0]) + + +def encrypt_address_fields(apps, schema_editor): + import json + + from django.conf import settings + + f = _make_fernet(settings) + + def enc(value): + if value is None: + return None + if not isinstance(value, str): + value = str(value) + return f.encrypt(value.encode("utf-8")).decode("utf-8") + + def enc_json(value): + if value is None: + return None + if not isinstance(value, str): + value = json.dumps(value, default=str) + return f.encrypt(value.encode("utf-8")).decode("utf-8") + + with schema_editor.connection.cursor() as cursor: + cursor.execute( + "SELECT uuid, address_line, street, district, city, region, " + "postal_code, country, raw_data, api_response FROM core_address" + ) + rows = cursor.fetchall() + for ( + row_id, + address_line, + street, + district, + city, + region, + postal_code, + country, + raw_data, + api_response, + ) in rows: + cursor.execute( + "UPDATE core_address SET " + "address_line=%s, street=%s, district=%s, city=%s, region=%s, " + "postal_code=%s, country=%s, raw_data=%s, api_response=%s " + "WHERE uuid=%s", + [ + enc(address_line), + enc(street), + enc(district), + enc(city), + enc(region), + enc(postal_code), + enc(country), + enc_json(raw_data), + enc_json(api_response), + row_id, + ], + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0056_pastedimage"), + ] + + operations = [ + # Encrypt text fields + migrations.AlterField( + model_name="address", + name="address_line", + field=encrypted_fields.fields.EncryptedTextField( + blank=True, + null=True, + help_text="address line for the customer", + verbose_name="address line", + ), + ), + migrations.AlterField( + model_name="address", + name="street", + field=encrypted_fields.fields.EncryptedCharField( + max_length=255, + null=True, + verbose_name="street", + ), + ), + migrations.AlterField( + model_name="address", + name="district", + field=encrypted_fields.fields.EncryptedCharField( + max_length=255, + null=True, + verbose_name="district", + ), + ), + migrations.AlterField( + model_name="address", + name="city", + field=encrypted_fields.fields.EncryptedCharField( + max_length=100, + null=True, + verbose_name="city", + ), + ), + migrations.AlterField( + model_name="address", + name="region", + field=encrypted_fields.fields.EncryptedCharField( + max_length=100, + null=True, + verbose_name="region", + ), + ), + migrations.AlterField( + model_name="address", + name="postal_code", + field=encrypted_fields.fields.EncryptedCharField( + max_length=20, + null=True, + verbose_name="postal code", + ), + ), + migrations.AlterField( + model_name="address", + name="country", + field=encrypted_fields.fields.EncryptedCharField( + max_length=40, + null=True, + verbose_name="country", + ), + ), + # JSON fields: JSONB → TEXT (encrypted JSON string) + migrations.AlterField( + model_name="address", + name="raw_data", + field=models.TextField( + blank=True, + null=True, + help_text="full JSON response from geocoder for this address", + ), + ), + migrations.AlterField( + model_name="address", + name="api_response", + field=models.TextField( + blank=True, + null=True, + help_text="stored JSON response from the geocoding service", + ), + ), + # Re-encrypt all existing plaintext values + migrations.RunPython(encrypt_address_fields, migrations.RunPython.noop), + ] diff --git a/engine/core/models.py b/engine/core/models.py index 267ca857..efc0b830 100644 --- a/engine/core/models.py +++ b/engine/core/models.py @@ -47,6 +47,7 @@ from django.utils.functional import cached_property from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField +from encrypted_fields.fields import EncryptedCharField, EncryptedTextField from mptt.fields import TreeForeignKey from mptt.models import MPTTModel @@ -72,6 +73,7 @@ from engine.core.utils.lists import FAILED_STATUSES from engine.core.utils.markdown import strip_markdown from engine.core.validators import validate_category_image_dimensions from engine.payments.models import Transaction +from schon.fields import EncryptedJSONTextField from schon.utils.misc import create_object if TYPE_CHECKING: @@ -1133,18 +1135,18 @@ class Address(NiceModel): is_publicly_visible = False - address_line = TextField( + address_line = EncryptedTextField( blank=True, null=True, help_text=_("address line for the customer"), verbose_name=_("address line"), ) - street = CharField(_("street"), max_length=255, null=True) - district = CharField(_("district"), max_length=255, null=True) - city = CharField(_("city"), max_length=100, null=True) - region = CharField(_("region"), max_length=100, null=True) - postal_code = CharField(_("postal code"), max_length=20, null=True) - country = CharField(_("country"), max_length=40, null=True) + street = EncryptedCharField(_("street"), max_length=255, null=True) + district = EncryptedCharField(_("district"), max_length=255, null=True) + city = EncryptedCharField(_("city"), max_length=100, null=True) + region = EncryptedCharField(_("region"), max_length=100, null=True) + postal_code = EncryptedCharField(_("postal code"), max_length=20, null=True) + country = EncryptedCharField(_("country"), max_length=40, null=True) location: PointField = PointField( geography=True, @@ -1154,13 +1156,13 @@ class Address(NiceModel): help_text=_("geolocation point: (longitude, latitude)"), ) - raw_data = JSONField( + raw_data = EncryptedJSONTextField( blank=True, null=True, help_text=_("full JSON response from geocoder for this address"), ) - api_response = JSONField( + api_response = EncryptedJSONTextField( blank=True, null=True, help_text=_("stored JSON response from the geocoding service"), diff --git a/engine/core/views.py b/engine/core/views.py index da435ed9..aa13f590 100644 --- a/engine/core/views.py +++ b/engine/core/views.py @@ -4,6 +4,7 @@ import os import traceback from contextlib import suppress from datetime import date, timedelta +from os import getenv import requests from constance import config @@ -35,6 +36,7 @@ from django_ratelimit.decorators import ratelimit from drf_spectacular.utils import extend_schema_view from drf_spectacular.views import SpectacularAPIView from graphene_file_upload.django import FileUploadGraphQLView +from graphql.validation import NoSchemaIntrospectionCustomRule from rest_framework import status from rest_framework.parsers import MultiPartParser from rest_framework.permissions import AllowAny, IsAdminUser @@ -85,6 +87,7 @@ from engine.core.utils.commerce import ( from engine.core.utils.emailing import contact_us_email from engine.core.utils.languages import get_flag_by_language from engine.payments.serializers import TransactionProcessSerializer +from schon.graphql_validators import QueryDepthLimitRule from schon.utils.renderers import camelize logger = logging.getLogger(__name__) @@ -121,7 +124,14 @@ sitemap_detail.__doc__ = _( # ty:ignore[invalid-assignment] ) +_graphql_validation_rules = [QueryDepthLimitRule] +if getenv("GRAPHQL_INTROSPECTION", "").lower() in ("1", "true", "yes"): + _graphql_validation_rules.append(NoSchemaIntrospectionCustomRule) + + class CustomGraphQLView(FileUploadGraphQLView): + validation_rules = tuple(_graphql_validation_rules) + def get_context(self, request): return request diff --git a/engine/core/viewsets.py b/engine/core/viewsets.py index 4e2a501a..54ddc458 100644 --- a/engine/core/viewsets.py +++ b/engine/core/viewsets.py @@ -1221,7 +1221,7 @@ class AddressViewSet(SchonViewSet): filterset_class = AddressFilter queryset = Address.objects.all() serializer_class = AddressSerializer - additional = {"create": "ALLOW", "retrieve": "ALLOW"} + additional = {"create": "ALLOW"} def get_serializer_class(self): if self.action == "create": @@ -1239,15 +1239,6 @@ class AddressViewSet(SchonViewSet): return Address.objects.none() - def retrieve(self, request: Request, *args, **kwargs) -> Response: - try: - address = Address.objects.get(uuid=str(kwargs.get("pk"))) - return Response( - status=status.HTTP_200_OK, data=self.get_serializer(address).data - ) - except Address.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) - def create(self, request: Request, *args, **kwargs) -> Response: create_serializer = AddressCreateSerializer( data=request.data, context={"request": request} diff --git a/engine/vibes_auth/admin.py b/engine/vibes_auth/admin.py index 9d03f2dd..b6d66805 100644 --- a/engine/vibes_auth/admin.py +++ b/engine/vibes_auth/admin.py @@ -110,7 +110,8 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin): ), ) list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff") - search_fields = ("email", "phone_number") + # phone_number is encrypted — DB-level search is not possible for it + search_fields = ("email",) list_filter = ( "is_verified", "is_active", diff --git a/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py b/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py new file mode 100644 index 00000000..2b8694ac --- /dev/null +++ b/engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py @@ -0,0 +1,136 @@ +import base64 + +import encrypted_fields.fields +from cryptography.fernet import Fernet, MultiFernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from django.db import migrations, models + + +def _make_fernet(settings): + keys = [] + salt_keys = ( + settings.SALT_KEY + if isinstance(settings.SALT_KEY, list) + else [settings.SALT_KEY] + ) + for secret_key in [settings.SECRET_KEY] + list( + getattr(settings, "SECRET_KEY_FALLBACKS", []) + ): + for salt_key in salt_keys: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt_key.encode("utf-8"), + iterations=100_000, + backend=default_backend(), + ) + keys.append( + base64.urlsafe_b64encode(kdf.derive(secret_key.encode("utf-8"))) + ) + return MultiFernet([Fernet(k) for k in keys]) if len(keys) > 1 else Fernet(keys[0]) + + +def encrypt_user_pii(apps, schema_editor): + import json + + from django.conf import settings + + f = _make_fernet(settings) + + def enc(value): + if value is None: + return None + if not isinstance(value, str): + value = str(value) + return f.encrypt(value.encode("utf-8")).decode("utf-8") + + def enc_json(value): + if value is None: + return None + # value may be a Python dict (from JSONB) or a JSON string (after TEXT cast) + if not isinstance(value, str): + value = json.dumps(value, default=str) + return f.encrypt(value.encode("utf-8")).decode("utf-8") + + with schema_editor.connection.cursor() as cursor: + cursor.execute( + "SELECT uuid, phone_number, first_name, last_name, attributes " + "FROM vibes_auth_user" + ) + rows = cursor.fetchall() + for row_id, phone, first, last, attrs in rows: + cursor.execute( + "UPDATE vibes_auth_user " + "SET phone_number=%s, first_name=%s, last_name=%s, attributes=%s " + "WHERE uuid=%s", + [enc(phone), enc(first), enc(last), enc_json(attrs), row_id], + ) + + +class Migration(migrations.Migration): + dependencies = [ + ( + "vibes_auth", + "0009_delete_emailimage_remove_emailtemplate_html_content_and_more", + ), + ] + + operations = [ + # Add activation token timestamp + migrations.AddField( + model_name="user", + name="activation_token_created", + field=models.DateTimeField( + blank=True, + null=True, + verbose_name="activation token created", + ), + ), + # Encrypt phone_number (also drops unique constraint) + migrations.AlterField( + model_name="user", + name="phone_number", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, + max_length=20, + null=True, + verbose_name="phone_number", + ), + ), + # Encrypt first_name + migrations.AlterField( + model_name="user", + name="first_name", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, + max_length=150, + null=True, + verbose_name="first_name", + ), + ), + # Encrypt last_name + migrations.AlterField( + model_name="user", + name="last_name", + field=encrypted_fields.fields.EncryptedCharField( + blank=True, + max_length=150, + null=True, + verbose_name="last_name", + ), + ), + # Encrypt attributes (JSONB → TEXT with JSON serialisation) + migrations.AlterField( + model_name="user", + name="attributes", + field=models.TextField( + blank=True, + null=True, + verbose_name="attributes", + ), + ), + # Re-encrypt existing plaintext values using raw SQL + migrations.RunPython(encrypt_user_pii, migrations.RunPython.noop), + ] diff --git a/engine/vibes_auth/models.py b/engine/vibes_auth/models.py index 9c2d2495..a55e9f46 100644 --- a/engine/vibes_auth/models.py +++ b/engine/vibes_auth/models.py @@ -23,6 +23,7 @@ from django.templatetags.static import static from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from encrypted_fields.fields import EncryptedCharField from rest_framework_simplejwt.token_blacklist.models import ( BlacklistedToken as BaseBlacklistedToken, ) @@ -35,6 +36,7 @@ from engine.payments.models import Balance from engine.vibes_auth.choices import SenderType, ThreadStatus from engine.vibes_auth.managers import UserManager from engine.vibes_auth.validators import validate_phone_number +from schon.fields import EncryptedJSONTextField class User(AbstractUser, NiceModel): @@ -51,10 +53,9 @@ class User(AbstractUser, NiceModel): return "users/" + str(self.uuid) + "/" + args[0] email = EmailField(_("email"), unique=True, help_text=_("user email address")) - phone_number = CharField( + phone_number = EncryptedCharField( _("phone_number"), max_length=20, - unique=True, blank=True, null=True, help_text=_("user phone number"), @@ -63,8 +64,12 @@ class User(AbstractUser, NiceModel): ], ) username: None = None - first_name = CharField(_("first_name"), max_length=150, blank=True, null=True) - last_name = CharField(_("last_name"), max_length=150, blank=True, null=True) + first_name = EncryptedCharField( + _("first_name"), max_length=150, blank=True, null=True + ) + last_name = EncryptedCharField( + _("last_name"), max_length=150, blank=True, null=True + ) avatar = ImageField( null=True, verbose_name=_("avatar"), @@ -90,6 +95,11 @@ class User(AbstractUser, NiceModel): ) activation_token = UUIDField(default=uuid4, verbose_name=_("activation token")) + activation_token_created = DateTimeField( + null=True, + blank=True, + verbose_name=_("activation token created"), + ) unsubscribe_token = UUIDField( default=uuid4, verbose_name=_("unsubscribe token"), @@ -102,7 +112,7 @@ class User(AbstractUser, NiceModel): blank=False, max_length=7, ) - attributes = JSONField( + attributes = EncryptedJSONTextField( verbose_name=_("attributes"), default=dict, blank=True, null=True ) @@ -135,8 +145,25 @@ class User(AbstractUser, NiceModel): def recently_viewed(self): return cache.get(f"user_{self.uuid}_rv", []) - def check_token(self, token): - return str(token) == str(self.activation_token) + def save(self, *args, **kwargs): + if self._state.adding and self.activation_token_created is None: + self.activation_token_created = timezone.now() + super().save(*args, **kwargs) + + def refresh_activation_token(self) -> None: + """Generate a fresh activation token and update its timestamp.""" + self.activation_token = uuid4() + self.activation_token_created = timezone.now() + + def check_token(self, token) -> bool: + from datetime import timedelta + + if str(token) != str(self.activation_token): + return False + if self.activation_token_created: + if timezone.now() > self.activation_token_created + timedelta(hours=24): + return False + return True def __str__(self): return self.email diff --git a/engine/vibes_auth/viewsets.py b/engine/vibes_auth/viewsets.py index 9942a514..9c7a072c 100644 --- a/engine/vibes_auth/viewsets.py +++ b/engine/vibes_auth/viewsets.py @@ -26,7 +26,10 @@ from engine.vibes_auth.serializers import ( MergeRecentlyViewedSerializer, UserSerializer, ) -from engine.vibes_auth.utils.emailing import send_reset_password_email_task +from engine.vibes_auth.utils.emailing import ( + send_reset_password_email_task, + send_verification_email_task, +) logger = logging.getLogger(__name__) @@ -130,6 +133,23 @@ class UserViewSet( ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h") ) def create(self, request: Request, *args, **kwargs) -> Response: + email = request.data.get("email") + if email: + with suppress(User.DoesNotExist): + pending = User.objects.get( + email=email, is_active=False, is_verified=False + ) + pending.refresh_activation_token() + pending.save() + send_verification_email_task.delay(user_pk=str(pending.uuid)) + return Response( + { + "detail": _( + "Account already registered but not yet activated. A new activation email has been sent." + ) + }, + status=status.HTTP_200_OK, + ) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() diff --git a/schon/fields.py b/schon/fields.py new file mode 100644 index 00000000..8e5dc8d5 --- /dev/null +++ b/schon/fields.py @@ -0,0 +1,43 @@ +import json + +from encrypted_fields.fields import EncryptedTextField + + +class EncryptedJSONTextField(EncryptedTextField): + """ + Stores a JSON-serializable value as Fernet-encrypted TEXT. + + Unlike EncryptedJSONField (which uses JSONB and breaks with psycopg3's + automatic JSONB→dict conversion), this field stores the JSON as a plain + TEXT column, encrypting the full serialised string. The column type in + the DB is text, not jsonb. + """ + + def get_internal_type(self) -> str: + return "TextField" + + def get_prep_value(self, value): + if value is not None and not isinstance(value, str): + value = json.dumps(value, default=str) + return super().get_prep_value(value) + + def from_db_value(self, value, expression, connection): + value = super().from_db_value(value, expression, connection) + if value is None: + return None + if isinstance(value, str): + try: + return json.loads(value) + except (ValueError, TypeError): + pass + return value + + def to_python(self, value): + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except (ValueError, TypeError): + pass + return value diff --git a/schon/graphql_validators.py b/schon/graphql_validators.py new file mode 100644 index 00000000..6761d700 --- /dev/null +++ b/schon/graphql_validators.py @@ -0,0 +1,56 @@ +from graphql import GraphQLError +from graphql.language.ast import ( + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + OperationDefinitionNode, +) +from graphql.validation import ValidationRule + +MAX_QUERY_DEPTH = 8 + + +def _max_field_depth(node, fragments, depth=0): + if not hasattr(node, "selection_set") or not node.selection_set: + return depth + return max( + ( + _selection_depth(sel, fragments, depth) + for sel in node.selection_set.selections + ), + default=depth, + ) + + +def _selection_depth(node, fragments, depth): + if isinstance(node, FieldNode): + return _max_field_depth(node, fragments, depth + 1) + if isinstance(node, InlineFragmentNode): + return _max_field_depth(node, fragments, depth) + if isinstance(node, FragmentSpreadNode): + fragment = fragments.get(node.name.value) + if fragment: + return _max_field_depth(fragment, fragments, depth) + return depth + + +class QueryDepthLimitRule(ValidationRule): + """Prevents DoS via deeply nested GraphQL queries (max depth: 8).""" + + def enter_document(self, node, *_args): + fragments = { + defn.name.value: defn + for defn in node.definitions + if isinstance(defn, FragmentDefinitionNode) + } + for defn in node.definitions: + if not isinstance(defn, OperationDefinitionNode): + continue + depth = _max_field_depth(defn, fragments) + if depth > MAX_QUERY_DEPTH: + self.report_error( + GraphQLError( + f"Query depth limit exceeded: max {MAX_QUERY_DEPTH}, got {depth}." + ) + ) diff --git a/schon/settings/base.py b/schon/settings/base.py index 31682fbc..6aebe2df 100644 --- a/schon/settings/base.py +++ b/schon/settings/base.py @@ -20,6 +20,7 @@ BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent INITIALIZED: bool = (BASE_DIR / ".initialized").exists() SECRET_KEY: str = getenv("SECRET_KEY", "SUPER_SECRET_KEY") +SALT_KEY: str = getenv("SALT_KEY", "schon-default-salt-key-change-in-production") DEBUG: bool = bool(int(getenv("DEBUG", "1"))) DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0"))) DEBUG_CELERY: bool = bool(int(getenv("DEBUG_DATABASE", "0"))) @@ -422,8 +423,24 @@ if getenv("SENTRY_DSN"): if isinstance(data, dict): # noinspection PyShadowingNames cleaned: dict[str, Any] = {} + _SENSITIVE_KEYS = { + "password", + "confirm_password", + "phone_number", + "phone", + "email", + "street", + "postal_code", + "postal", + "passport", + "secret", + "token", + "address", + "first_name", + "last_name", + } for key, value in data.items(): - if key.lower() in ("password", "confirm_password"): + if key.lower() in _SENSITIVE_KEYS: cleaned[key] = "[FILTERED]" else: cleaned[key] = scrub_sensitive(value) diff --git a/schon/settings/constance.py b/schon/settings/constance.py index b350d25b..78b5dcec 100644 --- a/schon/settings/constance.py +++ b/schon/settings/constance.py @@ -16,6 +16,14 @@ CONSTANCE_ADDITIONAL_FIELDS = { "widget": "engine.core.widgets.JSONTableWidget", }, ], + "password": [ + "django.forms.CharField", + { + "required": False, + "widget": "django.forms.PasswordInput", + "widget_attrs": {"render_value": True}, + }, + ], } CONSTANCE_CONFIG = OrderedDict( @@ -67,7 +75,11 @@ CONSTANCE_CONFIG = OrderedDict( ), ( "EMAIL_HOST_PASSWORD", - (getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), _("SMTP password")), + ( + getenv("EMAIL_HOST_PASSWORD", "SUPERsecretPASSWORD"), + _("SMTP password"), + "password", + ), ), ("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))), ### Features Options ### diff --git a/schon/settings/drf.py b/schon/settings/drf.py index 2a52c554..10bd9f87 100644 --- a/schon/settings/drf.py +++ b/schon/settings/drf.py @@ -26,6 +26,14 @@ REST_FRAMEWORK: dict[str, Any] = { "DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema", "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",), + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "60/minute", + "user": "600/minute", + }, "JSON_UNDERSCOREIZE": { "no_underscore_before_number": False, }, diff --git a/scripts/Unix/generate-environment-file.sh b/scripts/Unix/generate-environment-file.sh index 9a1a740c..9bc4b733 100755 --- a/scripts/Unix/generate-environment-file.sh +++ b/scripts/Unix/generate-environment-file.sh @@ -53,6 +53,7 @@ SCHON_LANGUAGE_CODE=$(prompt_default SCHON_LANGUAGE_CODE "en-gb") SECRET_KEY=$(prompt_autogen SECRET_KEY 32) JWT_SIGNING_KEY=$(prompt_autogen JWT_SIGNING_KEY 64) +SALT_KEY=$(prompt_autogen SALT_KEY 32) ALLOWED_HOSTS=$(prompt_default ALLOWED_HOSTS "schon.wiseless.xyz api.schon.wiseless.xyz") CSRF_TRUSTED_ORIGINS=$(prompt_default CSRF_TRUSTED_ORIGINS "https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz") @@ -101,6 +102,7 @@ SCHON_LANGUAGE_CODE="${SCHON_LANGUAGE_CODE}" SECRET_KEY="${SECRET_KEY}" JWT_SIGNING_KEY="${JWT_SIGNING_KEY}" +SALT_KEY="${SALT_KEY}" ALLOWED_HOSTS="${ALLOWED_HOSTS}" CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}" diff --git a/scripts/Unix/migrate-media.sh b/scripts/Unix/migrate-media.sh new file mode 100644 index 00000000..fd426cca --- /dev/null +++ b/scripts/Unix/migrate-media.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# ============================================================================= +# migrate-media.sh +# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon) +# into the new Docker-managed named volume (media-data). +# +# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance. +# ============================================================================= +set -euo pipefail + +HOST_MEDIA="$(pwd)/media" + +echo "Schon — media migration from host bind-mount → named volume" +echo "" + +if [ ! -d "$HOST_MEDIA" ]; then + echo "No ./media directory found on host. Nothing to migrate." + exit 0 +fi + +FILE_COUNT=$(find "$HOST_MEDIA" -type f | wc -l | tr -d ' ') +if [ "$FILE_COUNT" -eq 0 ]; then + echo "Host ./media directory is empty. Nothing to migrate." + exit 0 +fi + +echo "Found $FILE_COUNT file(s) in $HOST_MEDIA" +echo "" +echo "This will copy them into the Docker named volume 'media-data'." +read -rp "Continue? [y/N] " confirm +if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + echo "Migration cancelled." + exit 0 +fi + +echo "" +echo "Copying files..." +docker compose run --rm \ + -v "$HOST_MEDIA":/old_media:ro \ + app bash -c " + cp -a /old_media/. /app/media/ + COUNT=\$(find /app/media -type f | wc -l) + echo \"Migration complete: \$COUNT file(s) now in media volume.\" + " + +echo "" +echo "Done. You can safely ignore the host ./media directory — it is no longer used." +echo "To remove it: rm -rf ./media" diff --git a/scripts/Windows/generate-environment-file.ps1 b/scripts/Windows/generate-environment-file.ps1 index fa158e71..df3f3a38 100644 --- a/scripts/Windows/generate-environment-file.ps1 +++ b/scripts/Windows/generate-environment-file.ps1 @@ -59,6 +59,7 @@ $SCHON_LANGUAGE_CODE = Prompt-Default 'SCHON_LANGUAGE_CODE' 'en-gb' $SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32 $JWT_SIGNING_KEY = Prompt-AutoGen 'JWT_SIGNING_KEY' 64 +$SALT_KEY = Prompt-AutoGen 'SALT_KEY' 32 $ALLOWED_HOSTS = Prompt-Default 'ALLOWED_HOSTS' 'schon.wiseless.xyz api.schon.wiseless.xyz' $CSRF_TRUSTED_ORIGINS = Prompt-Default 'CSRF_TRUSTED_ORIGINS' 'https://schon.wiseless.xyz https://api.schon.wiseless.xyz https://www.schon.wiseless.xyz' @@ -108,6 +109,7 @@ $lines = @( "" "SECRET_KEY=""$SECRET_KEY""" "JWT_SIGNING_KEY=""$JWT_SIGNING_KEY""" + "SALT_KEY=""$SALT_KEY""" "" "ALLOWED_HOSTS=""$ALLOWED_HOSTS""" "CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS""" diff --git a/scripts/Windows/migrate-media.ps1 b/scripts/Windows/migrate-media.ps1 new file mode 100644 index 00000000..ac9b7756 --- /dev/null +++ b/scripts/Windows/migrate-media.ps1 @@ -0,0 +1,51 @@ +# ============================================================================= +# migrate-media.ps1 +# Migrates user-uploaded media files from host bind-mount (eVibes / early Schon) +# into the new Docker-managed named volume (media-data). +# +# Run this ONCE after upgrading from an eVibes or pre-volume Schon instance. +# ============================================================================= +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$HostMedia = Join-Path (Get-Location) "media" + +Write-Host "Schon - media migration from host bind-mount to named volume" -ForegroundColor Cyan +Write-Host "" + +if (-not (Test-Path $HostMedia)) { + Write-Host "No .\media directory found on host. Nothing to migrate." -ForegroundColor Yellow + exit 0 +} + +$FileCount = (Get-ChildItem -Path $HostMedia -Recurse -File).Count +if ($FileCount -eq 0) { + Write-Host "Host .\media directory is empty. Nothing to migrate." -ForegroundColor Yellow + exit 0 +} + +Write-Host "Found $FileCount file(s) in $HostMedia" -ForegroundColor White +Write-Host "" +Write-Host "This will copy them into the Docker named volume 'media-data'." -ForegroundColor White +$confirm = Read-Host "Continue? [y/N]" +if ($confirm -ne "y" -and $confirm -ne "Y") { + Write-Host "Migration cancelled." -ForegroundColor Yellow + exit 0 +} + +Write-Host "" +Write-Host "Copying files..." -ForegroundColor White + +$HostMediaUnix = $HostMedia -replace '\\', '/' -replace '^([A-Za-z]):', { "/$(($_.Value[0]).ToString().ToLower())" } + +docker compose run --rm ` + -v "${HostMediaUnix}:/old_media:ro" ` + app bash -c @" +cp -a /old_media/. /app/media/ +COUNT=`$(find /app/media -type f | wc -l) +echo "Migration complete: `$COUNT file(s) now in media volume." +"@ + +Write-Host "" +Write-Host "Done. You can safely ignore the host .\media directory - it is no longer used." -ForegroundColor Green +Write-Host "To remove it: Remove-Item -Recurse -Force .\media" -ForegroundColor Gray