Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@

LOCAL_APPS = [
"pages.citation",
"pages.contact",
"pages.dashboards",
"pages.data_management",
"pages.home",
Expand Down
18 changes: 18 additions & 0 deletions core/settings/development.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from .base import * # noqa: F401,F403
from .base import env

DEBUG = True

Expand Down Expand Up @@ -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)
23 changes: 23 additions & 0 deletions core/settings/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

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 for SSL ?

EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="")
EMAIL_TIMEOUT = env.int("EMAIL_TIMEOUT", default=10)
1 change: 1 addition & 0 deletions core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
path(settings.ADMIN_URL, admin.site.urls, name="admin"),
path("", include("pages.home.urls")),
path("citation/", include("pages.citation.urls")),
path("contact/", include("pages.contact.urls")),
path("dashboards/", include("pages.dashboards.urls")),
path("data-management/", include("pages.data_management.urls")),
path("privacy/", include("pages.privacy.urls")),
Expand Down
1 change: 1 addition & 0 deletions pages/contact/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contact app package."""
6 changes: 6 additions & 0 deletions pages/contact/apps.py
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"
181 changes: 181 additions & 0 deletions pages/contact/forms.py
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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have \r or \n in the email field ? I wasn't able to enter it in the form when I tired.

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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to use re or some other way to check for html tags instead of just for < and > signs. As there could scenario where the user wanna use them, like in a "comparison (greater or lesser)" context.

raise ValidationError("Please remove HTML tags.")
# Cap URLs
url_count = len(URL_REGEX.findall(value))
if url_count > MAX_URLS_ALLOWED:
Copy link
Contributor

Choose a reason for hiding this comment

The 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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While testing MAX_TOKEN_AGE, noticed this is getting triggered even if a category is selected.


# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The 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."
Copy link
Contributor

Choose a reason for hiding this comment

The 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
131 changes: 131 additions & 0 deletions pages/contact/templates/contact/contact_form.html
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>
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

bg-indigo-500, keep it within portal's visual identity or at least closer colours. This is very different and new.

</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 %}
Loading