From 28fa6fc874f4998a4327d427dd41b23cccc4d535 Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Mon, 28 Oct 2024 12:10:42 +0000 Subject: [PATCH 1/6] Update poetry dependency management --- Dockerfile | 30 ++++++++++++++---------------- docker-compose.yml | 13 ++++--------- run.sh | 1 + 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/Dockerfile b/Dockerfile index bbda708e..3cfd3aa6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,26 @@ -FROM python:3.13.0-slim-bookworm +FROM python:3.13.0-slim-bookworm as base-image RUN apt-get update && \ apt-get install -y --no-install-recommends \ - gettext \ - docutils-common \ - curl \ - pipx \ - wget - -COPY ./pyproject.toml /pyproject.toml -COPY ./poetry.lock /poetry.lock + gettext \ + docutils-common \ + curl \ + wget + ENV PATH="$PATH:/root/.local/bin" \ - POETRY_NO_INTERACTION=1 \ - POETRY_VIRTUALENVS_CREATE=false \ - POETRY_CACHE_DIR='/var/cache/pypoetry' \ TINI_VERSION=v0.19.0 \ # poetry: + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_CREATE=true \ + POETRY_CACHE_DIR='/var/cache/pypoetry' \ POETRY_VERSION=1.8.4 + # Installing `poetry` package manager: # https://github.com/python-poetry/poetry -RUN pip install --upgrade pip -RUN pipx install --python python3 poetry==${POETRY_VERSION} -RUN poetry install - +RUN curl -sSL https://install.python-poetry.org | python3 - RUN dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \ && wget "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${dpkgArch}" -O /usr/local/bin/tini \ @@ -36,6 +31,9 @@ RUN mkdir -p /jandig/src /jandig/locale /jandig/docs /jandig/static /jandig/buil WORKDIR /jandig +COPY ./pyproject.toml /jandig/pyproject.toml +COPY ./poetry.lock /jandig/poetry.lock + COPY ./src/ /jandig/src/ COPY ./docs/ /jandig/docs/ COPY ./locale/ /jandig/locale/ diff --git a/docker-compose.yml b/docker-compose.yml index 3c5d409c..0446c33c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,8 @@ services: ports: - 8000:8000 volumes: - - ./src/:/jandig/src/ - - ./docs/:/jandig/docs/ - - ./etc/:/jandig/etc/ - - ./locale/:/jandig/locale/ - - ./run.sh:/jandig/run.sh - - ./tasks.py:/jandig/tasks.py - - ./poetry.lock:/poetry.lock - - ./pyproject.toml:/pyproject.toml + - ./:/jandig + - poetry_cache:/var/cache/pypoetry env_file: - .envs/.example depends_on: @@ -90,4 +84,5 @@ services: volumes: postgres_data: media_data: - mailpit_data: \ No newline at end of file + mailpit_data: + poetry_cache: \ No newline at end of file diff --git a/run.sh b/run.sh index 13bb0be0..bf7219be 100755 --- a/run.sh +++ b/run.sh @@ -1,3 +1,4 @@ #!/bin/bash +poetry install poetry show poetry run inv collect db i18n --compile docs run -g From f2948e68d29dd13a720e8a0b795f6315631b64fe Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Mon, 28 Oct 2024 12:11:29 +0000 Subject: [PATCH 2/6] Remove Invoke from main dependencies on startup --- pyproject.toml | 2 +- run.sh | 8 +++++++- tasks.py | 29 +---------------------------- 3 files changed, 9 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aed96184..39518abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ Jinja2 = "^3.1.2" gunicorn = "^20.1.0" Pillow = "^11.0.0" django-cors-headers = "^3.13.0" -invoke = "^2.2.0" django-environ = "^0.9.0" psycopg2-binary = "^2.9.3" Sphinx = "^8.1.3" @@ -27,6 +26,7 @@ drf-nested-routers = "^0.93.4" django-htmx = "^1.18.0" [tool.poetry.group.dev.dependencies] +invoke = "^2.2.0" playwright = "^1.41.2" pytest = "^7.2.0" pytest-xdist = "^3.0.2" diff --git a/run.sh b/run.sh index bf7219be..66a7e3f4 100755 --- a/run.sh +++ b/run.sh @@ -1,4 +1,10 @@ #!/bin/bash poetry install poetry show -poetry run inv collect db i18n --compile docs run -g +# poetry run python src/manage.py collectstatic --no-input +poetry run python src/manage.py migrate +poetry run sphinx-build docs/ build/ +poetry run python etc/scripts/compilemessages.py + +bash -c "cd src && poetry run gunicorn --reload --worker-connections=10000 --workers=4 --log-level debug --bind 0.0.0.0:8000 config.wsgi" +# poetry run python src/manage.py runserver 0.0.0.0:8000 \ No newline at end of file diff --git a/tasks.py b/tasks.py index a46bf42f..589bb5c6 100644 --- a/tasks.py +++ b/tasks.py @@ -39,38 +39,16 @@ def run(ctx, ssl=False, gunicorn=False): manage(ctx, "runserver 0.0.0.0:8000") -@task -def db(ctx, make=False): - """ - Run migrations - """ - if make: - manage(ctx, "makemigrations") - manage(ctx, "migrate") - else: - manage(ctx, "migrate") - - -@task -def collect(ctx): - """ - Collect static files - """ - manage(ctx, "collectstatic --no-input --clear") - - # # Translations # @task -def i18n(ctx, compile=False, edit=False, lang="pt_BR", keep_pot=False): +def i18n(ctx, edit=False, lang="pt_BR", keep_pot=False): """ Extract messages for translation. """ if edit: ctx.run(f"poedit locale/{lang}/LC_MESSAGES/django.po") - elif compile: - ctx.run(f"{python} etc/scripts/compilemessages.py") else: print("Collecting messages") robust_manage(ctx, "makemessages", keep_pot=True, locale=lang) @@ -91,8 +69,3 @@ def i18n(ctx, compile=False, edit=False, lang="pt_BR", keep_pot=False): if not keep_pot: print("Cleaning up") ctx.run("rm ./locale/*.pot") - - -@task -def docs(ctx): - ctx.run("sphinx-build docs/ build/") From d4d51e248657613678be8f603c9569d2452b4d4a Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Mon, 28 Oct 2024 12:11:48 +0000 Subject: [PATCH 3/6] Fix double static file collection by django --- src/config/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config/settings.py b/src/config/settings.py index 6b8bb91a..6e474e5f 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -216,6 +216,10 @@ def debug(request): os.path.join(BASE_DIR, "blog", "static"), ] +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + AWS_PUBLIC_MEDIA_LOCATION = "media/public" # Storages From 9c88759d364edf1b738a163171dc9007bd15160b Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Mon, 28 Oct 2024 12:12:51 +0000 Subject: [PATCH 4/6] Update SMTP credentials management --- src/config/settings.py | 2 +- src/users/services/email_service.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 6e474e5f..1ddc7705 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -241,7 +241,7 @@ def debug(request): SMTP_SERVER = env("SMTP_SERVER", default="mailpit") SMTP_PORT = env("SMTP_PORT", default=1025) -SMTP_EMAIL = env("SMTP_EMAIL", default="jandig@jandig.com") +SMTP_USER = env("SMTP_USER", default="jandig@jandig.com") SMTP_PASSWORD = env("SMTP_PASSWORD", default="password") if len(sys.argv) > 1 and sys.argv[1] == "test": diff --git a/src/users/services/email_service.py b/src/users/services/email_service.py index 815d56e0..4828c7d1 100644 --- a/src/users/services/email_service.py +++ b/src/users/services/email_service.py @@ -9,15 +9,15 @@ class EmailService: def __init__(self, email_message): self.smtp_server = settings.SMTP_SERVER self.smtp_port = settings.SMTP_PORT - self.jandig_email = settings.SMTP_EMAIL - self.jandig_email_password = settings.SMTP_PASSWORD + self.smtp_user = settings.SMTP_USER + self.smtp_password = settings.SMTP_PASSWORD + self.jandig_email = "jandig@memelab.com.br" self.email_message = email_message def send_email_to_recover_password(self, multipart_message): email_server = smtplib.SMTP(self.smtp_server, self.smtp_port) email_server.starttls() - email_server.login(self.jandig_email, self.jandig_email_password) - email_server.sendmail( + a = email_server.login(self.smtp_user, self.smtp_password) multipart_message["From"], multipart_message["To"], multipart_message.as_string(), From d0aabf338f8e8c25638d7d284ae695c8d8892382 Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Wed, 30 Oct 2024 10:48:24 +0000 Subject: [PATCH 5/6] Fix SMTP auth with Mailjet --- .envs/.example | 5 +++-- src/config/settings.py | 1 + src/users/services/email_service.py | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.envs/.example b/.envs/.example index 1b45b959..2019ed86 100644 --- a/.envs/.example +++ b/.envs/.example @@ -36,5 +36,6 @@ POSTGRES_PASSWORD=secret # Email server variables SMTP_SERVER=mailpit SMTP_PORT=1025 -SMTP_EMAIL=local_jandig@jandig.com -SMTP_PASSWORD=local_password +SMTP_USER= +SMTP_PASSWORD= +SMTP_SENDER_MAIL="jandig@memelab.com.br" diff --git a/src/config/settings.py b/src/config/settings.py index 1ddc7705..6c2ab9d5 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -243,6 +243,7 @@ def debug(request): SMTP_PORT = env("SMTP_PORT", default=1025) SMTP_USER = env("SMTP_USER", default="jandig@jandig.com") SMTP_PASSWORD = env("SMTP_PASSWORD", default="password") +SMTP_SENDER_MAIL = env("SMTP_SENDER_MAIL", default="jandig@memelab.com.br") if len(sys.argv) > 1 and sys.argv[1] == "test": logging.disable(logging.CRITICAL) diff --git a/src/users/services/email_service.py b/src/users/services/email_service.py index 4828c7d1..fd23ec18 100644 --- a/src/users/services/email_service.py +++ b/src/users/services/email_service.py @@ -11,13 +11,14 @@ def __init__(self, email_message): self.smtp_port = settings.SMTP_PORT self.smtp_user = settings.SMTP_USER self.smtp_password = settings.SMTP_PASSWORD - self.jandig_email = "jandig@memelab.com.br" + self.jandig_email = settings.SMTP_SENDER_MAIL self.email_message = email_message def send_email_to_recover_password(self, multipart_message): email_server = smtplib.SMTP(self.smtp_server, self.smtp_port) email_server.starttls() - a = email_server.login(self.smtp_user, self.smtp_password) + email_server.login(self.smtp_user, self.smtp_password) + email_server.sendmail( multipart_message["From"], multipart_message["To"], multipart_message.as_string(), From e9569e5588eda51aae8b8cedaafb78f7570c7020 Mon Sep 17 00:00:00 2001 From: Pablo Silva Date: Wed, 30 Oct 2024 14:25:38 +0000 Subject: [PATCH 6/6] Add recaptcha v3 to Signup and Recover pass views --- .envs/.example | 8 +++ src/core/jinja2/core/arviewer.jinja2 | 4 ++ .../jinja2/users/recover-password.jinja2 | 23 ++++-- src/users/jinja2/users/signup.jinja2 | 23 ++++-- src/users/services/recaptcha_service.py | 70 +++++++++++++++++++ src/users/views.py | 43 ++++++++++-- 6 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 src/users/services/recaptcha_service.py diff --git a/.envs/.example b/.envs/.example index 2019ed86..b8941246 100644 --- a/.envs/.example +++ b/.envs/.example @@ -39,3 +39,11 @@ SMTP_PORT=1025 SMTP_USER= SMTP_PASSWORD= SMTP_SENDER_MAIL="jandig@memelab.com.br" + +# Recaptcha +RECAPTCHA_ENABLED=False +RECAPTCHA_PROJECT_ID= +RECAPTCHA_GCLOUD_API_KEY= +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= + diff --git a/src/core/jinja2/core/arviewer.jinja2 b/src/core/jinja2/core/arviewer.jinja2 index 32d492f9..2d53ad88 100644 --- a/src/core/jinja2/core/arviewer.jinja2 +++ b/src/core/jinja2/core/arviewer.jinja2 @@ -19,6 +19,10 @@ + {% block extra_css %} + {% endblock %} + {% block extra_js %} + {% endblock %} diff --git a/src/users/jinja2/users/recover-password.jinja2 b/src/users/jinja2/users/recover-password.jinja2 index 7f45c5e2..1d8efac2 100644 --- a/src/users/jinja2/users/recover-password.jinja2 +++ b/src/users/jinja2/users/recover-password.jinja2 @@ -1,21 +1,36 @@ {% extends '/core/arviewer.jinja2' %} -{% block content %} +{% block extra_css%} +{% endblock %} + +{% block extra_js%} + + {% if recaptcha_enabled %} + + + {% endif %} +{% endblock %} + +{% block content %}
- diff --git a/src/users/jinja2/users/signup.jinja2 b/src/users/jinja2/users/signup.jinja2 index 17846a72..58a80033 100644 --- a/src/users/jinja2/users/signup.jinja2 +++ b/src/users/jinja2/users/signup.jinja2 @@ -1,8 +1,23 @@ {% extends '/core/arviewer.jinja2' %} -{% block content %} - {# FIXME: maybe this can be improved #} +{% block extra_css%} +{% endblock %} + +{% block extra_js%} + + {% if recaptcha_enabled %} + + + {% endif %} +{% endblock %} + +{% block content %}
diff --git a/src/users/services/recaptcha_service.py b/src/users/services/recaptcha_service.py new file mode 100644 index 00000000..41a0a943 --- /dev/null +++ b/src/users/services/recaptcha_service.py @@ -0,0 +1,70 @@ +import logging + +import requests +from django.conf import settings + +# The minimum score threshold to consider the action as legitimate. +BOT_SCORE = 0.5 + +logger = logging.getLogger(__name__) + + +def create_assessment(token: str, recaptcha_action: str): + """Create an assessment to analyze the risk of a UI action. + Args: + project_id: Your Google Cloud Project ID. + recaptcha_key: The reCAPTCHA key associated with the site/app + token: The generated token obtained from the client. + recaptcha_action: Action name corresponding to the token. + """ + if not token: + logger.error( + "The token is missing. Recaptcha may be enabled but not configured correctly." + ) + return + + payload = { + "event": { + "token": token, + "expectedAction": recaptcha_action, + "siteKey": settings.RECAPTCHA_SITE_KEY, + } + } + + response = requests.post( + f"https://recaptchaenterprise.googleapis.com/v1/projects/{settings.RECAPTCHA_PROJECT_ID}/assessments?key={settings.RECAPTCHA_GCLOUD_API_KEY}", + json=payload, + ) + response_data = response.json() + logger.info(response.json()) + + # Check if the token is valid. + if not response_data["tokenProperties"]["valid"]: + logger.info( + "The CreateAssessment call failed because the token was " + + "invalid for the following reasons: " + + str(response_data["tokenProperties"]["invalidReason"]) + ) + return + + # Check if the expected action was executed. + if response_data["tokenProperties"]["action"] != recaptcha_action: + logger.info( + "The action attribute in your reCAPTCHA tag does" + + "not match the action you are expecting to score" + ) + return + else: + # Get the risk score and the reason(s). + # For more information on interpreting the assessment, see: + # https://cloud.google.com/recaptcha-enterprise/docs/interpret-assessment + for reason in response_data["riskAnalysis"]["reasons"]: + logger.info(reason) + logger.info( + "The reCAPTCHA score for this token is: " + + str(response_data["riskAnalysis"]["score"]) + ) + # Get the assessment name (id). Use this to annotate the assessment. + assessment_name = response_data["name"].split("/")[-1] + logger.info(f"Assessment name: {assessment_name}") + return response_data diff --git a/src/users/views.py b/src/users/views.py index 27404008..fb60ecf7 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -1,7 +1,7 @@ import json import logging -from core.models import Artwork, Exhibit, Marker, Object +from django.conf import settings from django.contrib.auth import ( authenticate, get_user_model, @@ -15,6 +15,8 @@ from django.views.decorators.cache import cache_page from django.views.decorators.http import require_http_methods +from core.models import Artwork, Exhibit, Marker, Object + from .forms import ( ArtworkForm, ExhibitForm, @@ -29,13 +31,23 @@ from .models import Profile from .services.email_service import EmailService from .services.encrypt_service import EncryptService +from .services.recaptcha_service import BOT_SCORE, create_assessment from .services.user_service import UserService -log = logging.getLogger("ej") +log = logging.getLogger(__file__) def signup(request): if request.method == "POST": + if settings.RECAPTCHA_ENABLED: + recaptcha_token = request.POST.get("g-recaptcha-response") + assessment = create_assessment( + token=recaptcha_token, recaptcha_action="sign_up" + ) + score = assessment.get("riskAnalysis", {}).get("score", -1) + if score <= BOT_SCORE: + return redirect("home") + form = SignupForm(request.POST) if form.is_valid(): @@ -49,7 +61,15 @@ def signup(request): else: form = SignupForm() - return render(request, "users/signup.jinja2", {"form": form}) + return render( + request, + "users/signup.jinja2", + { + "form": form, + "recaptcha_enabled": settings.RECAPTCHA_ENABLED, + "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY, + }, + ) User = get_user_model() @@ -57,6 +77,15 @@ def signup(request): def recover_password(request): if request.method == "POST": + if settings.RECAPTCHA_ENABLED: + recaptcha_token = request.POST.get("g-recaptcha-response") + assessment = create_assessment( + token=recaptcha_token, recaptcha_action="recover_password" + ) + score = assessment.get("riskAnalysis", {}).get("score", -1) + if score <= BOT_SCORE: + return redirect("home") + recover_password_form = RecoverPasswordForm(request.POST) if recover_password_form.is_valid(): @@ -85,7 +114,13 @@ def recover_password(request): recover_password_form = RecoverPasswordForm() return render( - request, "users/recover-password.jinja2", {"form": recover_password_form} + request, + "users/recover-password.jinja2", + { + "form": recover_password_form, + "recaptcha_enabled": settings.RECAPTCHA_ENABLED, + "recaptcha_site_key": settings.RECAPTCHA_SITE_KEY, + }, )