diff --git a/.envs/.example b/.envs/.example
index 1b45b959..b8941246 100644
--- a/.envs/.example
+++ b/.envs/.example
@@ -36,5 +36,14 @@ 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"
+
+# Recaptcha
+RECAPTCHA_ENABLED=False
+RECAPTCHA_PROJECT_ID=
+RECAPTCHA_GCLOUD_API_KEY=
+RECAPTCHA_SITE_KEY=
+RECAPTCHA_SECRET_KEY=
+
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/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 13bb0be0..66a7e3f4 100755
--- a/run.sh
+++ b/run.sh
@@ -1,3 +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/src/config/settings.py b/src/config/settings.py
index 6b8bb91a..6c2ab9d5 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
@@ -237,8 +241,9 @@ 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")
+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/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/email_service.py b/src/users/services/email_service.py
index 815d56e0..fd23ec18 100644
--- a/src/users/services/email_service.py
+++ b/src/users/services/email_service.py
@@ -9,14 +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 = 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()
- email_server.login(self.jandig_email, self.jandig_email_password)
+ email_server.login(self.smtp_user, self.smtp_password)
email_server.sendmail(
multipart_message["From"],
multipart_message["To"],
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,
+ },
)
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/")