Skip to content

Commit

Permalink
Merge pull request #2261 from liberapay/various
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco committed Jul 17, 2023
2 parents 6c0e1fa + edc5a13 commit 1022645
Show file tree
Hide file tree
Showing 15 changed files with 160 additions and 191 deletions.
9 changes: 9 additions & 0 deletions liberapay/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
send_upcoming_debit_notifications,
)
from liberapay.security import authentication, csrf, set_default_security_headers
from liberapay.security.csp import csp_allow, csp_allow_stripe
from liberapay.utils import (
b64decode_s, b64encode_s, erase_cookie, http_caching, set_cookie,
)
Expand Down Expand Up @@ -368,6 +369,14 @@ def _find_input_name(self, value):
return k
pando.http.request.Request.find_input_name = _find_input_name

if hasattr(pando.Response, 'csp_allow'):
raise Warning('pando.Response.csp_allow() already exists')
pando.Response.csp_allow = csp_allow

if hasattr(pando.Response, 'csp_allow_stripe'):
raise Warning('pando.Response.csp_allow_stripe() already exists')
pando.Response.csp_allow_stripe = csp_allow_stripe

if hasattr(pando.Response, 'encode_url'):
raise Warning('pando.Response.encode_url() already exists')
def _encode_url(url):
Expand Down
45 changes: 45 additions & 0 deletions liberapay/security/csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""This module provides tools for Content Security Policies.
"""

from typing import Tuple


class CSP(bytes):

# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
based_on_default_src = set(b'''
child-src connect-src font-src frame-src img-src manifest-src
media-src object-src script-src style-src worker-src
'''.split())

def __new__(cls, x):
if isinstance(x, dict):
self = bytes.__new__(cls, b';'.join(b' '.join(t).rstrip() for t in x.items()) + b';')
self.directives = dict(x)
else:
self = bytes.__new__(cls, x)
self.directives = dict(
(d.split(b' ', 1) + [b''])[:2] for d in self.split(b';') if d
)
return self


def csp_allow(response, *items: Tuple[bytes, bytes]) -> None:
csp = response.headers[b'content-security-policy']
d = csp.directives.copy()
for directive, value in items:
old_value = d.get(directive)
if old_value is None and directive in csp.based_on_default_src:
old_value = d.get(b'default-src')
d[directive] = b'%s %s' % (old_value, value) if old_value else value
response.headers[b'content-security-policy'] = CSP(d)


def csp_allow_stripe(response) -> None:
# https://stripe.com/docs/security#content-security-policy
csp_allow(
response,
(b'connect-src', b"api.stripe.com"),
(b'frame-src', b"js.stripe.com hooks.stripe.com"),
(b'script-src', b"js.stripe.com"),
)
29 changes: 1 addition & 28 deletions liberapay/wireup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
from liberapay.models.repository import Repository
from liberapay.models.tip import Tip
from liberapay.security.crypto import Cryptograph
from liberapay.security.csp import CSP
from liberapay.utils import find_files, markdown, resolve
from liberapay.utils.emails import compile_email_spt
from liberapay.utils.http_caching import asset_etag
Expand All @@ -67,34 +68,6 @@ def canonical(env):
return locals()


class CSP(bytes):

# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
based_on_default_src = set(b'''
child-src connect-src font-src frame-src img-src manifest-src
media-src object-src script-src style-src worker-src
'''.split())

def __new__(cls, x):
if isinstance(x, dict):
self = bytes.__new__(cls, b';'.join(b' '.join(t).rstrip() for t in x.items()) + b';')
self.directives = dict(x)
else:
self = bytes.__new__(cls, x)
self.directives = dict(
(d.split(b' ', 1) + [b''])[:2] for d in self.split(b';') if d
)
return self

def allow(self, directive, value):
d = dict(self.directives)
old_value = d.get(directive)
if old_value is None and directive in self.based_on_default_src:
old_value = d.get(b'default-src')
d[directive] = b'%s %s' % (old_value, value) if old_value else value
return CSP(d)


def csp(canonical_host, canonical_scheme, env):
csp = (
b"default-src 'self' %(main_domain)s;"
Expand Down
216 changes: 80 additions & 136 deletions templates/macros/your-tip.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
% from "templates/macros/icons.html" import fontawesome, glyphicon
% from "templates/macros/payment-methods.html" import payment_methods_icons with context

% macro tip_form(tippee, tip=None, inline=False, disabled='')
% macro tip_form(tippee, tip=None, disabled='')
% set tippee_is_stub = tippee.__class__.__name__ == 'AccountElsewhere'
% set tippee_p = tippee.participant if tippee_is_stub else tippee
% set pledging = tippee_p.payment_providers == 0 or not tippee_p.accepts_tips
Expand All @@ -13,147 +13,92 @@
% set new_currency = request.qs['currency']
% endif
% set currency_mismatch = tip_currency not in accepted_currencies
% if inline
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="back_to" value="{{ request.line.uri.decoded }}" />
% if tip.renewal_mode > 0 and not pledging
% if currency_mismatch
<p class="text-warning small">{{ _(
"Your current donation to {name} is in {currency}, but they now only "
"accept donations in {accepted_currency}. You can convert your donation "
"to that currency, or discontinue it."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) if len(accepted_currencies) == 1 else _(
"Your current donation to {name} is in {currency}, but they no longer "
"accept that currency. The suggested new currency is the {accepted_currency}, "
"but you can choose another one."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) }}</p>
% endif
% set periodic_amount = tip.periodic_amount.convert(new_currency)
{{ tip_input(new_currency, tip.period, periodic_amount, disabled, small=True,
accepted_currencies=accepted_currencies) }}
<button class="btn btn-{{ 'primary' if tip.renewal_mode > 0 else 'donate' }}
btn-sm" {{ disabled }}>{{
_("Modify") if tip.renewal_mode > 0 else _("Pledge") if pledging else _("Donate")
}}</button>
% if tip.renewal_mode > 0
&nbsp;&nbsp;
<button class="btn btn-danger btn-sm" name="selected_amount" value="0">{{ _("Discontinue") }}</button>
% endif
</form>
% else
% if tip.renewal_mode > 0 and not pledging
% if currency_mismatch
<p class="alert alert-warning">{{ _(
"Your current donation to {name} is in {currency}, but they now only "
"accept donations in {accepted_currency}. You can convert your donation "
"to that currency, or discontinue it."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) if len(accepted_currencies) == 1 else _(
"Your current donation to {name} is in {currency}, but they no longer "
"accept that currency. The suggested new currency is the {accepted_currency}, "
"but you can choose another one."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) }}</p>
% else
<p>{{ _(
"You are currently donating {money_amount} per week to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) if tip.period == 'weekly' else _(
"You are currently donating {money_amount} per month to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) if tip.period == 'monthly' else _(
"You are currently donating {money_amount} per year to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) }}</p>
% endif
<p class="alert alert-warning">{{ _(
"Your current donation to {name} is in {currency}, but they now only "
"accept donations in {accepted_currency}. You can convert your donation "
"to that currency, or discontinue it."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) if len(accepted_currencies) == 1 else _(
"Your current donation to {name} is in {currency}, but they no longer "
"accept that currency. The suggested new currency is the {accepted_currency}, "
"but you can choose another one."
, name=tippee_name
, currency=Currency(tip_currency)
, accepted_currency=Currency(new_currency)
) }}</p>
% else
<p>{{ _("Please select or input an amount:") }}</p>
<p>{{ _(
"You are currently donating {money_amount} per week to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) if tip.period == 'weekly' else _(
"You are currently donating {money_amount} per month to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) if tip.period == 'monthly' else _(
"You are currently donating {money_amount} per year to {recipient_name}. "
"The form below enables you to modify or stop your donation."
, money_amount=tip.periodic_amount, recipient_name=tippee_name
) }}</p>
% endif
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="currency" value="{{ new_currency }}" />
<div class="form-group">
{{ tip_select(tip, new_currency, tippee, disabled) }}
</div>
</form>
% if len(accepted_currencies) > 1
<br>
<p>{{ ngettext(
"The {currency_name} isn't your preferred currency? {n} other is supported:",
"The {currency_name} isn't your preferred currency? {n} others are supported:",
n=len(accepted_currencies) - 1, currency_name=Currency(new_currency)
) if tippee_is_stub else ngettext(
"The {currency_name} isn't your preferred currency? {username} also accepts {n} other:",
"The {currency_name} isn't your preferred currency? {username} also accepts {n} others:",
n=len(accepted_currencies) - 1, currency_name=Currency(new_currency), username=tippee_name
) }}</p>
<form action="" method="GET" class="form-inline">
<select class="form-control" name="currency">
% set paypal = tippee_p.payment_providers.__and__(2)
% for c, translated_currency_name in locale.supported_currencies.items()
% if c in accepted_currencies and c != new_currency
<option value="{{ c }}">
{{ translated_currency_name }}
({{ locale.currency_symbols.get(c, c) }})
% if paypal and c not in constants.PAYPAL_CURRENCIES
({{ _("not supported by PayPal") }})
% endif
</option>
% else
<p>{{ _("Please select or input an amount:") }}</p>
% endif
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="currency" value="{{ new_currency }}" />
<div class="form-group">
{{ tip_select(tip, new_currency, tippee, disabled) }}
</div>
</form>
% if len(accepted_currencies) > 1
<br>
<p>{{ ngettext(
"The {currency_name} isn't your preferred currency? {n} other is supported:",
"The {currency_name} isn't your preferred currency? {n} others are supported:",
n=len(accepted_currencies) - 1, currency_name=Currency(new_currency)
) if tippee_is_stub else ngettext(
"The {currency_name} isn't your preferred currency? {username} also accepts {n} other:",
"The {currency_name} isn't your preferred currency? {username} also accepts {n} others:",
n=len(accepted_currencies) - 1, currency_name=Currency(new_currency), username=tippee_name
) }}</p>
<form action="" method="GET" class="form-inline">
<select class="form-control" name="currency">
% set paypal = tippee_p.payment_providers.__and__(2)
% for c, translated_currency_name in locale.supported_currencies.items()
% if c in accepted_currencies and c != new_currency
<option value="{{ c }}">
{{ translated_currency_name }}
({{ locale.currency_symbols.get(c, c) }})
% if paypal and c not in constants.PAYPAL_CURRENCIES
({{ _("not supported by PayPal") }})
% endif
% endfor
</select>
<button class="btn btn-default">{{ _("Switch") }}</button>
</form>
% endif
</option>
% endif
% endfor
</select>
<button class="btn btn-default">{{ _("Switch") }}</button>
</form>
% endif
% endmacro

% macro tip_input(new_currency, period, periodic_amount, disabled='', small=False, accepted_currencies=None)
% macro tip_input(new_currency, period, periodic_amount, disabled='', accepted_currencies=None)
<div class="form-inline inline-block">
<div class="input-group {{ 'input-group-sm' if small else '' }}">
% if accepted_currencies and len(accepted_currencies) > 1
<div class="input-group-btn">
<select class="btn btn-default" name="currency">
% for c in constants.CURRENCIES
<option value="{{ c }}" {{ 'selected' if c == new_currency }}
{{ 'disabled' if c not in accepted_currencies }}>{{
locale.currency_symbols.get(c, c)
}}</option>
% endfor
</select>
<div class="input-group">
<div class="input-group-addon">{{ locale.currency_symbols.get(new_currency, new_currency) }}</div>
<input type="hidden" name="currency" value="{{ new_currency }}" />
<input type="tel" inputmode="decimal" name="amount" id="amount" placeholder="{{ _('Amount') }}"
class="amount form-control"
data-required-if-checked="#custom-amount-radio"
value="{{ locale.format_money(periodic_amount, format='amount_only')
if periodic_amount else '' }}"
{{ disabled }} />
</div>
% else
<div class="input-group-addon">{{ locale.currency_symbols.get(new_currency, new_currency) }}</div>
<input type="hidden" name="currency" value="{{ new_currency }}" />
% endif
<input type="tel" inputmode="decimal" name="amount" id="amount" placeholder="{{ _('Amount') }}"
class="amount form-control {{ 'input-sm' if small else '' }}"
data-required-if-checked="#custom-amount-radio"
value="{{ locale.format_money(periodic_amount, format='amount_only')
if periodic_amount else '' }}"
{{ disabled }} />
</div>
% set period = request.qs.get('period') or period or 'weekly'
% if small
<select name="period" class="form-control {{ 'input-sm' if small else '' }}">
<option value="weekly">{{ _("per week") }}</option>
<option value="monthly" {{ 'selected' if period == 'monthly' }}>{{ _("per month") }}</option>
<option value="yearly" {{ 'selected' if period == 'yearly' }}>{{ _("per year") }}</option>
</select>
% else
% set period = request.qs.get('period') or period or 'weekly'
<div class="btn-group btn-group-radio">
% set periods = [
('weekly', _("per week")),
Expand All @@ -167,11 +112,10 @@
</label>
% endfor
</div>
% endif
</div>
% endmacro

% macro tip_select(tip, new_currency, tippee, disabled='', small=False)
% macro tip_select(tip, new_currency, tippee, disabled='')
% set tippee_is_stub = tippee.__class__.__name__ == 'AccountElsewhere'
% set tippee_p = tippee.participant if tippee_is_stub else tippee
% set pledging = tippee_p.payment_providers == 0 or not tippee_p.accepts_tips
Expand Down Expand Up @@ -217,7 +161,7 @@ <h5 class="list-group-item-heading">{{ _(std_tip.label) }}</h5>
{{ 'checked' if periodic_amount else '' }} />
<div class="radio-label">
<h5 class="list-group-item-heading">{{ _("Custom") }}</h5>
{{ tip_input(new_currency, period, periodic_amount, disabled, small=small) }}
{{ tip_input(new_currency, period, periodic_amount, disabled) }}
</div>
</label>
</li>
Expand Down
2 changes: 1 addition & 1 deletion tests/py/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

from liberapay import utils
from liberapay.i18n.currencies import Money, MoneyBasket
from liberapay.security.csp import CSP
from liberapay.testing import Harness
from liberapay.utils import markdown, b64encode_s, b64decode_s, cbor
from liberapay.wireup import CSP


class Tests(Harness):
Expand Down
2 changes: 1 addition & 1 deletion www/%username/giving/pay/paypal/%payin_id.spt
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ if request.method == 'POST':
AND p.payment_providers & %s > 0
ORDER BY t.id
""", (payer.id, set(body.parse_list('tips', int)), PAYPAL_BIT))
if len(set(tip.amount.currency for tip in tips)) != 1:
if set(tip.amount.currency for tip in tips) != {payin_amount.currency}:
raise response.invalid_input(body.get('tips'), 'tips', 'body')
if len(tips) > 1:
raise response.error(400, "We don't support one-to-many payments through PayPal yet.")
Expand Down
Loading

0 comments on commit 1022645

Please sign in to comment.