Browse Source

Re-add donations via Stripe

Multiple people have been asking me how they can make a one-time
donation without going through GitHub or Patreon, so I'll re-add this
for now but will need to keep an eye out for fraud and potentially
disable it again soon.
merge-requests/137/head
Deimos 2 years ago
parent
commit
0dbb031562
  1. 4
      tildes/production.ini.example
  2. 1
      tildes/requirements.in
  3. 10
      tildes/static/js/behaviors/stripe-checkout.js
  4. 28
      tildes/static/js/behaviors/stripe-donate-form.js
  5. 4
      tildes/tildes/lib/ratelimit.py
  6. 4
      tildes/tildes/routes.py
  7. 54
      tildes/tildes/templates/donate_stripe.jinja2
  8. 18
      tildes/tildes/templates/donate_stripe_redirect.jinja2
  9. 15
      tildes/tildes/templates/donate_success.jinja2
  10. 115
      tildes/tildes/views/donate.py

4
tildes/production.ini.example

@ -23,6 +23,8 @@ redis.sessions.timeout = 3600
sqlalchemy.url = postgresql+psycopg2://tildes:@:6432/tildes
stripe.recurring_donation_product_id = prod_ProductID
tildes.default_user_comment_label_weight = 1.0
# Path to the file to use to check for passwords that have been in data breaches, which
@ -42,4 +44,6 @@ webassets.manifest = json
# API keys for external APIs
api_keys.embedly = embedlykeygoeshere
api_keys.stripe.publishable = pk_live_ActualKeyShouldGoHere
api_keys.stripe.secret = sk_live_ActualKeyShouldGoHere
api_keys.youtube = youtubekeygoeshere

1
tildes/requirements.in

@ -34,6 +34,7 @@ requests
sentry-sdk
SQLAlchemy<1.4
SQLAlchemy-Utils
stripe
titlecase
webargs
wrapt

10
tildes/static/js/behaviors/stripe-checkout.js

@ -0,0 +1,10 @@
// Copyright (c) 2019 Tildes contributors <code@tildes.net>
// SPDX-License-Identifier: AGPL-3.0-or-later
$.onmount("[data-js-stripe-checkout]", function() {
/* eslint-disable-next-line no-undef */
var stripe = Stripe($(this).attr("data-js-stripe-checkout"));
stripe.redirectToCheckout({
sessionId: $(this).attr("data-js-stripe-checkout-session")
});
});

28
tildes/static/js/behaviors/stripe-donate-form.js

@ -0,0 +1,28 @@
// Copyright (c) 2019 Tildes contributors <code@tildes.net>
// SPDX-License-Identifier: AGPL-3.0-or-later
$.onmount("[data-js-stripe-donate-form]", function() {
$(this).on("submit", function(event) {
var $amountInput = $(this).find("#amount");
var amount = $amountInput.val();
var $errorDiv = $(this).find(".text-status-message");
// remove dollar sign and/or comma, then parse into float
amount = amount.replace(/[$,]/g, "");
amount = parseFloat(amount);
if (isNaN(amount)) {
$errorDiv.text("Please enter a valid dollar amount.");
event.preventDefault();
return;
} else if (amount < 1.0) {
$errorDiv.text("Donation amount must be at least $1.");
event.preventDefault();
return;
}
// set the value in case any of the replacements happened
$amountInput.val(amount);
});
});

4
tildes/tildes/lib/ratelimit.py

@ -308,6 +308,10 @@ _RATE_LIMITED_ACTIONS = (
RateLimitedAction("register", timedelta(hours=1), 50),
RateLimitedAction("topic_post", timedelta(hours=4), 10, max_burst=4),
RateLimitedAction("comment_post", timedelta(hours=1), 10, max_burst=5),
RateLimitedAction("donate_stripe", timedelta(hours=1), 5, by_user=False),
RateLimitedAction(
"global_donate_stripe", timedelta(hours=1), 50, by_user=False, by_ip=False
),
)
# (public) dict to be able to look up the actions by name

4
tildes/tildes/routes.py

@ -115,6 +115,10 @@ def includeme(config: Configurator) -> None:
# Route to expose metrics to Prometheus
config.add_route("metrics", "/metrics")
# Routes for Stripe donations (linked from Docs site)
config.add_route("donate_stripe", "/donate_stripe")
config.add_route("donate_success", "/donate_success")
# Add all intercooler routes under the /api/web path
with config.route_prefix_context("/api/web"):
add_intercooler_routes(config)

54
tildes/tildes/templates/donate_stripe.jinja2

@ -0,0 +1,54 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% block title %}Donate to Tildes{% endblock %}
{% block main_heading %}Credit card donation (via Stripe){% endblock %}
{% block content %}
<p>After submitting this form, you will be redirected to the Stripe site to enter your payment info.</p>
<p>Note that the donation is in Canadian Dollars (CAD) by default, but you can switch to USD if you prefer. At the moment, $10 CAD is about $7.50 USD.</p>
<div class="divider"></div>
<form class="form-narrow" method="post" data-js-stripe-donate-form>
<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}">
<div class="form-group">
<label class="form-label">Donation type</label>
<label class="form-radio">
<input type="radio" name="interval" value="onetime" checked>
<i class="form-icon"></i> One time
</label>
<label class="form-radio">
<input type="radio" name="interval" value="month">
<i class="form-icon"></i> Monthly
</label>
<label class="form-radio">
<input type="radio" name="interval" value="year">
<i class="form-icon"></i> Yearly
</label>
</div>
<div class="form-group">
<label class="form-label" for="amount">Donation amount (must be at least $1)</label>
<div class="input-group">
<span class="input-group-addon">$</span>
<input class="form-input" id="amount" name="amount" type="text" data-js-auto-focus>
<select class="form-select" id="currency" name="currency">
<option value="CAD" selected>CAD</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div class="form-buttons">
<button class="btn btn-primary" type="submit">Donate</button>
<div class="text-status-message text-error"></div>
</div>
</form>
{% endblock %}

