From ff99177139cc4b7a9d68d103024541b31211f4c2 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sun, 1 Mar 2026 22:22:59 +0300 Subject: [PATCH 1/5] feat(viewsets): add endpoint to retrieve products by identifier introduce `exact_list` action in `viewsets.py` to retrieve products by `uuid`, `slug`, or `sku` identifiers. Includes input validation and ratelimiting. Adds corresponding schema documentation and a GraphQL mutation for similar functionality. --- engine/core/docs/drf/viewsets.py | 14 ++++++++++++++ engine/core/graphene/mutations.py | 28 +++++++++++++++++++++++++++- engine/core/viewsets.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/engine/core/docs/drf/viewsets.py b/engine/core/docs/drf/viewsets.py index 5d7e0a67..eb6b0086 100644 --- a/engine/core/docs/drf/viewsets.py +++ b/engine/core/docs/drf/viewsets.py @@ -750,6 +750,20 @@ PRODUCT_SCHEMA = { **BASE_ERRORS, }, ), + "exact_list": extend_schema( + tags=[ + "products", + ], + summary=_("retrieve exact products by identifier"), + description=_( + "retrieve a list of products by identifier type (uuid, slug, or sku). " + "Send a POST request with `identificator_type` and `identificators` (list of values)." + ), + responses={ + status.HTTP_200_OK: ProductSimpleSerializer(many=True), + **BASE_ERRORS, + }, + ), "seo_meta": extend_schema( tags=[ "products", diff --git a/engine/core/graphene/mutations.py b/engine/core/graphene/mutations.py index eca0f2c3..a0b82b4f 100644 --- a/engine/core/graphene/mutations.py +++ b/engine/core/graphene/mutations.py @@ -15,10 +15,11 @@ from engine.core.graphene.object_types import ( BulkProductInput, FeedbackType, OrderType, + ProductType, SearchResultsType, WishlistType, ) -from engine.core.models import Address, Order, OrderProduct, Wishlist +from engine.core.models import Address, Order, OrderProduct, Product, Wishlist from engine.core.utils import format_attributes, is_url_safe from engine.core.utils.caching import web_cache from engine.core.utils.emailing import contact_us_email @@ -574,6 +575,31 @@ class BuyProduct(Mutation): ) +class RetrieveExactProducts(Mutation): + class Meta: + description = _("retrieve exact products by identificator") + + class Arguments: + identificator_type = String(required=True) + identificators = List(String, required=True) + + products = List(ProductType, required=True) + + def mutate(self, info, identificator_type: str, identificators: list[str]): + match identificator_type: + case "uuid": + products = Product.objects.filter(uuid__in=identificators) + case "slug": + products = Product.objects.filter(slug__in=identificators) + case "sku": + products = Product.objects.filter(sku__in=identificators) + case _: + raise BadRequest( + _("identificator_type must be one of: uuid, slug, sku") + ) + return RetrieveExactProducts(products=products) # ty: ignore[unknown-argument] + + # noinspection PyUnusedLocal,PyTypeChecker class FeedbackProductAction(Mutation): class Meta: diff --git a/engine/core/viewsets.py b/engine/core/viewsets.py index 4b43373e..4e2a501a 100644 --- a/engine/core/viewsets.py +++ b/engine/core/viewsets.py @@ -530,6 +530,34 @@ class ProductViewSet(SchonViewSet): self.check_object_permissions(self.request, obj) return obj + @action(detail=False, methods=("POST",), url_path="retrieve-exact") + @method_decorator(ratelimit(key="ip", rate="8/s" if not settings.DEBUG else "44/s")) + def exact_list(self, request: Request, *args, **kwargs) -> Response: + identificator_type = request.data.get("identificator_type") + identificators = request.data.get("identificators", []) + + if not identificator_type or not identificators: + return Response( + {"detail": _("identificator_type and identificators are required")}, + status=status.HTTP_400_BAD_REQUEST, + ) + + match identificator_type: + case "uuid": + qs = self.get_queryset().filter(uuid__in=identificators) + case "slug": + qs = self.get_queryset().filter(slug__in=identificators) + case "sku": + qs = self.get_queryset().filter(sku__in=identificators) + case _: + return Response( + {"detail": _("identificator_type must be one of: uuid, slug, sku")}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ProductSimpleSerializer(qs, many=True) + return Response(serializer.data) + # noinspection PyUnusedLocal @action(detail=True, methods=("GET",), url_path="feedbacks") @method_decorator(ratelimit(key="ip", rate="2/s" if not settings.DEBUG else "44/s")) From c3d23be9734d1c24816710801333966617b4e41e Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Sun, 1 Mar 2026 22:58:49 +0300 Subject: [PATCH 2/5] feat(dependencies): add `django-fernet-encrypted-fields` and update packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added `django-fernet-encrypted-fields` for encrypted field support in Django. Updated dependencies including `django-health-check` (4.0.6 → 4.1.0), `django-modeltranslation` (0.19.19 → 0.20.2), and `filelock` (3.24.3 → 3.25.0) to their latest versions for improved functionality and compatibility. --- pyproject.toml | 7 ++++--- uv.lock | 39 +++++++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dac42eff..4526aae0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,13 +21,14 @@ dependencies = [ "django-dbbackup==5.2.0", "django-elasticsearch-dsl==8.2", "django-extensions==4.1", + "django-fernet-encrypted-fields==0.3.1", "django-filter==25.2", - "django-health-check==4.0.6", + "django-health-check==4.1.0", "django-import-export[all]==4.4.0", "django-json-widget==2.1.1", "django-model-utils==5.0.0", "django-md-field==0.1.0", - "django-modeltranslation==0.19.19", + "django-modeltranslation==0.20.2", "django-mptt==0.18.0", "django-redis==6.0.0", "django-ratelimit==4.1.0", @@ -47,7 +48,7 @@ dependencies = [ "drf-spectacular-websocket==1.3.1", "drf-orjson-renderer==1.8.0", "elasticsearch-dsl==8.18.0", - "filelock==3.24.3", + "filelock==3.25.0", "filetype==1.2.0", "graphene-django==3.2.3", "graphene-file-upload==1.3.0", diff --git a/uv.lock b/uv.lock index 61acb3af..883731c1 100644 --- a/uv.lock +++ b/uv.lock @@ -866,6 +866,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" }, ] +[[package]] +name = "django-fernet-encrypted-fields" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" }, +] + [[package]] name = "django-filter" version = "25.2" @@ -880,15 +893,15 @@ wheels = [ [[package]] name = "django-health-check" -version = "4.0.6" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "dnspython" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/fe/718725c58fd177cff0cfb8abe3010f2cad582713f2bc52eaf7120b750dec/django_health_check-4.0.6.tar.gz", hash = "sha256:03837041ba8a235e810e16218f2ef3feb372c4af72776fa3676c16435c72171c", size = 20763, upload-time = "2026-02-23T17:11:40.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/e6/37aefb657a522c410844d7fe617c997036ef570157905518c2bf03abfb8d/django_health_check-4.1.0.tar.gz", hash = "sha256:7c9ea01edad40b3ea1d9b5780fcec3fdc1e263f9d0c8503af3b043e6a4b38964", size = 21124, upload-time = "2026-03-01T16:03:10.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/44/2fa6ec47c1c18159c094f7d00397a208b6311e8b26d603dd22ba6e79b99d/django_health_check-4.0.6-py3-none-any.whl", hash = "sha256:efba106bc4f92b1b084f3af751e9eeb0b5c1af77d0af212e432ede2ba8f1e94f", size = 25813, upload-time = "2026-02-23T17:11:39.419Z" }, + { url = "https://files.pythonhosted.org/packages/a2/01/a2a8e5250c0bf352660208bf62dd6bdc67d3c52c16a412da9c382b0bfd12/django_health_check-4.1.0-py3-none-any.whl", hash = "sha256:a823ec81aa108909855159fa1fdb84d443d0c25057de4cc98bfe230ae52d0751", size = 26173, upload-time = "2026-03-01T16:03:09.03Z" }, ] [[package]] @@ -957,15 +970,15 @@ wheels = [ [[package]] name = "django-modeltranslation" -version = "0.19.19" +version = "0.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/af/3c34fca94fccae681a15734bc6557cf9be958d1d063ddbb233580b894054/django_modeltranslation-0.19.19.tar.gz", hash = "sha256:26dd8454f19540a2eb05e303608a2d89dd80aacb75ab95f8ea272cf4324d2644", size = 77750, upload-time = "2025-12-15T10:25:38.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/aa/3492ab6250e0c87e26f2f4fd522e4c9b214da871bfe4089a13888a802c6e/django_modeltranslation-0.20.2.tar.gz", hash = "sha256:d687bdcae9305dcdc8d644b75f1d687f355073e82049f4b9c4ba0feb347a0980", size = 81589, upload-time = "2026-03-01T15:59:07.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/2a/fdf265e91e37ee363b2c45f3d2a01752a3b38ab082a3ec0b0677105bd367/django_modeltranslation-0.19.19-py3-none-any.whl", hash = "sha256:55ac2ce47486b9e8ca18b155f7705170a53b7e1346bf7bf89304e99787486e8f", size = 93441, upload-time = "2025-12-15T10:25:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/79/46/6c1edcab4233baa02cc6cfaf43e01ccc0c28757f9450171e54d790512e21/django_modeltranslation-0.20.2-py3-none-any.whl", hash = "sha256:964385c3cf3a584f564e995f2e9ff0e961d0297ef04ff51821ac529e12064290", size = 98257, upload-time = "2026-03-01T15:59:06.09Z" }, ] [[package]] @@ -1333,11 +1346,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.24.3" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] @@ -3352,6 +3365,7 @@ dependencies = [ { name = "django-debug-toolbar" }, { name = "django-elasticsearch-dsl" }, { name = "django-extensions" }, + { name = "django-fernet-encrypted-fields" }, { name = "django-filter" }, { name = "django-health-check" }, { name = "django-import-export", extra = ["all"] }, @@ -3454,13 +3468,14 @@ requires-dist = [ { name = "django-debug-toolbar", specifier = "==6.2.0" }, { name = "django-elasticsearch-dsl", specifier = "==8.2" }, { name = "django-extensions", specifier = "==4.1" }, + { name = "django-fernet-encrypted-fields", specifier = "==0.3.1" }, { name = "django-filter", specifier = "==25.2" }, - { name = "django-health-check", specifier = "==4.0.6" }, + { name = "django-health-check", specifier = "==4.1.0" }, { name = "django-import-export", extras = ["all"], specifier = "==4.4.0" }, { name = "django-json-widget", specifier = "==2.1.1" }, { name = "django-md-field", specifier = "==0.1.0" }, { name = "django-model-utils", specifier = "==5.0.0" }, - { name = "django-modeltranslation", specifier = "==0.19.19" }, + { name = "django-modeltranslation", specifier = "==0.20.2" }, { name = "django-mptt", specifier = "==0.18.0" }, { name = "django-ratelimit", specifier = "==4.1.0" }, { name = "django-redis", specifier = "==6.0.0" }, @@ -3481,7 +3496,7 @@ requires-dist = [ { name = "drf-spectacular", specifier = "==0.29.0" }, { name = "drf-spectacular-websocket", specifier = "==1.3.1" }, { name = "elasticsearch-dsl", specifier = "==8.18.0" }, - { name = "filelock", specifier = "==3.24.3" }, + { name = "filelock", specifier = "==3.25.0" }, { name = "filetype", specifier = "==1.2.0" }, { name = "graphene-django", specifier = "==3.2.3" }, { name = "graphene-file-upload", specifier = "==1.3.0" }, From adec5503b2ef97d6c1144876cff2db80023cb25a Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 2 Mar 2026 00:11:57 +0300 Subject: [PATCH 3/5] feat(core/auth): enable encryption for sensitive fields and token handling Add encryption for user PII fields (phone number, name, attributes) and address fields to enhance data security. Introduced timestamped activation tokens for improved validation. Included migrations to encrypt existing plaintext data. Refactored GraphQL settings to limit query depth and optionally disable introspection for enhanced API defense. Implemented throttling to safeguard API rates. Improved Dockerfiles for better user management and restored media migration tools for smooth instance upgrades. --- Dockerfiles/app.Dockerfile | 20 +- Dockerfiles/beat.Dockerfile | 21 +- Dockerfiles/stock_updater.Dockerfile | 21 +- Dockerfiles/worker.Dockerfile | 20 +- docker-compose.yml | 7 + engine/core/admin.py | 14 +- .../migrations/0057_encrypt_address_fields.py | 186 ++++++++++++++++++ engine/core/models.py | 20 +- engine/core/views.py | 10 + engine/core/viewsets.py | 11 +- engine/vibes_auth/admin.py | 3 +- .../0010_encrypt_user_pii_and_token_expiry.py | 136 +++++++++++++ engine/vibes_auth/models.py | 41 +++- engine/vibes_auth/viewsets.py | 22 ++- schon/fields.py | 43 ++++ schon/graphql_validators.py | 56 ++++++ schon/settings/base.py | 19 +- schon/settings/constance.py | 14 +- schon/settings/drf.py | 8 + scripts/Unix/generate-environment-file.sh | 2 + scripts/Unix/migrate-media.sh | 48 +++++ scripts/Windows/generate-environment-file.ps1 | 2 + scripts/Windows/migrate-media.ps1 | 51 +++++ 23 files changed, 703 insertions(+), 72 deletions(-) create mode 100644 engine/core/migrations/0057_encrypt_address_fields.py create mode 100644 engine/vibes_auth/migrations/0010_encrypt_user_pii_and_token_expiry.py create mode 100644 schon/fields.py create mode 100644 schon/graphql_validators.py create mode 100644 scripts/Unix/migrate-media.sh create mode 100644 scripts/Windows/migrate-media.ps1 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 From c3b4637044663b7c7ead478d122328076b05900a Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 2 Mar 2026 00:15:36 +0300 Subject: [PATCH 4/5] feat(uninstall): enhance volume removal for additional data types add handling for postgres-data, redis-data, static-data, and media-data volumes in both Windows and Unix uninstall scripts. Ensures errors are logged if volume removal fails. --- scripts/Unix/uninstall.sh | 4 ++++ scripts/Windows/uninstall.ps1 | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/Unix/uninstall.sh b/scripts/Unix/uninstall.sh index 125b8e41..81566cbc 100644 --- a/scripts/Unix/uninstall.sh +++ b/scripts/Unix/uninstall.sh @@ -13,6 +13,10 @@ log_success "Services were shut down successfully!" # Remove volumes log_step "Removing volumes..." +docker volume remove -f schon_postgres-data || log_warning "Failed to remove postgres-data volume" +docker volume remove -f schon_redis-data || log_warning "Failed to remove redis-data volume" +docker volume remove -f schon_static-data || log_warning "Failed to remove static-data volume" +docker volume remove -f schon_media-data || log_warning "Failed to remove media-data volume" docker volume rm -f schon_prometheus-data || log_warning "Failed to remove prometheus-data volume" docker volume rm -f schon_es-data || log_warning "Failed to remove es-data volume" log_success "Volumes were removed successfully!" diff --git a/scripts/Windows/uninstall.ps1 b/scripts/Windows/uninstall.ps1 index db1658e6..ec8d05c2 100644 --- a/scripts/Windows/uninstall.ps1 +++ b/scripts/Windows/uninstall.ps1 @@ -21,6 +21,22 @@ Write-Success "Services were shut down successfully!" # Remove volumes Write-Step "Removing volumes..." +docker volume remove -f schon_postgres-data +if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to remove postgres-data volume" +} +docker volume remove -f schon_redis-data +if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to remove redis-data volume" +} +docker volume remove -f schon_static-data +if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to remove static-data volume" +} +docker volume remove -f schon_media-data +if ($LASTEXITCODE -ne 0) { + Write-Warning-Custom "Failed to remove media-data volume" +} docker volume remove -f schon_prometheus-data if ($LASTEXITCODE -ne 0) { Write-Warning-Custom "Failed to remove prometheus-data volume" From 6d24ee0c929bce610f4ee65f27779fc5cdaec4ae Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 2 Mar 2026 00:33:21 +0300 Subject: [PATCH 5/5] refactor(core): use `defaults` in `get_or_create` and add annotations Refactored `get_or_create` calls in `demo_data.py` to use the `defaults` parameter for cleaner code and consistency. Added type annotations for `get_min_price` and `get_max_price` in serializers for improved type safety. --- engine/core/management/commands/demo_data.py | 26 +++++++++++--------- engine/core/serializers/detail.py | 4 +-- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/engine/core/management/commands/demo_data.py b/engine/core/management/commands/demo_data.py index 9ddccca2..7d7a1f8f 100644 --- a/engine/core/management/commands/demo_data.py +++ b/engine/core/management/commands/demo_data.py @@ -99,11 +99,13 @@ class Command(BaseCommand): def staff_user(self): user, _ = User.objects.get_or_create( email=f"staff@{DEMO_EMAIL_DOMAIN}", - first_name="Alice", - last_name="Schon", - is_staff=True, - is_active=True, - is_verified=True, + defaults={ + "first_name": "Alice", + "last_name": "Schon", + "is_staff": True, + "is_active": True, + "is_verified": True, + }, ) if _: user.set_password("Staff!Demo888") @@ -116,12 +118,14 @@ class Command(BaseCommand): def super_user(self): user, _ = User.objects.get_or_create( email=f"super@{DEMO_EMAIL_DOMAIN}", - first_name="Bob", - last_name="Schon", - is_superuser=True, - is_staff=True, - is_active=True, - is_verified=True, + defaults={ + "first_name": "Bob", + "last_name": "Schon", + "is_superuser": True, + "is_staff": True, + "is_active": True, + "is_verified": True, + }, ) if _: user.set_password("Super!Demo888") diff --git a/engine/core/serializers/detail.py b/engine/core/serializers/detail.py index f656cbb0..e8d978f3 100644 --- a/engine/core/serializers/detail.py +++ b/engine/core/serializers/detail.py @@ -107,10 +107,10 @@ class CategoryDetailSerializer(ModelSerializer): return list(serializer.data) return [] - def get_min_price(self, obj: Category): + def get_min_price(self, obj: Category) -> float: return obj.min_price - def get_max_price(self, obj: Category): + def get_max_price(self, obj: Category) -> float: return obj.max_price