11"""Core app views."""
22
33import logging
4+ import re
5+ import time
46import xml .etree .ElementTree as ET
57from decimal import Decimal
68from html import unescape
911
1012from django .contrib import messages
1113from django .contrib .auth .mixins import LoginRequiredMixin , UserPassesTestMixin
14+ from django .core .signing import BadSignature , SignatureExpired , TimestampSigner
1215from django .db import models
1316from django .http import HttpRequest , HttpResponse , JsonResponse
1417from django .shortcuts import redirect
@@ -171,12 +174,106 @@ class ContactView(TemplateView):
171174
172175 template_name = "contact.html"
173176
177+ def get_context_data (self , ** kwargs ):
178+ context = super ().get_context_data (** kwargs )
179+ signer = TimestampSigner ()
180+ context ["form_token" ] = signer .sign ("contact-form" )
181+ return context
182+
174183
175184class ContactSubmitView (View ):
176185 """Handle contact form submissions."""
177186
187+ # In-memory rate limiter: {ip: [timestamp, ...]}
188+ _rate_limits : ClassVar [dict [str , list [float ]]] = {}
189+ RATE_LIMIT_MAX = 5 # max submissions per window
190+ RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
191+ MIN_SUBMIT_TIME = 3 # minimum seconds between form load and submit
192+
193+ @staticmethod
194+ def _looks_like_gibberish (text : str ) -> bool :
195+ """Detect bot-generated gibberish strings."""
196+ if not text :
197+ return False
198+ # Mostly consonants with no vowels → gibberish
199+ letters = re .sub (r"[^a-zA-Z]" , "" , text )
200+ if len (letters ) < 4 :
201+ return False
202+ vowels = sum (1 for c in letters .lower () if c in "aeiou" )
203+ vowel_ratio = vowels / len (letters )
204+ if vowel_ratio < 0.15 :
205+ return True
206+ # Excessive uppercase in middle of word
207+ return len (letters ) > 6 and sum (1 for c in letters [1 :] if c .isupper ()) > len (letters ) * 0.5
208+
209+ def _get_client_ip (self , request : HttpRequest ) -> str :
210+ forwarded = request .META .get ("HTTP_X_FORWARDED_FOR" , "" )
211+ if forwarded :
212+ return forwarded .split ("," )[0 ].strip ()
213+ return request .META .get ("REMOTE_ADDR" , "" )
214+
215+ def _is_rate_limited (self , ip : str ) -> bool :
216+ now = time .monotonic ()
217+ timestamps = self ._rate_limits .get (ip , [])
218+ # Prune old entries
219+ timestamps = [t for t in timestamps if now - t < self .RATE_LIMIT_WINDOW ]
220+ self ._rate_limits [ip ] = timestamps
221+ return len (timestamps ) >= self .RATE_LIMIT_MAX
222+
223+ def _record_submission (self , ip : str ) -> None :
224+ now = time .monotonic ()
225+ self ._rate_limits .setdefault (ip , []).append (now )
226+
178227 async def post (self , request : HttpRequest ) -> HttpResponse :
179228 """Process the contact form POST request."""
229+ # --- Honeypot check: bots fill hidden fields ---
230+ if request .POST .get ("website" , "" ):
231+ logger .warning ("Honeypot triggered on contact form" )
232+ # Fake success to not tip off bots
233+ messages .success (
234+ request ,
235+ "Thank you for reaching out! We'll get back to you within 24 hours." ,
236+ )
237+ return redirect ("core:contact" )
238+
239+ # --- Time-based check: reject impossibly fast submissions ---
240+ form_token = request .POST .get ("form_token" , "" )
241+ signer = TimestampSigner ()
242+ try :
243+ signer .unsign (form_token , max_age = 86400 ) # token valid for 24h
244+ except (BadSignature , SignatureExpired ):
245+ logger .warning ("Invalid or expired form token on contact form" )
246+ messages .error (request , "Your session has expired. Please try again." )
247+ return redirect ("core:contact" )
248+
249+ # Check if form was submitted too quickly (bot speed)
250+ try :
251+ # unsign with a very short max_age to detect fast submissions
252+ signer .unsign (form_token , max_age = self .MIN_SUBMIT_TIME )
253+ except SignatureExpired :
254+ pass # Good — enough time has passed
255+ except BadSignature :
256+ messages .error (request , "Your session has expired. Please try again." )
257+ return redirect ("core:contact" )
258+ else :
259+ # Token is still valid with MIN_SUBMIT_TIME → submitted too fast
260+ logger .warning ("Contact form submitted too quickly (bot suspected)" )
261+ messages .success (
262+ request ,
263+ "Thank you for reaching out! We'll get back to you within 24 hours." ,
264+ )
265+ return redirect ("core:contact" )
266+
267+ # --- Rate limiting per IP ---
268+ client_ip = self ._get_client_ip (request )
269+ if self ._is_rate_limited (client_ip ):
270+ logger .warning ("Rate limit exceeded for IP %s on contact form" , client_ip )
271+ messages .error (
272+ request ,
273+ "Too many submissions. Please try again later." ,
274+ )
275+ return redirect ("core:contact" )
276+
180277 name = request .POST .get ("name" , "" ).strip ()
181278 email = request .POST .get ("email" , "" ).strip ()
182279 company = request .POST .get ("company" , "" ).strip ()
@@ -188,6 +285,25 @@ async def post(self, request: HttpRequest) -> HttpResponse:
188285 messages .error (request , "Please fill in all required fields." )
189286 return redirect ("core:contact" )
190287
288+ # --- Gibberish / bot content detection ---
289+ if self ._looks_like_gibberish (name ):
290+ logger .warning ("Gibberish name detected: %s" , name [:50 ])
291+ messages .success (
292+ request ,
293+ "Thank you for reaching out! We'll get back to you within 24 hours." ,
294+ )
295+ return redirect ("core:contact" )
296+
297+ # --- Valid project_type check ---
298+ valid_types = {choice [0 ] for choice in ContactSubmission .project_type .field .choices }
299+ valid_types .add ("" ) # allow empty
300+ if project_type not in valid_types :
301+ logger .warning ("Invalid project_type: %s" , project_type [:50 ])
302+ messages .error (request , "Please select a valid project type." )
303+ return redirect ("core:contact" )
304+
305+ self ._record_submission (client_ip )
306+
191307 submission = await ContactSubmission .objects .acreate (
192308 name = name ,
193309 email = email ,
0 commit comments