18
tildes/tildes/templates/donate_stripe_redirect.jinja2

@ -0,0 +1,18 @@
{# Copyright (c) 2018 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% block title %}Stripe donation{% endblock %}
{% block content %}
<script src="https://js.stripe.com/v3/"></script>
<p>Redirecting to Stripe...</p>
{# This div will cause the page to redirect to the Stripe Checkout page #}
<div
data-js-stripe-checkout="{{ publishable_key }}"
data-js-stripe-checkout-session="{{ session_id }}"
></div>
{% endblock %}

15
tildes/tildes/templates/donate_success.jinja2

@ -0,0 +1,15 @@
{# Copyright (c) 2019 Tildes contributors <code@tildes.net> #}
{# SPDX-License-Identifier: AGPL-3.0-or-later #}
{% extends 'base_no_sidebar.jinja2' %}
{% block title %}Thanks for donating!{% endblock %}
{% block content %}
<div class="empty">
<h2 class="empty-title">Thanks for donating to Tildes!</h2>
<p class="empty-subtitle">You should receive an email receipt. If you have any questions, please feel free to contact <a href="mailto:donate@tildes.net">donate@tildes.net</a></p>
<div class="empty-action"><a href="/" class="btn btn-primary">Back to the home page</a></div>
</div>
{% endblock %}

115
tildes/tildes/views/donate.py

@ -0,0 +1,115 @@
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
# SPDX-License-Identifier: AGPL-3.0-or-later
"""The view for donating via Stripe."""
import stripe
from marshmallow.fields import Float, String
from marshmallow.validate import OneOf, Range
from pyramid.httpexceptions import HTTPInternalServerError
from pyramid.request import Request
from pyramid.security import NO_PERMISSION_REQUIRED
from pyramid.view import view_config
from tildes.metrics import incr_counter
from tildes.views.decorators import rate_limit_view, use_kwargs
@view_config(
route_name="donate_stripe",
request_method="GET",
renderer="donate_stripe.jinja2",
permission=NO_PERMISSION_REQUIRED,
)
def get_donate_stripe(request: Request) -> dict:
"""Display the form for donating with Stripe."""
# pylint: disable=unused-argument
return {}
@view_config(
route_name="donate_stripe",
request_method="POST",
renderer="donate_stripe_redirect.jinja2",
permission=NO_PERMISSION_REQUIRED,
)
@use_kwargs(
{
"amount": Float(required=True, validate=Range(min=1.0)),
"currency": String(required=True, validate=OneOf(("CAD", "USD"))),
"interval": String(required=True, validate=OneOf(("onetime", "month", "year"))),
},
location="form",
)
@rate_limit_view("global_donate_stripe")
@rate_limit_view("donate_stripe")
def post_donate_stripe(
request: Request, amount: int, currency: str, interval: str
) -> dict:
"""Process a Stripe donation."""
try:
stripe.api_key = request.registry.settings["api_keys.stripe.secret"]
publishable_key = request.registry.settings["api_keys.stripe.publishable"]
product_id = request.registry.settings["stripe.recurring_donation_product_id"]
except KeyError as exc:
raise HTTPInternalServerError from exc
incr_counter("donation_initiations", type="stripe")
if interval == "onetime":
session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[
{
"name": "One-time donation - tildes.net",
"amount": int(amount * 100),
"currency": currency,
"quantity": 1,
}
],
submit_type="donate",
success_url="https://tildes.net/donate_success",
cancel_url="https://docs.tildes.net/donate",
)
else:
product = stripe.Product.retrieve(product_id)
existing_plans = stripe.Plan.list(product=product, active=True, limit=100)
# look through existing plans to see if there's already a matching one, or
# create a new plan if not
for existing_plan in existing_plans:
if (
existing_plan.amount == int(amount * 100)
and existing_plan.currency == currency.lower()
and existing_plan.interval == interval
):
plan = existing_plan
break
else:
plan = stripe.Plan.create(
amount=int(amount * 100),
currency=currency,
interval=interval,
product=product,
)
session = stripe.checkout.Session.create(
payment_method_types=["card"],
subscription_data={"items": [{"plan": plan.id}]},
success_url="https://tildes.net/donate_success",
cancel_url="https://docs.tildes.net/donate",
)
return {"publishable_key": publishable_key, "session_id": session.id}
@view_config(route_name="donate_success", renderer="donate_success.jinja2")
def get_donate_success(request: Request) -> dict:
"""Display a message after a successful donation."""
# pylint: disable=unused-argument
# incrementing this metric on page-load and hard-coding Stripe isn't ideal, but it
# should do the job for now
incr_counter("donations", type="stripe")
return {}
Loading…
Cancel
Save