diff --git a/tildes/production.ini.example b/tildes/production.ini.example index 6696e50..3c7ad68 100644 --- a/tildes/production.ini.example +++ b/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 diff --git a/tildes/requirements.in b/tildes/requirements.in index 5124adb..cce55ce 100644 --- a/tildes/requirements.in +++ b/tildes/requirements.in @@ -34,6 +34,7 @@ requests sentry-sdk SQLAlchemy<1.4 SQLAlchemy-Utils +stripe titlecase webargs wrapt diff --git a/tildes/static/js/behaviors/stripe-checkout.js b/tildes/static/js/behaviors/stripe-checkout.js new file mode 100644 index 0000000..1e9c2a2 --- /dev/null +++ b/tildes/static/js/behaviors/stripe-checkout.js @@ -0,0 +1,10 @@ +// Copyright (c) 2019 Tildes contributors +// 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") + }); +}); diff --git a/tildes/static/js/behaviors/stripe-donate-form.js b/tildes/static/js/behaviors/stripe-donate-form.js new file mode 100644 index 0000000..d6db4d1 --- /dev/null +++ b/tildes/static/js/behaviors/stripe-donate-form.js @@ -0,0 +1,28 @@ +// Copyright (c) 2019 Tildes contributors +// 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); + }); +}); diff --git a/tildes/tildes/lib/ratelimit.py b/tildes/tildes/lib/ratelimit.py index 75516a3..3791c04 100644 --- a/tildes/tildes/lib/ratelimit.py +++ b/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 diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index e306130..bd8b8f4 100644 --- a/tildes/tildes/routes.py +++ b/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) diff --git a/tildes/tildes/templates/donate_stripe.jinja2 b/tildes/tildes/templates/donate_stripe.jinja2 new file mode 100644 index 0000000..220f78e --- /dev/null +++ b/tildes/tildes/templates/donate_stripe.jinja2 @@ -0,0 +1,54 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# 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 %} +

After submitting this form, you will be redirected to the Stripe site to enter your payment info.

+ +

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.

+ +
+ +
+ + +
+ + + + +
+ +
+ + +
+ $ + + +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/tildes/tildes/templates/donate_stripe_redirect.jinja2 b/tildes/tildes/templates/donate_stripe_redirect.jinja2 new file mode 100644 index 0000000..4a0f7bf --- /dev/null +++ b/tildes/tildes/templates/donate_stripe_redirect.jinja2 @@ -0,0 +1,18 @@ +{# Copyright (c) 2018 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Stripe donation{% endblock %} + +{% block content %} + + +

Redirecting to Stripe...

+ + {# This div will cause the page to redirect to the Stripe Checkout page #} +
+{% endblock %} diff --git a/tildes/tildes/templates/donate_success.jinja2 b/tildes/tildes/templates/donate_success.jinja2 new file mode 100644 index 0000000..3c0f550 --- /dev/null +++ b/tildes/tildes/templates/donate_success.jinja2 @@ -0,0 +1,15 @@ +{# Copyright (c) 2019 Tildes contributors #} +{# SPDX-License-Identifier: AGPL-3.0-or-later #} + +{% extends 'base_no_sidebar.jinja2' %} + +{% block title %}Thanks for donating!{% endblock %} + +{% block content %} +
+

Thanks for donating to Tildes!

+

You should receive an email receipt. If you have any questions, please feel free to contact donate@tildes.net

+ + +
+{% endblock %} diff --git a/tildes/tildes/views/donate.py b/tildes/tildes/views/donate.py new file mode 100644 index 0000000..36881bb --- /dev/null +++ b/tildes/tildes/views/donate.py @@ -0,0 +1,115 @@ +# Copyright (c) 2018 Tildes contributors +# 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 {}