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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,7 @@ docker_environment_init.sh
.envrc

# Exclude Makefiles
Makefile
Makefile

# Exclude files create at runtime - by webpack and storybook
common/static/
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# https://hub.docker.com/r/nikolaik/python-nodejs
FROM nikolaik/python-nodejs:python3.10-nodejs16

# Refresh Yarn repo GPG key (old key 62D54FD4003F6525 expired; fetch the
# current one so apt-get update succeeds on bookworm-based images).
RUN curl -fsSL https://dl.yarnpkg.com/debian/pubkey.gpg \
| gpg --dearmor -o /usr/share/keyrings/yarn-archive-keyring.gpg \
&& sed -i 's|^deb \[.*\] https://dl.yarnpkg.com|deb [signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com|;s|^deb https://dl.yarnpkg.com|deb [signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com|' \
/etc/apt/sources.list.d/yarn.list 2>/dev/null || true

# This to get GDAL thanks to https://stackoverflow.com/questions/62546706/how-do-i-install-gdal-in-a-python-docker-environment
RUN apt-get update && apt-get install

Expand Down
46 changes: 7 additions & 39 deletions common/components/common/integrations/NewsletterSignup.jsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,19 @@
// @flow

import React from "react";

// In mailchimp go to forms > other forms choose the subscription landing page to get the url
class NewsletterSignup extends React.Component {
render() {
return (
<div id="mc_embed_signup">
<form
action="https://democracylab.us3.list-manage.com/subscribe/post?u=72af92d0a817dcbf3aa960ee0&amp;id=d3b4c4d81c"
method="post"
id="mc-embedded-subscribe-form"
name="mc-embedded-subscribe-form"
className="validate"
<a
href="https://mailchi.mp/democracylab/subscribe"
target="_blank"
noValidate
rel="noopener noreferrer"
className={this.props.btnClass}
>
<div id="mc_embed_signup_scroll">
<div className="mc-field-group SocialFooter-signupcontainer">
<label htmlFor="mce-EMAIL" />
<input
type="submit"
value="Subscribe"
name="subscribe"
id="mc-embedded-subscribe"
className={this.props.btnClass}
/>
</div>
<div id="mce-responses" className="clear">
<div
className="response mc_display_none"
id="mce-error-response"
/>
<div
className="response mc_display_none"
id="mce-success-response"
/>
</div>
<div className="mc_embed_hidden" aria-hidden="true">
<input
type="text"
name="b_72af92d0a817dcbf3aa960ee0_d3b4c4d81c"
tabIndex="-1"
defaultValue=""
/>
</div>
</div>
</form>
Subscribe
</a>
</div>
);
}
Expand Down
30 changes: 30 additions & 0 deletions common/components/controllers/SignUpController.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import DjangoCSRFToken from "django-react-csrftoken";
import React from "react";
import ReCAPTCHA from "react-google-recaptcha";
import type { Validator } from "../forms/FormValidation.jsx";
import FormValidation from "../forms/FormValidation.jsx";
import metrics from "../utils/metrics.js";
Expand All @@ -27,6 +28,7 @@ type State = {|
validations: $ReadOnlyArray<Validator>,
termsOpen: boolean,
didCheckTerms: boolean,
reCaptchaValue: ?string,
isValid: boolean,
|};

Expand All @@ -50,6 +52,7 @@ class SignUpController extends React.Component<Props, State> {
password2: "",
termsOpen: false,
didCheckTerms: false,
reCaptchaValue: null,
isValid: false,
validations: [
{
Expand Down Expand Up @@ -88,10 +91,23 @@ class SignUpController extends React.Component<Props, State> {
checkFunc: (state: State) => state.didCheckTerms,
errorMessage: "Agree to terms of service",
},
{
checkFunc: (state: State) =>
!this.isCaptchaEnabled() || !_.isEmpty(state.reCaptchaValue),
errorMessage: "Please complete the captcha",
},
],
};
}

isCaptchaEnabled(): boolean {
return !_.isEmpty(window.GR_SITEKEY);
}

reCaptchaOnChange(value: ?string): void {
this.setState({ reCaptchaValue: value });
}

onValidationCheck(isValid: boolean): void {
if (isValid !== this.state.isValid) {
this.setState({ isValid });
Expand Down Expand Up @@ -153,6 +169,11 @@ class SignUpController extends React.Component<Props, State> {
/>
</div>
<input name="password" value={this.state.password1} type="hidden" />
<input
name="reCaptchaValue"
value={this.state.reCaptchaValue || ""}
type="hidden"
/>

<div>
<input name="newsletter_signup" type="checkbox" />
Expand Down Expand Up @@ -206,6 +227,15 @@ class SignUpController extends React.Component<Props, State> {
formState={this.state}
/>

{this.isCaptchaEnabled() ? (
<div className="LogInController-captcha">
<ReCAPTCHA
sitekey={window.GR_SITEKEY}
onChange={this.reCaptchaOnChange.bind(this)}
/>
</div>
) : null}

<Button
variant="success"
className="LogInController-signInButton"
Expand Down
40 changes: 39 additions & 1 deletion common/helpers/mailing_list.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
"""
Mailchimp setup instructions

1. Create a Mailchimp account and audience
- In Mailchimp, create or choose the audience that should receive signups.
- This module uses member status "subscribed" after DemocracyLab email
verification, so users are not prompted with a second Mailchimp opt-in email.

2. Create an API key
- In Mailchimp: Profile -> Extras -> API keys -> Create A Key.
- Copy the generated key.

3. Configure app environment variables
- Set MAILCHIMP_API_KEY to your Mailchimp API key.
- Set MAILCHIMP_SUBSCRIBE_LIST_ID to your audience/list id.

4. Find your audience/list id
- In Mailchimp audience settings, copy the Audience ID.
- Use that value for MAILCHIMP_SUBSCRIBE_LIST_ID.

5. Verify the signup flow
- Start the app and submit signup with newsletter opt-in.
- Verify the user email first (subscription is deferred until verification).
- Confirm a "subscribed" member appears in Mailchimp after verification.

Security notes
- Keep subscription deferred until after DemocracyLab email verification.
- Keep CAPTCHA and signup rate limiting enabled on the signup endpoint.
"""

import threading
from mailchimp3 import MailChimp
from django.conf import settings
Expand All @@ -16,11 +46,19 @@ def print_error(self, err_msg):
err_msg = "Failed to subscribe {first} {last}({email}) to mailing list: {err_msg}".format(
first=self.first_name,
last=self.last_name,
email=self.email,
email=self.masked_email(),
err_msg=err_msg,
)
print(err_msg)

def masked_email(self):
if not self.email or '@' not in self.email:
return '***'

local, domain = self.email.split('@', 1)
visible = local[:2] if len(local) >= 2 else local[:1]
return '{local}***@{domain}'.format(local=visible, domain=domain)

def run(self):
if settings.MAILCHIMP_API_KEY is None:
self.print_error("MAILCHIMP_API_KEY not set")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('democracylab', '0010_alter_usertaggedtechnologies_tag'),
]

operations = [
migrations.AddField(
model_name='contributor',
name='newsletter_signup_requested',
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions democracylab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class UserTaggedTechnologies(TaggedItemBase):

class Contributor(User):
email_verified = models.BooleanField(default=False)
newsletter_signup_requested = models.BooleanField(default=False)
country = models.CharField(max_length=2, blank=True)
postal_code = models.CharField(max_length=20, blank=True)
phone_primary = models.CharField(max_length=200, blank=True)
Expand Down
6 changes: 6 additions & 0 deletions democracylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ def read_connection_config(config):
# Google ReCaptcha keys - site key is exposed to the front end, secret is not
GR_SITEKEY = os.environ.get("GOOGLE_RECAPTCHA_SITE_KEY", "")
GR_SECRETKEY = os.environ.get("GOOGLE_RECAPTCHA_SECRET_KEY", "")
SIGNUP_RATE_LIMIT_ATTEMPTS = int(os.environ.get("SIGNUP_RATE_LIMIT_ATTEMPTS", "10"))
SIGNUP_RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("SIGNUP_RATE_LIMIT_WINDOW_SECONDS", "60"))

# Heap Analytics app id
HEAP_ANALYTICS_ID = os.environ.get("HEAP_ANALYTICS_ID", "")
Expand Down Expand Up @@ -412,6 +414,10 @@ def read_connection_config(config):
"*.google-analytics.com",
"*.nr-data.net",
"*.hereapi.com",
# reCAPTCHA posts token validation requests to Google endpoints.
"https://www.google.com",
# reCAPTCHA also loads assets from Google's static CDN.
"https://www.gstatic.com",
"https://*.hotjar.com",
"https://*.hotjar.io",
"wss://*.hotjar.com",
Expand Down
79 changes: 73 additions & 6 deletions democracylab/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
from common.helpers.front_end import section_url
from common.helpers.mailing_list import SubscribeToMailingList
from common.helpers.qiqo_chat import SubscribeUserToQiqoChat
from django.conf import settings
from django.contrib.auth import login, logout, authenticate
from django.contrib.auth.tokens import default_token_generator
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import redirect
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
import simplejson as json
import requests
from .emails import send_verification_email, send_password_reset_email
from .forms import DemocracyLabUserCreationForm, DemocracyLabUserAddDetailsForm
from .models import Contributor, get_request_contributor, get_contributor_by_username
Expand All @@ -17,6 +20,55 @@
from salesforce import contact as salesforce_contact


def _client_ip(request):
forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
return request.META.get('REMOTE_ADDR', '')


def _is_signup_rate_limited(request):
ip = _client_ip(request)
cache_key = 'signup-attempts:{ip}'.format(ip=ip)
limit = settings.SIGNUP_RATE_LIMIT_ATTEMPTS
window_seconds = settings.SIGNUP_RATE_LIMIT_WINDOW_SECONDS

attempts = cache.get(cache_key, 0)
if attempts >= limit:
return True

if attempts == 0:
cache.set(cache_key, 1, timeout=window_seconds)
else:
try:
cache.incr(cache_key)
except ValueError:
cache.set(cache_key, attempts + 1, timeout=window_seconds)
return False


def _is_signup_captcha_valid(request):
if not settings.GR_SECRETKEY:
return True

recaptcha_value = request.POST.get('reCaptchaValue')
if not recaptcha_value:
return False

try:
response = requests.post(
'https://www.google.com/recaptcha/api/siteverify',
data={
'secret': settings.GR_SECRETKEY,
'response': recaptcha_value,
},
timeout=5,
)
return response.status_code == 200 and response.json().get('success') is True
except Exception:
return False


def login_view(request, provider=None):
provider_ids = [p.id for p in registry.get_list()]
if request.method == 'POST':
Expand Down Expand Up @@ -60,17 +112,27 @@ def logout_view(request):

def signup(request):
if request.method == 'POST':
if _is_signup_rate_limited(request):
messages.error(request, 'Too many signup attempts. Please wait a minute and try again.')
return redirect(section_url(FrontEndSection.SignUp))

if not _is_signup_captcha_valid(request):
messages.error(request, 'Captcha validation failed. Please try again.')
return redirect(section_url(FrontEndSection.SignUp))

form = DemocracyLabUserCreationForm(request.POST)
if form.is_valid():
email = form.cleaned_data.get('email')
raw_password = form.cleaned_data.get('password1')
subscribe_checked = bool(form.data.get('newsletter_signup'))
# TODO: Form validation
contributor = Contributor(
username=email.lower(),
email=email.lower(),
first_name=form.cleaned_data.get('first_name'),
last_name=form.cleaned_data.get('last_name'),
email_verified=False
email_verified=False,
newsletter_signup_requested=subscribe_checked,
)
contributor.set_password(raw_password)
contributor.save()
Expand All @@ -79,11 +141,6 @@ def signup(request):
login(request, user)
send_verification_email(contributor)

subscribe_checked = form.data.get('newsletter_signup')
if subscribe_checked:
SubscribeToMailingList(email=contributor.email, first_name=contributor.first_name,
last_name=contributor.last_name)

SubscribeUserToQiqoChat(contributor)

return redirect(section_url(FrontEndSection.SignedUp))
Expand Down Expand Up @@ -139,7 +196,17 @@ def verify_user(request, user_id, token):
# TODO: Add feedback from the frontend to indicate success/failure
contributor = Contributor.objects.get(id=user_id)
contributor.email_verified = True
subscribe_to_newsletter = contributor.newsletter_signup_requested
contributor.newsletter_signup_requested = False
contributor.save()

if subscribe_to_newsletter:
SubscribeToMailingList(
email=contributor.email,
first_name=contributor.first_name,
last_name=contributor.last_name,
)

return redirect(section_url(FrontEndSection.EmailVerified))
else:
return HttpResponse(status=401)
Expand Down