Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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).
Comment thread
ddfridley marked this conversation as resolved.
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
Comment thread
ddfridley marked this conversation as resolved.

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):
Comment thread
ddfridley marked this conversation as resolved.
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
Comment thread
ddfridley marked this conversation as resolved.
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', '')
Comment thread
ddfridley marked this conversation as resolved.


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
Comment thread
ddfridley marked this conversation as resolved.
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