-
Notifications
You must be signed in to change notification settings - Fork 0
FREYA-1532: Add contact form #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7da04e1
2b2adb2
5ebc548
d359acd
597c881
0fea712
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,7 @@ | |
""" | ||
|
||
from .base import * # noqa: F401,F403 | ||
from .base import env | ||
|
||
DEBUG = True | ||
|
||
|
@@ -56,3 +57,20 @@ | |
}, | ||
}, | ||
} | ||
|
||
|
||
# EMAIL (Development defaults, override via .env if needed) | ||
# ------------------------------------------------------------------------------ | ||
EMAIL_BACKEND = env( | ||
"EMAIL_BACKEND", | ||
default="django.core.mail.backends.console.EmailBackend", | ||
) | ||
DEFAULT_FROM_EMAIL = env( | ||
"DEFAULT_FROM_EMAIL", | ||
default="Pathogens Portal <[email protected]>", | ||
) | ||
CONTACT_RECIPIENT_EMAIL = env( | ||
"CONTACT_RECIPIENT_EMAIL", | ||
default="[email protected]", | ||
) | ||
EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -40,3 +40,26 @@ | |
# ------------------------------------------------------------------------------ | ||
MEDIA_ROOT = env("MEDIA_ROOT") | ||
MEDIA_URL = env("MEDIA_URL", default="media").rstrip("/") + "/" | ||
|
||
|
||
# EMAIL (Production via env; placeholders acceptable) | ||
# ------------------------------------------------------------------------------ | ||
EMAIL_BACKEND = env( | ||
"EMAIL_BACKEND", | ||
default="django.core.mail.backends.smtp.EmailBackend", | ||
) | ||
DEFAULT_FROM_EMAIL = env( | ||
"DEFAULT_FROM_EMAIL", | ||
default="Pathogens Portal <[email protected]>", | ||
) | ||
CONTACT_RECIPIENT_EMAIL = env( | ||
"CONTACT_RECIPIENT_EMAIL", | ||
default="[email protected]", | ||
) | ||
EMAIL_HOST = env("EMAIL_HOST", default="") | ||
EMAIL_PORT = env.int("EMAIL_PORT", default=587) | ||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True) | ||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", default=False) | ||
EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") | ||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="") | ||
EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Contact app package.""" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class ContactConfig(AppConfig): | ||
default_auto_field = "django.db.models.BigAutoField" | ||
name = "pages.contact" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
"""Contact form definitions and anti-spam validation. | ||
This module defines the `ContactForm` used by the contact page. It includes | ||
layered, user-transparent anti-spam checks (honeypot, submission timing, and | ||
double-submit cookie) and content validation. | ||
The form accepts `request` in its constructor to access cookies for the | ||
double-submit verification. | ||
""" | ||
|
||
from __future__ import annotations | ||
|
||
import re | ||
import time | ||
from typing import Any, Optional | ||
|
||
from django import forms | ||
from django.core import signing | ||
from django.core.exceptions import ValidationError | ||
|
||
|
||
URL_REGEX = re.compile(r"(https?://|www\.)", re.IGNORECASE) | ||
|
||
# Validation and anti-spam constants | ||
MIN_SUBMIT_SECONDS = 2 | ||
MAX_TOKEN_AGE_SECONDS = 60 * 60 | ||
MAX_URLS_ALLOWED = 3 | ||
|
||
|
||
class ContactForm(forms.Form): | ||
"""Contact form with layered anti-spam. | ||
Fields: | ||
name: Optional full name of the sender (2–100 chars when provided). | ||
email: Optional reply address, validated when provided. | ||
message: Main text body, 20–5000 chars, with URL and HTML limits. | ||
suggestion, dm_support, other: Category checkboxes, at least one must | ||
be selected. | ||
website: Honeypot field, must remain empty. | ||
contact_ts: Signed timestamp token to check submission timing. | ||
contact_dsc: Token that must match the cookie for double-submit check. | ||
Anti-spam strategy: | ||
- Honeypot rejects naive bots filling hidden fields. | ||
- TimestampSigner token rejects submissions that are too fast (<2s) or | ||
too old (>60 minutes). | ||
- Double-submit cookie reduces scripted posts and replays. | ||
""" | ||
|
||
name = forms.CharField(min_length=2, max_length=100, required=False) | ||
email = forms.EmailField(required=False) | ||
message = forms.CharField(min_length=20, max_length=5000, widget=forms.Textarea) | ||
suggestion = forms.BooleanField(required=False) | ||
dm_support = forms.BooleanField( | ||
required=False, label="Request for data management/sharing support" | ||
) | ||
other = forms.BooleanField(required=False) | ||
|
||
# Anti-spam fields | ||
website = forms.CharField(required=False, widget=forms.HiddenInput) | ||
contact_ts = forms.CharField(widget=forms.HiddenInput, strip=False) | ||
contact_dsc = forms.CharField(widget=forms.HiddenInput, strip=False) | ||
|
||
# Internal state for logging (not exposed to users) | ||
_blocked_reason: Optional[str] = None | ||
|
||
def __init__(self, *args: Any, request=None, **kwargs: Any) -> None: | ||
"""Initialise the form. | ||
Args: | ||
*args: Positional form args. | ||
request: Optional HttpRequest to access cookies for token checks. | ||
**kwargs: Keyword form args. | ||
""" | ||
super().__init__(*args, **kwargs) | ||
self.request = request | ||
|
||
def clean_email(self) -> str: | ||
"""Validate email and prevent header injection. | ||
Returns: | ||
The sanitised email value. | ||
Raises: | ||
ValidationError: If CR/LF characters are present. | ||
""" | ||
value = self.cleaned_data.get("email", "") | ||
if "\r" in value or "\n" in value: | ||
raise ValidationError("Enter a valid email address.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to have |
||
return value | ||
|
||
def clean_message(self) -> str: | ||
"""Validate message content against HTML and URL constraints. | ||
Returns: | ||
The validated message content. | ||
Raises: | ||
ValidationError: If HTML-like tags are present or URL count exceeds | ||
the allowed threshold. | ||
""" | ||
value = self.cleaned_data.get("message", "") | ||
# Reject HTML-like content | ||
if "<" in value or ">" in value: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better to use |
||
raise ValidationError("Please remove HTML tags.") | ||
# Cap URLs | ||
url_count = len(URL_REGEX.findall(value)) | ||
if url_count > MAX_URLS_ALLOWED: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should discuss with the team if we wanna have limits on url a user can send. |
||
raise ValidationError("Too many links in the message.") | ||
return value | ||
|
||
def clean(self) -> dict[str, Any]: | ||
"""Form-level validation for category, timing, and token checks. | ||
Returns: | ||
The cleaned data dictionary. | ||
Raises: | ||
ValidationError: On any anti-spam or category selection failure. | ||
""" | ||
cleaned = super().clean() | ||
|
||
# At least one category must be selected | ||
if not ( | ||
cleaned.get("suggestion") | ||
or cleaned.get("dm_support") | ||
or cleaned.get("other") | ||
): | ||
raise ValidationError("Please select at least one option.") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While testing |
||
|
||
# Honeypot | ||
if cleaned.get("website"): | ||
self._blocked_reason = "HONEYPOT_HIT" | ||
raise ValidationError( | ||
"We couldn't submit the form. Please try again in a moment." | ||
) | ||
|
||
# Timing token | ||
ts_token = cleaned.get("contact_ts") or "" | ||
try: | ||
# Accept TimestampSigner style token or JSON payload signed via dumps | ||
try: | ||
signer = signing.TimestampSigner(salt="contact-ts") | ||
ts_str = signer.unsign(ts_token, max_age=MAX_TOKEN_AGE_SECONDS) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We had a discussion about having a upper limit check while changing data platform's contact form, and I still stand by it. Persons like me might get easily distracted and comeback after 1 hour, might type a long message and submit, only to fail. That could be really frustrating 😅 So unless there is a notification after 60 saying to reload the page, which prevents the user from typing, we should not have upper limit for time check. |
||
ts = int(ts_str) | ||
except signing.BadSignature: | ||
payload = signing.loads( | ||
ts_token, salt="contact-ts", max_age=MAX_TOKEN_AGE_SECONDS | ||
) | ||
ts = int(payload.get("ts", 0)) | ||
except signing.SignatureExpired: | ||
self._blocked_reason = "TOKEN_EXPIRED" | ||
raise ValidationError( | ||
"We couldn't submit the form. Please try again in a moment." | ||
) | ||
except signing.BadSignature: | ||
self._blocked_reason = "TOKEN_BAD_SIGNATURE" | ||
raise ValidationError( | ||
"We couldn't submit the form. Please try again in a moment." | ||
) | ||
|
||
now = int(time.time()) | ||
if now - ts < MIN_SUBMIT_SECONDS: | ||
self._blocked_reason = "TOO_FAST" | ||
raise ValidationError( | ||
"We couldn't submit the form. Please try again in a moment." | ||
) | ||
|
||
# Double-submit cookie check | ||
posted_token = cleaned.get("contact_dsc") or "" | ||
cookie_token = None | ||
if hasattr(self, "request") and self.request is not None: | ||
cookie_token = self.request.COOKIES.get("contact_dsc") | ||
|
||
if not cookie_token or cookie_token != posted_token: | ||
self._blocked_reason = "TOKEN_MISMATCH" | ||
raise ValidationError( | ||
"We couldn't submit the form. Please try again in a moment." | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When either of these error is triggered, would the form work if the use try again later as the error message states (without reloading the contact page) ? |
||
) | ||
|
||
return cleaned |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
{% extends "base.html" %} | ||
|
||
{% block content %} | ||
<div class="py-8"> | ||
<div> | ||
We welcome questions and suggestions regarding the <i>Swedish Pathogens Portal</i> and we can help with your data | ||
management or data sharing question (or at least point you to where your question can be answered). You can fill | ||
out the form below and the <a href="/about/">the team behind the Portal</a> will be happy to help you find a good | ||
solution. Alternatively, you can send us an email: <a href="mailto:[email protected]">[email protected]</a>. | ||
</div> | ||
|
||
<h3 class="mt-6">Contact and suggestions form</h3> | ||
<div class="mt-2"> | ||
Please fill out this form to make suggestions for the <i>Swedish Pathogens Portal</i> or to ask a data management or | ||
data sharing question. Please provide your contact information if you would like to receive a response. The data | ||
portal team will send you a response within the next couple of days. | ||
</div> | ||
|
||
{# Error summary #} | ||
{% if form.non_field_errors %} | ||
<div role="alert" aria-live="assertive" class="mt-4 rounded-md border border-red-200 bg-red-50 p-4 text-red-800"> | ||
<p class="font-medium">We couldn't submit the form. Please check the fields below.</p> | ||
</div> | ||
{% endif %} | ||
|
||
{# Success and info messages #} | ||
{% if messages %} | ||
<div role="status" aria-live="polite" class="mt-4"> | ||
{% for message in messages %} | ||
<div class="mb-3 rounded-md border border-green-200 bg-green-50 p-4 text-green-800">{{ message }}</div> | ||
{% endfor %} | ||
</div> | ||
{% endif %} | ||
|
||
<form method="post" action="" novalidate class="mt-6 space-y-6"> | ||
{% csrf_token %} | ||
|
||
{# Hidden anti-spam fields #} | ||
{{ form.website }} | ||
{{ form.contact_ts }} | ||
{{ form.contact_dsc }} | ||
|
||
<div> | ||
<label for="id_name" class="block text-sm font-medium text-gray-900">Name</label> | ||
<input type="text" name="name" id="id_name" value="{{ form.name.value|default_if_none:'' }}" | ||
class="mt-1 block w-full rounded-md border {% if form.name.errors %}border-red-600{% else %}border-gray-300{% endif %} px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500" | ||
{% if form.name.errors %}aria-invalid="true" aria-describedby="id_name_error" autofocus{% endif %} | ||
minlength="2" maxlength="100"> | ||
{% if form.name.errors %} | ||
<p id="id_name_error" class="mt-1 text-sm text-red-600">{{ form.name.errors.0 }}</p> | ||
{% endif %} | ||
<p class="mt-1 text-sm text-gray-500">Please provide your name in case you would like to receive a response from us. In other cases, name is not required.</p> | ||
</div> | ||
|
||
<div> | ||
<label for="id_email" class="block text-sm font-medium text-gray-900">Email</label> | ||
<input type="email" name="email" id="id_email" value="{{ form.email.value|default_if_none:'' }}" | ||
class="mt-1 block w-full rounded-md border {% if form.email.errors %}border-red-600{% else %}border-gray-300{% endif %} px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500" | ||
{% if form.email.errors %}aria-invalid="true" aria-describedby="id_email_error" autofocus{% endif %}> | ||
{% if form.email.errors %} | ||
<p id="id_email_error" class="mt-1 text-sm text-red-600">{{ form.email.errors.0 }}</p> | ||
{% endif %} | ||
<p class="mt-1 text-sm text-gray-500">Please provide your e-mail address in case you would like to receive a response from us. In other cases, the e-mail address is not required.</p> | ||
</div> | ||
|
||
<div> | ||
<div class="block text-sm font-medium text-gray-900">What kind of a question or suggestion do you have? | ||
<span class="text-red-600">*</span></div> | ||
<div class="mt-2 space-y-2"> | ||
<label class="flex items-center gap-2 text-gray-900"> | ||
<input type="checkbox" name="suggestion" id="id_suggestion" {% if form.suggestion.value %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"> | ||
<span>Suggestion for the Portal</span> | ||
</label> | ||
<label class="flex items-center gap-2 text-gray-900"> | ||
<input type="checkbox" name="dm_support" id="id_dm_support" {% if form.dm_support.value %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"> | ||
<span>Request for help with data management or data sharing questions</span> | ||
</label> | ||
<label class="flex items-center gap-2 text-gray-900"> | ||
<input type="checkbox" name="other" id="id_other" {% if form.other.value %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"> | ||
<span>Other</span> | ||
</label> | ||
</div> | ||
{% if form.non_field_errors %} | ||
<p class="mt-1 text-sm text-red-600">Please select at least one alternative.</p> | ||
{% endif %} | ||
<p class="mt-1 text-sm text-gray-500">You can ask a question or suggest one or more items using this form.</p> | ||
</div> | ||
|
||
<div> | ||
<label for="id_message" class="block text-sm font-medium text-gray-900">Message</label> | ||
<textarea name="message" id="id_message" | ||
class="mt-1 block w-full rounded-md border {% if form.message.errors %}border-red-600{% else %}border-gray-300{% endif %} px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-40" | ||
{% if form.message.errors %}aria-invalid="true" aria-describedby="id_message_error" autofocus{% endif %} | ||
required minlength="20" maxlength="5000">{{ form.message.value|default_if_none:'' }}</textarea> | ||
{% if form.message.errors %} | ||
<p id="id_message_error" class="mt-1 text-sm text-red-600">{{ form.message.errors.0 }}</p> | ||
{% endif %} | ||
<p class="mt-1 text-sm text-gray-500">Please describe what you need help with (e.g., sharing your own dataset) or | ||
provide link(s) and/or DOI(s) to your suggested dataset, project, publication, preprint, etc. and anything else | ||
that we should know.</p> | ||
</div> | ||
|
||
<div> | ||
<button type="submit" class="inline-flex items-center rounded-md bg-teal-600 px-4 py-2 text-white hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">Send message</button> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know we are not finalising styles now, but wanna mention as this is small and I might forget later.
|
||
</div> | ||
</form> | ||
|
||
<div class="mt-10"> | ||
<h3>Personal data policy</h3> | ||
<p> | ||
The personal data you provide in this form, your name and email address, will be used to process your suggestion of | ||
added resource to the <a href="https://www.pathogens.se/">Swedish Pathogens Portal</a>. The Data Portal is a | ||
service run by the SciLifeLab Data Centre on assignment from the Swedish Research Council (Vetenskapsrådet). It | ||
serves to address <a href="https://www.regeringen.se/regeringsuppdrag/2020/05/uppdrag-om-svensk-samordning-och-deltagande-i-eu-kommissionens-covid-19-dataplattform/" target="_blank" rel="noopener noreferrer"> | ||
the government assignment to the Swedish Research Council</a> | ||
to participate in the European open data platform for COVID-19 research. | ||
</p> | ||
<p> | ||
The information you provide will be processed for research purposes, i.e. using the lawful basis of public interest | ||
and in accordance with Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016, the | ||
General Data Protection Regulation. | ||
</p> | ||
<p> | ||
The following parties will have access to processing your personal data: SciLifeLab Data Centre, Uppsala | ||
University. Your personal data will be deleted when no longer needed, or when stipulated by the archival rules for | ||
the university as a government authority. If you want to update or remove your personal data please contact the | ||
controller SciLifeLab Data Centre at Uppsala University using <a href="mailto:[email protected]">[email protected]</a>. | ||
</p> | ||
</div> | ||
</div> | ||
{% endblock %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we will only use
TLS
, why enable an option forSSL
?