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.
This commit is contained in:
parent
c3d23be973
commit
adec5503b2
23 changed files with 703 additions and 72 deletions
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,18 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/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 pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/app-entrypoint.sh /usr/local/bin/app-entrypoint.sh
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
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"]
|
ENTRYPOINT ["/usr/bin/bash", "app-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,17 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/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 pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/beat-entrypoint.sh /usr/local/bin/beat-entrypoint.sh
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
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"]
|
ENTRYPOINT ["/usr/bin/bash", "beat-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,17 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/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 pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/stock-updater-entrypoint.sh /usr/local/bin/stock-updater-entrypoint.sh
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
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"]
|
ENTRYPOINT ["/usr/bin/bash", "stock-updater-entrypoint.sh"]
|
||||||
|
|
@ -5,8 +5,7 @@ LABEL authors="fureunoir"
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
DEBIAN_FRONTEND=noninteractive \
|
DEBIAN_FRONTEND=noninteractive
|
||||||
PATH="/root/.local/bin:$PATH"
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -33,18 +32,16 @@ RUN set -eux; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
RUN curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
ENV PATH="/root/.local/bin:/root/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
RUN uv venv /opt/schon-python
|
|
||||||
ENV VIRTUAL_ENV=/opt/schon-python
|
ENV VIRTUAL_ENV=/opt/schon-python
|
||||||
ENV UV_PROJECT_ENVIRONMENT=/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 pyproject.toml pyproject.toml
|
||||||
COPY uv.lock uv.lock
|
COPY uv.lock uv.lock
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN uv venv /opt/schon-python && \
|
||||||
uv sync --extra worker --extra openai --locked
|
uv sync --extra worker --extra openai --locked
|
||||||
|
|
||||||
COPY ./scripts/Docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
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"]
|
ENTRYPOINT ["/usr/bin/bash", "worker-entrypoint.sh"]
|
||||||
|
|
@ -13,6 +13,8 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- static-data:/app/static
|
||||||
|
- media-data:/app/media
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|
@ -131,6 +133,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -159,6 +162,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -187,6 +191,7 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- media-data:/app/media
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -221,3 +226,5 @@ volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
es-data:
|
es-data:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
|
static-data:
|
||||||
|
media-data:
|
||||||
|
|
|
||||||
|
|
@ -1046,16 +1046,10 @@ class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
|
||||||
"country",
|
"country",
|
||||||
"user",
|
"user",
|
||||||
)
|
)
|
||||||
list_filter = (
|
# country and region are encrypted — DB-level filtering is not possible
|
||||||
"country",
|
list_filter = ()
|
||||||
"region",
|
# street, city, postal_code are encrypted — DB-level search is not possible
|
||||||
)
|
search_fields = ("user__email",)
|
||||||
search_fields = (
|
|
||||||
"street",
|
|
||||||
"city",
|
|
||||||
"postal_code",
|
|
||||||
"user__email",
|
|
||||||
)
|
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"uuid",
|
"uuid",
|
||||||
"modified",
|
"modified",
|
||||||
|
|
|
||||||
186
engine/core/migrations/0057_encrypt_address_fields.py
Normal file
186
engine/core/migrations/0057_encrypt_address_fields.py
Normal file
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
@ -47,6 +47,7 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.http import urlsafe_base64_encode
|
from django.utils.http import urlsafe_base64_encode
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_extensions.db.fields import AutoSlugField
|
from django_extensions.db.fields import AutoSlugField
|
||||||
|
from encrypted_fields.fields import EncryptedCharField, EncryptedTextField
|
||||||
from mptt.fields import TreeForeignKey
|
from mptt.fields import TreeForeignKey
|
||||||
from mptt.models import MPTTModel
|
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.utils.markdown import strip_markdown
|
||||||
from engine.core.validators import validate_category_image_dimensions
|
from engine.core.validators import validate_category_image_dimensions
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
|
from schon.fields import EncryptedJSONTextField
|
||||||
from schon.utils.misc import create_object
|
from schon.utils.misc import create_object
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -1133,18 +1135,18 @@ class Address(NiceModel):
|
||||||
|
|
||||||
is_publicly_visible = False
|
is_publicly_visible = False
|
||||||
|
|
||||||
address_line = TextField(
|
address_line = EncryptedTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("address line for the customer"),
|
help_text=_("address line for the customer"),
|
||||||
verbose_name=_("address line"),
|
verbose_name=_("address line"),
|
||||||
)
|
)
|
||||||
street = CharField(_("street"), max_length=255, null=True)
|
street = EncryptedCharField(_("street"), max_length=255, null=True)
|
||||||
district = CharField(_("district"), max_length=255, null=True)
|
district = EncryptedCharField(_("district"), max_length=255, null=True)
|
||||||
city = CharField(_("city"), max_length=100, null=True)
|
city = EncryptedCharField(_("city"), max_length=100, null=True)
|
||||||
region = CharField(_("region"), max_length=100, null=True)
|
region = EncryptedCharField(_("region"), max_length=100, null=True)
|
||||||
postal_code = CharField(_("postal code"), max_length=20, null=True)
|
postal_code = EncryptedCharField(_("postal code"), max_length=20, null=True)
|
||||||
country = CharField(_("country"), max_length=40, null=True)
|
country = EncryptedCharField(_("country"), max_length=40, null=True)
|
||||||
|
|
||||||
location: PointField = PointField(
|
location: PointField = PointField(
|
||||||
geography=True,
|
geography=True,
|
||||||
|
|
@ -1154,13 +1156,13 @@ class Address(NiceModel):
|
||||||
help_text=_("geolocation point: (longitude, latitude)"),
|
help_text=_("geolocation point: (longitude, latitude)"),
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_data = JSONField(
|
raw_data = EncryptedJSONTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("full JSON response from geocoder for this address"),
|
help_text=_("full JSON response from geocoder for this address"),
|
||||||
)
|
)
|
||||||
|
|
||||||
api_response = JSONField(
|
api_response = EncryptedJSONTextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("stored JSON response from the geocoding service"),
|
help_text=_("stored JSON response from the geocoding service"),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import os
|
||||||
import traceback
|
import traceback
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
from os import getenv
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from constance import config
|
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.utils import extend_schema_view
|
||||||
from drf_spectacular.views import SpectacularAPIView
|
from drf_spectacular.views import SpectacularAPIView
|
||||||
from graphene_file_upload.django import FileUploadGraphQLView
|
from graphene_file_upload.django import FileUploadGraphQLView
|
||||||
|
from graphql.validation import NoSchemaIntrospectionCustomRule
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.parsers import MultiPartParser
|
from rest_framework.parsers import MultiPartParser
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
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.emailing import contact_us_email
|
||||||
from engine.core.utils.languages import get_flag_by_language
|
from engine.core.utils.languages import get_flag_by_language
|
||||||
from engine.payments.serializers import TransactionProcessSerializer
|
from engine.payments.serializers import TransactionProcessSerializer
|
||||||
|
from schon.graphql_validators import QueryDepthLimitRule
|
||||||
from schon.utils.renderers import camelize
|
from schon.utils.renderers import camelize
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class CustomGraphQLView(FileUploadGraphQLView):
|
||||||
|
validation_rules = tuple(_graphql_validation_rules)
|
||||||
|
|
||||||
def get_context(self, request):
|
def get_context(self, request):
|
||||||
return request
|
return request
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1221,7 +1221,7 @@ class AddressViewSet(SchonViewSet):
|
||||||
filterset_class = AddressFilter
|
filterset_class = AddressFilter
|
||||||
queryset = Address.objects.all()
|
queryset = Address.objects.all()
|
||||||
serializer_class = AddressSerializer
|
serializer_class = AddressSerializer
|
||||||
additional = {"create": "ALLOW", "retrieve": "ALLOW"}
|
additional = {"create": "ALLOW"}
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
|
|
@ -1239,15 +1239,6 @@ class AddressViewSet(SchonViewSet):
|
||||||
|
|
||||||
return Address.objects.none()
|
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:
|
def create(self, request: Request, *args, **kwargs) -> Response:
|
||||||
create_serializer = AddressCreateSerializer(
|
create_serializer = AddressCreateSerializer(
|
||||||
data=request.data, context={"request": request}
|
data=request.data, context={"request": request}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,8 @@ class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff")
|
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 = (
|
list_filter = (
|
||||||
"is_verified",
|
"is_verified",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
@ -23,6 +23,7 @@ from django.templatetags.static import static
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from encrypted_fields.fields import EncryptedCharField
|
||||||
from rest_framework_simplejwt.token_blacklist.models import (
|
from rest_framework_simplejwt.token_blacklist.models import (
|
||||||
BlacklistedToken as BaseBlacklistedToken,
|
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.choices import SenderType, ThreadStatus
|
||||||
from engine.vibes_auth.managers import UserManager
|
from engine.vibes_auth.managers import UserManager
|
||||||
from engine.vibes_auth.validators import validate_phone_number
|
from engine.vibes_auth.validators import validate_phone_number
|
||||||
|
from schon.fields import EncryptedJSONTextField
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser, NiceModel):
|
class User(AbstractUser, NiceModel):
|
||||||
|
|
@ -51,10 +53,9 @@ class User(AbstractUser, NiceModel):
|
||||||
return "users/" + str(self.uuid) + "/" + args[0]
|
return "users/" + str(self.uuid) + "/" + args[0]
|
||||||
|
|
||||||
email = EmailField(_("email"), unique=True, help_text=_("user email address"))
|
email = EmailField(_("email"), unique=True, help_text=_("user email address"))
|
||||||
phone_number = CharField(
|
phone_number = EncryptedCharField(
|
||||||
_("phone_number"),
|
_("phone_number"),
|
||||||
max_length=20,
|
max_length=20,
|
||||||
unique=True,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
help_text=_("user phone number"),
|
help_text=_("user phone number"),
|
||||||
|
|
@ -63,8 +64,12 @@ class User(AbstractUser, NiceModel):
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
username: None = None
|
username: None = None
|
||||||
first_name = CharField(_("first_name"), max_length=150, blank=True, null=True)
|
first_name = EncryptedCharField(
|
||||||
last_name = CharField(_("last_name"), max_length=150, blank=True, null=True)
|
_("first_name"), max_length=150, blank=True, null=True
|
||||||
|
)
|
||||||
|
last_name = EncryptedCharField(
|
||||||
|
_("last_name"), max_length=150, blank=True, null=True
|
||||||
|
)
|
||||||
avatar = ImageField(
|
avatar = ImageField(
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name=_("avatar"),
|
verbose_name=_("avatar"),
|
||||||
|
|
@ -90,6 +95,11 @@ class User(AbstractUser, NiceModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
activation_token = UUIDField(default=uuid4, verbose_name=_("activation token"))
|
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(
|
unsubscribe_token = UUIDField(
|
||||||
default=uuid4,
|
default=uuid4,
|
||||||
verbose_name=_("unsubscribe token"),
|
verbose_name=_("unsubscribe token"),
|
||||||
|
|
@ -102,7 +112,7 @@ class User(AbstractUser, NiceModel):
|
||||||
blank=False,
|
blank=False,
|
||||||
max_length=7,
|
max_length=7,
|
||||||
)
|
)
|
||||||
attributes = JSONField(
|
attributes = EncryptedJSONTextField(
|
||||||
verbose_name=_("attributes"), default=dict, blank=True, null=True
|
verbose_name=_("attributes"), default=dict, blank=True, null=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,8 +145,25 @@ class User(AbstractUser, NiceModel):
|
||||||
def recently_viewed(self):
|
def recently_viewed(self):
|
||||||
return cache.get(f"user_{self.uuid}_rv", [])
|
return cache.get(f"user_{self.uuid}_rv", [])
|
||||||
|
|
||||||
def check_token(self, token):
|
def save(self, *args, **kwargs):
|
||||||
return str(token) == str(self.activation_token)
|
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):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ from engine.vibes_auth.serializers import (
|
||||||
MergeRecentlyViewedSerializer,
|
MergeRecentlyViewedSerializer,
|
||||||
UserSerializer,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -130,6 +133,23 @@ class UserViewSet(
|
||||||
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
|
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
|
||||||
)
|
)
|
||||||
def create(self, request: Request, *args, **kwargs) -> Response:
|
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 = self.get_serializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
|
||||||
43
schon/fields.py
Normal file
43
schon/fields.py
Normal file
|
|
@ -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
|
||||||
56
schon/graphql_validators.py
Normal file
56
schon/graphql_validators.py
Normal file
|
|
@ -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}."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -20,6 +20,7 @@ BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
|
||||||
INITIALIZED: bool = (BASE_DIR / ".initialized").exists()
|
INITIALIZED: bool = (BASE_DIR / ".initialized").exists()
|
||||||
|
|
||||||
SECRET_KEY: str = getenv("SECRET_KEY", "SUPER_SECRET_KEY")
|
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: bool = bool(int(getenv("DEBUG", "1")))
|
||||||
DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
DEBUG_DATABASE: bool = bool(int(getenv("DEBUG_DATABASE", "0")))
|
||||||
DEBUG_CELERY: 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):
|
if isinstance(data, dict):
|
||||||
# noinspection PyShadowingNames
|
# noinspection PyShadowingNames
|
||||||
cleaned: dict[str, Any] = {}
|
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():
|
for key, value in data.items():
|
||||||
if key.lower() in ("password", "confirm_password"):
|
if key.lower() in _SENSITIVE_KEYS:
|
||||||
cleaned[key] = "[FILTERED]"
|
cleaned[key] = "[FILTERED]"
|
||||||
else:
|
else:
|
||||||
cleaned[key] = scrub_sensitive(value)
|
cleaned[key] = scrub_sensitive(value)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ CONSTANCE_ADDITIONAL_FIELDS = {
|
||||||
"widget": "engine.core.widgets.JSONTableWidget",
|
"widget": "engine.core.widgets.JSONTableWidget",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"password": [
|
||||||
|
"django.forms.CharField",
|
||||||
|
{
|
||||||
|
"required": False,
|
||||||
|
"widget": "django.forms.PasswordInput",
|
||||||
|
"widget_attrs": {"render_value": True},
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
CONSTANCE_CONFIG = OrderedDict(
|
CONSTANCE_CONFIG = OrderedDict(
|
||||||
|
|
@ -67,7 +75,11 @@ CONSTANCE_CONFIG = OrderedDict(
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"EMAIL_HOST_PASSWORD",
|
"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"))),
|
("EMAIL_FROM", (getenv("EMAIL_FROM", "Schon"), _("Mail from option"))),
|
||||||
### Features Options ###
|
### Features Options ###
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,14 @@ REST_FRAMEWORK: dict[str, Any] = {
|
||||||
"DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",),
|
"DEFAULT_PARSER_CLASSES": ("schon.utils.parsers.CamelCaseParser",),
|
||||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema",
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.generators.AutoSchema",
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.AllowAny",),
|
"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": {
|
"JSON_UNDERSCOREIZE": {
|
||||||
"no_underscore_before_number": False,
|
"no_underscore_before_number": False,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ SCHON_LANGUAGE_CODE=$(prompt_default SCHON_LANGUAGE_CODE "en-gb")
|
||||||
|
|
||||||
SECRET_KEY=$(prompt_autogen SECRET_KEY 32)
|
SECRET_KEY=$(prompt_autogen SECRET_KEY 32)
|
||||||
JWT_SIGNING_KEY=$(prompt_autogen JWT_SIGNING_KEY 64)
|
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")
|
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")
|
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}"
|
SECRET_KEY="${SECRET_KEY}"
|
||||||
JWT_SIGNING_KEY="${JWT_SIGNING_KEY}"
|
JWT_SIGNING_KEY="${JWT_SIGNING_KEY}"
|
||||||
|
SALT_KEY="${SALT_KEY}"
|
||||||
|
|
||||||
ALLOWED_HOSTS="${ALLOWED_HOSTS}"
|
ALLOWED_HOSTS="${ALLOWED_HOSTS}"
|
||||||
CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}"
|
CSRF_TRUSTED_ORIGINS="${CSRF_TRUSTED_ORIGINS}"
|
||||||
|
|
|
||||||
48
scripts/Unix/migrate-media.sh
Normal file
48
scripts/Unix/migrate-media.sh
Normal file
|
|
@ -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"
|
||||||
|
|
@ -59,6 +59,7 @@ $SCHON_LANGUAGE_CODE = Prompt-Default 'SCHON_LANGUAGE_CODE' 'en-gb'
|
||||||
|
|
||||||
$SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32
|
$SECRET_KEY = Prompt-AutoGen 'SECRET_KEY' 32
|
||||||
$JWT_SIGNING_KEY = Prompt-AutoGen 'JWT_SIGNING_KEY' 64
|
$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'
|
$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'
|
$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"""
|
"SECRET_KEY=""$SECRET_KEY"""
|
||||||
"JWT_SIGNING_KEY=""$JWT_SIGNING_KEY"""
|
"JWT_SIGNING_KEY=""$JWT_SIGNING_KEY"""
|
||||||
|
"SALT_KEY=""$SALT_KEY"""
|
||||||
""
|
""
|
||||||
"ALLOWED_HOSTS=""$ALLOWED_HOSTS"""
|
"ALLOWED_HOSTS=""$ALLOWED_HOSTS"""
|
||||||
"CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS"""
|
"CSRF_TRUSTED_ORIGINS=""$CSRF_TRUSTED_ORIGINS"""
|
||||||
|
|
|
||||||
51
scripts/Windows/migrate-media.ps1
Normal file
51
scripts/Windows/migrate-media.ps1
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue