diff --git a/backend/.env.example b/backend/.env.example index 192d645..3de5985 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,14 +54,11 @@ DEFAULT_FROM_EMAIL= USE_S3=False # RustFS (docker-compose default — S3-compatible MinIO successor) -AWS_S3_ENDPOINT_URL=http://rustfs:9000 -AWS_S3_CUSTOM_DOMAIN=localhost:9000/vontor -AWS_STORAGE_BUCKET_NAME=vontor -AWS_ACCESS_KEY_ID=rustfsadmin -AWS_SECRET_ACCESS_KEY=rustfsadmin -RUSTFS_ACCESS_KEY=rustfsadmin -RUSTFS_SECRET_KEY=rustfsadmin -RUSTFS_BUCKET_NAME=vontor +AWS_S3_ENDPOINT_URL=https://s3.vontor.cz +AWS_S3_CUSTOM_DOMAIN=s3.vontor.cz +AWS_STORAGE_BUCKET_NAME=vontor-cz +AWS_ACCESS_KEY_ID=pO70oxXGV4R6OSHxNmzv +AWS_SECRET_ACCESS_KEY=1gY19XzWBOWiIkDKvCQF8Xkc72mFX4iILkBBV0ML # AWS S3 (swap in for production — clear AWS_S3_ENDPOINT_URL) # AWS_STORAGE_BUCKET_NAME=my-bucket @@ -80,4 +77,7 @@ RUSTFS_BUCKET_NAME=vontor # -- ČSOB API -- CSOB_MERCHANT_ID=A6680stogb -CSOB_API_URL=https://iapi.iplatebnibrana.csob.cz/ # PŘI PRODUKCI VYMĚNIT ZA REALNE API!!!! \ No newline at end of file +CSOB_API_URL=https://iapi.iplatebnibrana.csob.cz/ # PŘI PRODUKCI VYMĚNIT ZA REALNE API!!!! + +# -- TURNSTILE CAPTCHA -- +SECRET_KEY_TURNSTILE=xxx \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py index 6f3df89..a97c33e 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -25,6 +25,7 @@ from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter +from vontor_cz.turnstile import verify_turnstile User = get_user_model() @@ -52,6 +53,11 @@ class CookieTokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + serializer = self.get_serializer(data=request.data) try: serializer.is_valid(raise_exception=True) @@ -310,6 +316,11 @@ class UserRegistrationViewSet(ModelViewSet): http_method_names = ['post'] def create(self, request, *args, **kwargs): + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() diff --git a/backend/advertisement/views.py b/backend/advertisement/views.py index ec0c1b5..44de8fb 100644 --- a/backend/advertisement/views.py +++ b/backend/advertisement/views.py @@ -9,6 +9,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view from .models import ContactMe from .serializer import ContactMeSerializer from .tasks import send_contact_me_email_task, send_newly_added_items_to_store_email_task_last_week +from vontor_cz.turnstile import verify_turnstile @extend_schema(tags=["advertisement", "public"]) @@ -26,6 +27,11 @@ class ContactMePublicView(APIView): if honeypot: return Response({"status": "ok"}, status=status.HTTP_200_OK) + turnstile_token = request.data.get("turnstile_token", "") + remote_ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")) + if not verify_turnstile(turnstile_token, remote_ip): + return Response({"detail": "Ověření CAPTCHA selhalo."}, status=status.HTTP_400_BAD_REQUEST) + if not email or not message: return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 368cc74..3cbe55c 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -958,6 +958,9 @@ GOPAY_GATEWAY_URL = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com # New: absolute URL that GoPay calls (publicly reachable) GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook") +# --- Cloudflare Turnstile --- +CLOUDFLARE_TURNSTILE_SECRET_KEY = os.getenv("SECRET_KEY_TURNSTILE", "") + # -------------------------------------DOWNLOADER LIMITS------------------------------------ DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024 diff --git a/backend/vontor_cz/turnstile.py b/backend/vontor_cz/turnstile.py new file mode 100644 index 0000000..205ab22 --- /dev/null +++ b/backend/vontor_cz/turnstile.py @@ -0,0 +1,33 @@ +import logging +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + +def verify_turnstile(token: str, remote_ip: str | None = None) -> bool: + """ + Verify a Cloudflare Turnstile token against the siteverify API. + Returns True if valid, False otherwise. + If CLOUDFLARE_TURNSTILE_SECRET_KEY is not configured, skips verification (dev bypass). + """ + secret = getattr(settings, "CLOUDFLARE_TURNSTILE_SECRET_KEY", "") + if not secret: + logger.debug("Turnstile: no secret key configured, skipping verification.") + return True + + payload = {"secret": secret, "response": token} + if remote_ip: + payload["remoteip"] = remote_ip + + try: + resp = requests.post(SITEVERIFY_URL, data=payload, timeout=5) + result = resp.json() + if not result.get("success"): + logger.warning("Turnstile verification failed: %s", result.get("error-codes")) + return bool(result.get("success")) + except Exception as e: + logger.error("Turnstile: siteverify request failed: %s", e) + return False diff --git a/frontend/index.html b/frontend/index.html index 7f3a76a..0ffb91e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,10 +21,14 @@ + + +
+ diff --git a/frontend/src/components/home/ContactMe/ContactMeForm.tsx b/frontend/src/components/home/ContactMe/ContactMeForm.tsx index a8902ab..51357a3 100644 --- a/frontend/src/components/home/ContactMe/ContactMeForm.tsx +++ b/frontend/src/components/home/ContactMe/ContactMeForm.tsx @@ -3,6 +3,7 @@ import styles from "./contact-me.module.css" import { LuMousePointerClick } from "react-icons/lu"; import { publicApi } from "@/api/publicClient"; import { useTranslation } from "react-i18next"; +import { useTurnstile } from "@/hooks/useTurnstile"; export default function ContactMeForm() { const { t } = useTranslation("home"); @@ -12,21 +13,33 @@ export default function ContactMeForm() { const [email, setEmail] = useState("") const [message, setMessage] = useState("") const [loading, setLoading] = useState(false) + const [envelopeFading, setEnvelopeFading] = useState(false) const [success, setSuccess] = useState(false) const [error, setError] = useState("") const openingRef = useRef(null) + const { containerRef, token: turnstileToken, enabled: turnstileEnabled, reset: resetTurnstile } = useTurnstile() async function handleSubmit(e: React.FormEvent) { e.preventDefault() + if (!turnstileToken) return setLoading(true) setError("") try { - await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "" }) - setSuccess(true) + await publicApi.post("/api/advertisement/contact-me/", { email, message, hp: "", turnstile_token: turnstileToken ?? "" }) setEmail("") setMessage("") + // Step 1: slide form into envelope (content drops down behind cover, 1s transition) + setContentMoveUp(false) + setOpeningBehind(false) + // Step 2: close flap after form has slid in + setTimeout(() => setOpened(false), 1100) + // Step 3: fade out envelope after flap finishes closing + setTimeout(() => setEnvelopeFading(true), 2300) + // Step 4: show success panel + setTimeout(() => setSuccess(true), 2700) } catch { setError(t("contact.sendError")) + resetTurnstile() } finally { setLoading(false) } @@ -54,73 +67,74 @@ export default function ContactMeForm() { } } + function handleNewMessage() { + setSuccess(false) + setEnvelopeFading(false) + setOpened(true) + setContentMoveUp(true) + setOpeningBehind(true) + } + + if (success) { + return ( +
+
+

{t("contact.messageSent")}

+

+ {t("contact.replyIn24h")} +

+ +
+ ) + } + return ( -
+
-
- {success ? ( -
-
-

{t("contact.messageSent")}

-

- {t("contact.replyIn24h")} -

- -
- ) : ( -
- setEmail(e.target.value)} - /> -