diff --git a/401.spt b/401.spt index 0b2d0e1320..e3773fe2e1 100644 --- a/401.spt +++ b/401.spt @@ -4,7 +4,8 @@ banner = "401" [---] text/html via jinja2 {% extends "templates/base.html" %} {% block content %} -Please {% include "templates/sign-in-using.html" %} to continue. +

{{ _('Please sign in to continue.') }}

+

{{ sign_in_using() }}

{% endblock %} [---] application/json via stdlib_percent { "error_code": 401 diff --git a/410.spt b/410.spt index 833b2dbcd7..7b4b7738a3 100644 --- a/410.spt +++ b/410.spt @@ -8,7 +8,8 @@ title = _('Closed') if 'username' in request.path else '410'

{{ _("The account owner has closed this account.") }}

{% if user.ANON %}

{{ _("Are you the account owner?") }}

-

{% include "templates/sign-in-using.html" %} {{ _("to reopen your account.") }}

+

{{ _("You may reopen your account by signing in.") }}

+

{{ sign_in_using() }}

{% endif %} {% else %}

{{ status_strings[410] }}

diff --git a/defaults.env b/defaults.env index 54925d4325..9955681575 100644 --- a/defaults.env +++ b/defaults.env @@ -96,11 +96,11 @@ ASPEN_WWW_ROOT=www/ # https://github.com/benoitc/gunicorn/issues/186 GUNICORN_OPTS="--workers=1 --timeout=99999999" -# For testing Team review ticket posting +# For testing project review ticket posting # Set your own username and an access token in local.env -TEAM_REVIEW_REPO=gratipay/test-gremlin -TEAM_REVIEW_USERNAME= -TEAM_REVIEW_TOKEN= +PROJECT_REVIEW_REPO= +PROJECT_REVIEW_USERNAME= +PROJECT_REVIEW_TOKEN= RAISE_SIGNIN_NOTIFICATIONS=no diff --git a/emails/verification-notice.spt b/emails/verification-notice.spt new file mode 100644 index 0000000000..d6675cb568 --- /dev/null +++ b/emails/verification-notice.spt @@ -0,0 +1,72 @@ +{{ _("New activity on your account") }} + +[---] text/html +{% if new_email_verified %} +{{ ngettext( "We are connecting the {package_name} npm package to the {username} account on " + "Gratipay. This is a notification sent to {email_address} because that is the " + "primary email address we have on file." + , "We are connecting {n} npm packages to the {username} account on Gratipay. This is " + "a notification sent to {email_address} because that is the primary email address " + "we have on file." + , n=npackages + , package_name=('{}'|safe).format(package_name) + , username=('{0}'|safe).format(username) + , email_address=('{}'|safe).format(email) + ) }} +{% elif npackages > 0 %} +{{ ngettext( "We are connecting {email_address} and the {package_name} npm package to the " + "{username} account on Gratipay. This is a notification sent to {email_address_2} " + "because that is the primary email address we have on file." + , "We are connecting {email_address} and {n} npm packages to the {username} account on " + "Gratipay. This is a notification sent to {email_address_2} because that is the " + "primary email address we have on file." + , n=npackages + , package_name=('{}'|safe).format(package_name) + , username=('{0}'|safe).format(username) + , email_address=('{}'|safe).format(new_email) + , email_address_2=('{}'|safe).format(email) + ) }} +{% else %} +{{ _( "We are connecting {email_address} to the {username} account on Gratipay. This is a " + "notification sent to {email_address_2} because that is the primary email address we have " + "on file." + , username=('{0}'|safe).format(username) + , email_address=('{}'|safe).format(new_email) + , email_address_2=('{}'|safe).format(email) + ) }} + {% endif %} +[---] text/plain +{% if new_email_verified %} +{{ ngettext( "We are connecting the {package_name} npm package to the {username} account on " + "Gratipay. This is a notification sent to {email_address} because that is the " + "primary email address we have on file." + , "We are connecting {n} npm packages to the {username} account on Gratipay. This is " + "a notification sent to {email_address} because that is the primary email address " + "we have on file." + , n=npackages + , package_name=package_name + , username=username + , email_address=email + ) }} +{% elif npackages > 0 %} +{{ ngettext( "We are connecting {email_address} and the {package_name} npm package to the " + "{username} account on Gratipay. This is a notification sent to {email_address_2} " + "because that is the primary email address we have on file." + , "We are connecting {email_address} and {n} npm packages to the {username} account on " + "Gratipay. This is a notification sent to {email_address_2} because that is the " + "primary email address we have on file." + , n=npackages + , package_name=package_name + , username=username + , email_address=new_email + , email_address_2=email + ) }} +{% else %} +{{ _( "We are connecting {email_address} to the {username} account on Gratipay. This is a " + "notification sent to {email_address_2} because that is the primary email address we have " + "on file." + , username=username + , email_address=new_email + , email_address_2=email + ) }} + {% endif %} diff --git a/emails/verification.spt b/emails/verification.spt index 906c24a505..bdbc60c90a 100644 --- a/emails/verification.spt +++ b/emails/verification.spt @@ -1,17 +1,64 @@ {{ _("Connect to {0} on Gratipay?", username) }} [---] text/html -{{ _("We've received a request to connect {0} to the {1} account on Gratipay. Sound familiar?", - ('%s'|safe) % email, - ('{0}'|safe).format(username)) }} +{% if new_email_verified %} +{{ ngettext( "We've received a request to connect the {package_name} npm package to the " + "{username} account on Gratipay. Sound familiar?" + , "We've received a request to connect {n} npm packages to the {username} account " + "on Gratipay. Sound familiar?" + , n=npackages + , package_name=('{}'|safe).format(package_name) + , username=('{0}'|safe).format(username) + ) }} +{% elif npackages > 0 %} +{{ ngettext( "We've received a request to connect {email_address} and the {package_name} npm " + "package to the {username} account on Gratipay. Sound familiar?" + , "We've received a request to connect {email_address} and {n} npm packages to the " + "{username} account on Gratipay. Sound familiar?" + , n=npackages + , package_name=('{}'|safe).format(package_name) + , email_address=('{}'|safe).format(new_email) + , username=('{0}'|safe).format(username) + ) }} +{% else %} +{{ _( "We've received a request to connect {email_address} to the {username} account on Gratipay. " + "Sound familiar?" + , email_address=('{}'|safe).format(new_email) + , username=('{0}'|safe).format(username) + ) }} +{% endif %}

{{ _("Yes, proceed!") }} [---] text/plain -{{ _("We've received a request to connect {0} to the {1} account on Gratipay. Sound familiar?", - email, username) }} +{% if new_email_verified %} +{{ ngettext( "We've received a request to connect the {package_name} npm package to the " + "{username} account on Gratipay. Sound familiar?" + , "We've received a request to connect {n} npm packages to the {username} account " + "on Gratipay. Sound familiar?" + , n=npackages + , package_name=package_name + , username=username + ) }} +{% elif npackages > 0 %} +{{ ngettext( "We've received a request to connect {email_address} and the {package_name} npm " + "package to the {username} account on Gratipay. Sound familiar?" + , "We've received a request to connect {email_address} and {n} npm packages to the " + "{username} account on Gratipay. Sound familiar?" + , n=npackages + , package_name=package_name + , email_address=new_email + , username=username + ) }} +{% else %} +{{ _( "We've received a request to connect {email_address} to the {username} account on Gratipay. " + "Sound familiar?" + , email_address=new_email + , username=username + ) }} +{% endif %} -{{ _("Follow this link to finish connecting your email:") }} +{{ _("Follow this link to finish connecting:") }} {{ link }} diff --git a/emails/verification_notice.spt b/emails/verification_notice.spt deleted file mode 100644 index b48abd72ca..0000000000 --- a/emails/verification_notice.spt +++ /dev/null @@ -1,13 +0,0 @@ -{{ _("Connecting {0} to {1} on Gratipay.", new_email, username) }} - -[---] text/html -{{ _("We are connecting {0} to the {1} account on Gratipay. This is a notification " - "sent to {2} because that is the primary email address we have on file.", - ('%s'|safe) % new_email, - ('{0}'|safe).format(username), - ('%s'|safe) % email) }} - -[---] text/plain -{{ _("We are connecting {0} to the {1} account on Gratipay. This is a notification " - "sent to {2} because that is the primary email address we have on file.", - new_email, username, email) }} diff --git a/error.spt b/error.spt index d07b35a3e7..1c69853e8a 100644 --- a/error.spt +++ b/error.spt @@ -2,8 +2,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera from aspen.http import status_strings -from gratipay.utils import LazyResponse -from gratipay.utils.i18n import HTTP_ERRORS +from gratipay.utils.i18n import HTTP_ERRORS, LocalizedErrorResponse [----------------------------------------] @@ -19,7 +18,7 @@ try: except Exception as e: website.tell_sentry(e, state) -if isinstance(response, LazyResponse): +if isinstance(response, LocalizedErrorResponse): response.render_body(state) err = response.body if code == 500 and not err: diff --git a/gratipay/application.py b/gratipay/application.py index 1c14539935..4ac3db1888 100644 --- a/gratipay/application.py +++ b/gratipay/application.py @@ -7,6 +7,7 @@ from .cron import Cron from .models import GratipayDB from .payday_runner import PaydayRunner +from .project_review_repo import ProjectReviewRepo from .website import Website @@ -35,7 +36,6 @@ def __init__(self): wireup.base_url(website, env) wireup.secure_cookies(env) wireup.billing(env) - wireup.team_review(env) wireup.username_restrictions(website) wireup.load_i18n(website.project_root, tell_sentry) wireup.other_stuff(website, env) @@ -46,6 +46,7 @@ def __init__(self): self.install_periodic_jobs(website, env, db) self.website = website self.payday_runner = PaydayRunner(self) + self.project_review_repo = ProjectReviewRepo(env) def install_periodic_jobs(self, website, env, db): @@ -62,7 +63,7 @@ def add_event(self, c, type, payload): This is the function we use to capture interesting events that happen across the system in one place, the ``events`` table. - :param c: a :py:class:`Postres` or :py:class:`Cursor` instance + :param c: a :py:class:`Postgres` or :py:class:`Cursor` instance :param unicode type: an indicator of what type of event it is--either ``participant``, ``team`` or ``payday`` :param payload: an arbitrary JSON-serializable data structure; for ``participant`` type, diff --git a/gratipay/elsewhere/__init__.py b/gratipay/elsewhere/__init__.py index e125c46136..87e7524355 100644 --- a/gratipay/elsewhere/__init__.py +++ b/gratipay/elsewhere/__init__.py @@ -17,7 +17,7 @@ from requests_oauthlib import OAuth1Session, OAuth2Session from gratipay.elsewhere._extractors import not_available -from gratipay.utils import LazyResponse +from gratipay.utils.i18n import LocalizedErrorResponse ACTIONS = {'opt-in', 'connect'} @@ -137,13 +137,13 @@ def msg(_, to_age): return _("You've consumed your quota of requests, you can try again in {0}.", to_age(reset)) else: return _("You're making requests too fast, please try again later.") - raise LazyResponse(status, msg) + raise LocalizedErrorResponse(status, msg) if status != 200: log('{} api responded with {}:\n{}'.format(self.name, status, response.text) , level=logging.ERROR) msg = lambda _: _("{0} returned an error, please try again later.", self.display_name) - raise LazyResponse(502, msg) + raise LocalizedErrorResponse(502, msg) return response diff --git a/gratipay/exceptions.py b/gratipay/exceptions.py index 983a4a4f18..cece213bdc 100644 --- a/gratipay/exceptions.py +++ b/gratipay/exceptions.py @@ -4,7 +4,7 @@ from __future__ import print_function, unicode_literals -from aspen import Response +from gratipay.utils.i18n import LocalizedErrorResponse class ProblemChangingUsername(Exception): @@ -27,28 +27,37 @@ class UsernameAlreadyTaken(ProblemChangingUsername): msg = "The username '{}' is already taken." -class ProblemChangingEmail(Response): - def __init__(self, *args): - Response.__init__(self, 400, self.msg.format(*args)) +class ProblemChangingEmail(LocalizedErrorResponse): + pass class EmailAlreadyVerified(ProblemChangingEmail): - msg = "{} is already verified for this Gratipay account." + def lazy_body(self, _): + return _("You have already added and verified that address.") class EmailTaken(ProblemChangingEmail): - msg = "{} is already connected to a different Gratipay account." + def lazy_body(self, _): + return _("That address is already linked to a different Gratipay account.") class CannotRemovePrimaryEmail(ProblemChangingEmail): - msg = "You cannot remove your primary email address." + def lazy_body(self, _): + return _("You cannot remove your primary email address.") + +class EmailNotOnFile(ProblemChangingEmail): + def lazy_body(self, _): + return _("That email address is not on file for this package.") class EmailNotVerified(ProblemChangingEmail): - msg = "The email address '{}' is not verified." + def lazy_body(self, _): + return _("That email address is not verified.") class TooManyEmailAddresses(ProblemChangingEmail): - msg = "You've reached the maximum number of email addresses we allow." + def lazy_body(self, _): + return _("You've reached the maximum number of email addresses we allow.") -class Throttled(Exception): - msg = "You've initiated too many emails too quickly. Please try again in a minute or two." +class Throttled(LocalizedErrorResponse): + def lazy_body(self, _): + return _("You've initiated too many emails too quickly. Please try again in a minute or two.") class ProblemChangingNumber(Exception): @@ -78,3 +87,5 @@ def __str__(self): return "Negative balance not allowed in this context." class NotWhitelisted(Exception): pass +class NoPackages(Exception): pass +class NoTeams(Exception): pass diff --git a/gratipay/models/package/__init__.py b/gratipay/models/package/__init__.py index 571e2cb971..7b1a906035 100644 --- a/gratipay/models/package/__init__.py +++ b/gratipay/models/package/__init__.py @@ -3,13 +3,24 @@ from postgres.orm import Model +from .emails import Emails +from .team import Team + NPM = 'npm' # We are starting with a single package manager. If we see # traction we will expand. -class Package(Model): +class Package(Model, Emails, Team): """Represent a gratipackage. :-) + + Packages are entities on open source package managers; `npm + `_ is the only one we support so far. Each package + on npm has a page on Gratipay with an URL of the form ``/on/npm/foo/``. + Packages can be claimed by Gratipay participants, at which point we create + a :py:class:`~gratipay.models.team.Team` for them under the hood so they + can start accepting payments. + """ typname = 'packages' @@ -25,6 +36,31 @@ def __ne__(self, other): return self.id != other.id + @property + def url_path(self): + """The path part of the URL for this package on Gratipay. + """ + return '/on/{}/{}/'.format(self.package_manager, self.name) + + + @property + def remote_human_url(self): + """The URL for the main page for this package on its package manager. + """ + if self.package_manager == NPM: + return 'https://www.npmjs.com/package/{}'.format(self.name) + raise NotImplementedError() + + + @property + def remote_api_url(self): + """The main API URL for this package on its package manager. + """ + if self.package_manager == NPM: + return 'https://registry.npmjs.com/{}'.format(self.name) + raise NotImplementedError() + + # Constructors # ============ @@ -40,10 +76,3 @@ def from_names(cls, package_manager, name): """ return cls.db.one("SELECT packages.*::packages FROM packages " "WHERE package_manager=%s and name=%s", (package_manager, name)) - - - # Emails - # ====== - - def send_confirmation_email(self, address): - pass diff --git a/gratipay/models/package/emails.py b/gratipay/models/package/emails.py new file mode 100644 index 0000000000..32c38f3f51 --- /dev/null +++ b/gratipay/models/package/emails.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +from collections import OrderedDict + + +PRIMARY, VERIFIED, UNVERIFIED, UNLINKED, OTHER = \ + 'primary verified unverified unlinked other'.split() + + +class Emails(object): + """A :py:class:`~gratipay.models.package.Package` has emails associated + with it, which we use to verify package ownership. + """ + + def classify_emails_for_participant(self, participant): + """List email addresses on file for this package, classified by their + relevance to the participant in question. Returns a list of + ``(address, classification)`` string tuples, sorted by + classification as follows: + + - primary + - verified + - unverified + - unlinked + - other + + Emails are alphabetical within each classification. + + """ + package_emails_by_group = OrderedDict(( (PRIMARY, []) + , (VERIFIED, []) + , (UNVERIFIED, []) + , (UNLINKED, []) + , (OTHER, []) + )) + + group_by_participant_email = {} + for email in participant.get_emails(): + if email.address == participant.email_address: + group = PRIMARY + elif email.verified: + group = VERIFIED + else: + group = UNVERIFIED + group_by_participant_email[email.address] = group + + other_verified = self.db.all(''' + + SELECT address + FROM emails + WHERE verified is true + AND participant_id != %s + AND address = ANY((SELECT emails FROM packages WHERE id=%s)::text[]) + ORDER BY address ASC + + ''', (participant.id, self.id)) + + for email in sorted(self.emails): + group = group_by_participant_email.get(email) + if not group: + group = OTHER if email in other_verified else UNLINKED + package_emails_by_group[group].append(email) + + out = [] + for group in package_emails_by_group: + for email in package_emails_by_group[group]: + out.append((email, group)) + return out diff --git a/gratipay/models/package/team.py b/gratipay/models/package/team.py new file mode 100644 index 0000000000..7538f0e6f0 --- /dev/null +++ b/gratipay/models/package/team.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +import uuid + +from gratipay.models.team import Team as _Team + + +class Team(object): + """A :py:class:`~gratipay.models.package.Package` can have a + :py:class:`~gratipay.models.team.Team` associated with it. + """ + + @property + def team(self): + """A computed attribute, the :py:class:`~gratipay.models.team.Team` + linked to this package if there is one, otherwise ``None``. Makes a + database call. + """ + return self.load_team(self.db) + + + def load_team(self, cursor): + """Given a database cursor, return a + :py:class:`~gratipay.models.team.Team` if there is one linked to this + package, or ``None`` if not. + """ + return cursor.one( 'SELECT t.*::teams FROM teams t WHERE t.id=' + '(SELECT team_id FROM teams_to_packages tp WHERE tp.package_id=%s)' + , (self.id,) + ) + + + def get_or_create_linked_team(self, cursor, owner): + """Given a db cursor and a :py:class:`Participant`, return a + :py:class:`~gratipay.models.team.Team`. + """ + team = self.load_team(cursor) + if team: + return team + + def slug_options(): + yield self.name + for i in range(1, 10): + yield '{}-{}'.format(self.name, i) + yield uuid.uuid4().hex + + for slug in slug_options(): + if cursor.one('SELECT count(*) FROM teams WHERE slug=%s', (slug,)) == 0: + break + + team = _Team.insert( slug=slug + , slug_lower=slug.lower() + , name=slug + , homepage='https://www.npmjs.com/package/' + self.name + , product_or_service=self.description + , owner=owner + , _cursor=cursor + ) + cursor.run('INSERT INTO teams_to_packages (team_id, package_id) ' + 'VALUES (%s, %s)', (team.id, self.id)) + self.app.add_event( cursor + , 'package' + , dict(id=self.id, action='link', values=dict(team_id=team.id)) + ) + return team + + + def unlink_team(self, cursor): + """Given a db cursor, unlink the team associated with this package + (it's a bug if called with no team linked). + """ + team = self.load_team(cursor) + assert team is not None # sanity check + cursor.run('DELETE FROM teams_to_packages WHERE package_id=%s', (self.id,)) + self.app.add_event( cursor + , 'package' + , dict(id=self.id, action='unlink', values=dict(team_id=team.id)) + ) diff --git a/gratipay/models/participant/email.py b/gratipay/models/participant/email.py index 38ca03bc32..4cb3ac451d 100644 --- a/gratipay/models/participant/email.py +++ b/gratipay/models/participant/email.py @@ -9,7 +9,7 @@ import gratipay from gratipay.exceptions import EmailAlreadyVerified, EmailTaken, CannotRemovePrimaryEmail -from gratipay.exceptions import EmailNotVerified, TooManyEmailAddresses +from gratipay.exceptions import EmailNotVerified, TooManyEmailAddresses, EmailNotOnFile, NoPackages from gratipay.security.crypto import constant_time_compare from gratipay.utils import encode_for_querystring @@ -41,140 +41,291 @@ class Email(object): """ - def add_email(self, email): + def start_email_verification(self, email, *packages): """Add an email address for a participant. This is called when adding a new email address, and when resending the verification email for an unverified email address. :param unicode email: the email address to add + :param gratipay.models.package.Package packages: packages to optionally + also verify ownership of :returns: ``None`` :raises EmailAlreadyVerified: if the email is already verified for - this participant + this participant (unless they're claiming packages) :raises EmailTaken: if the email is verified for a different participant + :raises EmailNotOnFile: if the email address is not on file for any of + the packages :raises TooManyEmailAddresses: if the participant already has 10 emails :raises Throttled: if the participant adds too many emails too quickly """ + with self.db.get_cursor() as c: + self.validate_email_verification_request(c, email, *packages) + link = self.get_email_verification_link(c, email, *packages) + + verified_emails = self.get_verified_email_addresses() + kwargs = dict( npackages=len(packages) + , package_name=packages[0].name if packages else '' + , new_email=email + , new_email_verified=email in verified_emails + , link=link + , include_unsubscribe=False + ) + self.app.email_queue.put(self, 'verification', email=email, **kwargs) + if self.email_address and self.email_address != email: + self.app.email_queue.put( self + , 'verification-notice' + + # Don't count this one against their sending quota. + # It's going to their own verified address, anyway. + , _user_initiated=False + + , **kwargs + ) + - # Check that this address isn't already verified - owner = self.db.one(""" - SELECT p.username - FROM emails e INNER JOIN participants p - ON e.participant_id = p.id - WHERE e.address = %(email)s - AND e.verified IS true - """, locals()) - if owner: - if owner == self.username: - raise EmailAlreadyVerified(email) + def validate_email_verification_request(self, c, email, *packages): + """Given a cursor, email, and packages, return ``None`` or raise. + """ + if not all(email in p.emails for p in packages): + raise EmailNotOnFile() + + owner_id = c.one(""" + SELECT participant_id + FROM emails + WHERE address = %(email)s + AND verified IS true + """, dict(email=email)) + + if owner_id: + if owner_id != self.id: + raise EmailTaken() + elif packages: + pass # allow reverify if claiming packages else: - raise EmailTaken(email) + raise EmailAlreadyVerified() if len(self.get_emails()) > 9: - raise TooManyEmailAddresses(email) + if owner_id and owner_id == self.id and packages: + pass # they're using an already-verified email to verify packages + else: + raise TooManyEmailAddresses() + + + def get_email_verification_link(self, c, email, *packages): + """Get a link to complete an email verification workflow. + + :param Cursor c: the cursor to use + :param unicode email: the email address to be verified + + :param packages: :py:class:`~gratipay.models.package.Package` objects + for which a successful verification will also entail verification of + ownership of the package + :returns: a URL by which to complete the verification process + + """ + self.app.add_event( c + , 'participant' + , dict(id=self.id, action='add', values=dict(email=email)) + ) + nonce = self.get_email_verification_nonce(c, email) + if packages: + self.start_package_claims(c, nonce, *packages) + link = "{base_url}/~{username}/emails/verify.html?email2={encoded_email}&nonce={nonce}" + return link.format( base_url=gratipay.base_url + , username=self.username_lower + , encoded_email=encode_for_querystring(email) + , nonce=nonce + ) + + + def get_email_verification_nonce(self, c, email): + """Given a cursor and email address, return a verification nonce. + """ nonce = str(uuid.uuid4()) - verification_start = utcnow() - - try: - with self.db.get_cursor() as c: - self.app.add_event(c, 'participant', dict(id=self.id, action='add', values=dict(email=email))) - c.run(""" - INSERT INTO emails - (address, nonce, verification_start, participant_id) - VALUES (%s, %s, %s, %s) - """, (email, nonce, verification_start, self.id)) - except IntegrityError: - nonce = self.db.one(""" + existing = c.one( 'SELECT * FROM emails WHERE address=%s AND participant_id=%s' + , (email, self.id) + ) # can't use eafp here because of cursor error handling + + if existing is None: + + # Not in the table yet. This should throw an IntegrityError if the + # address is verified for a different participant. + + c.run( "INSERT INTO emails (participant_id, address, nonce) VALUES (%s, %s, %s)" + , (self.id, email, nonce) + ) + else: + + # Already in the table. Restart verification. Henceforth, old links + # will fail. + + if existing.nonce: + c.run('DELETE FROM claims WHERE nonce=%s', (existing.nonce,)) + c.run(""" UPDATE emails - SET verification_start=%s + SET nonce=%s + , verification_start=now() WHERE participant_id=%s AND address=%s - AND verified IS NULL - RETURNING nonce - """, (verification_start, self.id, email)) - if not nonce: - return self.add_email(email) - - base_url = gratipay.base_url - username = self.username_lower - encoded_email = encode_for_querystring(email) - link = "{base_url}/~{username}/emails/verify.html?email2={encoded_email}&nonce={nonce}" - self.app.email_queue.put( self - , 'verification' - , email=email - , link=link.format(**locals()) - , include_unsubscribe=False - ) - if self.email_address: - self.app.email_queue.put( self - , 'verification_notice' - , new_email=email - , include_unsubscribe=False + """, (nonce, self.id, email)) - # Don't count this one against their sending quota. - # It's going to their own verified address, anyway. - , _user_initiated=False - ) + return nonce - def update_email(self, email): - """Set the email address for the participant. + def start_package_claims(self, c, nonce, *packages): + """Takes a cursor, nonce and list of packages, inserts into ``claims`` + and returns ``None`` (or raise :py:exc:`NoPackages`). """ - if not getattr(self.get_email(email), 'verified', False): - raise EmailNotVerified(email) - username = self.username - with self.db.get_cursor() as c: - self.app.add_event(c, 'participant', dict(id=self.id, action='set', values=dict(primary_email=email))) - c.run(""" - UPDATE participants - SET email_address=%(email)s - WHERE username=%(username)s - """, locals()) + if not packages: + raise NoPackages() + + # We want to make a single db call to insert all claims, so we need to + # do a little SQL construction. Do it in such a way that we still avoid + # Python string interpolation (~= SQLi vector). + + extra_sql, values = [], [] + for p in packages: + extra_sql.append('(%s, %s)') + values += [nonce, p.id] + c.run('INSERT INTO claims (nonce, package_id) VALUES' + ', '.join(extra_sql), values) + self.app.add_event( c + , 'participant' + , dict( id=self.id + , action='start-claim' + , values=dict(package_ids=[p.id for p in packages]) + ) + ) + + + def set_primary_email(self, email, cursor=None): + """Set the primary email address for the participant. + """ + if cursor: + self._set_primary_email(email, cursor) + else: + with self.db.get_cursor() as cursor: + self._set_primary_email(email, cursor) self.set_attributes(email_address=email) - def verify_email(self, email, nonce): - if '' in (email, nonce): + def _set_primary_email(self, email, cursor): + if not getattr(self.get_email(email, cursor), 'verified', False): + raise EmailNotVerified() + self.app.add_event( cursor + , 'participant' + , dict(id=self.id, action='set', values=dict(primary_email=email)) + ) + cursor.run(""" + UPDATE participants + SET email_address=%(email)s + WHERE username=%(username)s + """, dict(email=email, username=self.username)) + + + def finish_email_verification(self, email, nonce): + if '' in (email.strip(), nonce.strip()): return VERIFICATION_MISSING - r = self.get_email(email) - if r is None: - return VERIFICATION_FAILED - if r.verified: - assert r.nonce is None # and therefore, order of conditions matters - return VERIFICATION_REDUNDANT - if not constant_time_compare(r.nonce, nonce): - return VERIFICATION_FAILED - if (utcnow() - r.verification_start) > EMAIL_HASH_TIMEOUT: - return VERIFICATION_EXPIRED - try: - self.db.run(""" - UPDATE emails - SET verified=true, verification_end=now(), nonce=NULL - WHERE participant_id=%s - AND address=%s - AND verified IS NULL - """, (self.id, email)) - except IntegrityError: - return VERIFICATION_STYMIED + with self.db.get_cursor() as cursor: + record = self.get_email(email, cursor, and_lock=True) + if record is None: + return VERIFICATION_FAILED + packages = self.get_packages_claiming(cursor, nonce) + if record.verified and not packages: + assert record.nonce is None # and therefore, order of conditions matters + return VERIFICATION_REDUNDANT + if not constant_time_compare(record.nonce, nonce): + return VERIFICATION_FAILED + if (utcnow() - record.verification_start) > EMAIL_HASH_TIMEOUT: + return VERIFICATION_EXPIRED + try: + if packages: + self.finish_package_claims(cursor, nonce, *packages) + self.save_email_address(cursor, email) + except IntegrityError: + return VERIFICATION_STYMIED + return VERIFICATION_SUCCEEDED + + + def get_packages_claiming(self, cursor, nonce): + """Given a nonce, return :py:class:`~gratipay.models.package.Package` + objects associated with it. + """ + return cursor.all(""" + SELECT p.*::packages + FROM packages p + JOIN claims c + ON p.id = c.package_id + WHERE c.nonce=%s + """, (nonce,)) - if not self.email_address: - self.update_email(email) - return VERIFICATION_SUCCEEDED + def save_email_address(self, cursor, address): + """Given an email address, modify the database. + + This is where we actually mark the email address as verified. + Additionally, we clear out any competing claims to the same address. - def get_email(self, email): - """Return a record for a single email address on file for this participant. """ - return self.db.one(""" - SELECT * - FROM emails + cursor.run(""" + UPDATE emails + SET verified=true, verification_end=now(), nonce=NULL WHERE participant_id=%s AND address=%s - """, (self.id, email)) + AND verified IS NULL + """, (self.id, address)) + cursor.run(""" + DELETE + FROM emails + WHERE participant_id != %s + AND address=%s + """, (self.id, address)) + if not self.email_address: + self.set_primary_email(address, cursor) + + + def finish_package_claims(self, cursor, nonce, *packages): + """Create teams if needed and associate them with the packages. + """ + if not packages: + raise NoPackages() + + package_ids, teams, team_ids = [], [], [] + for package in packages: + package_ids.append(package.id) + team = package.get_or_create_linked_team(cursor, self) + teams.append(team) + team_ids.append(team.id) + review_url = self.app.project_review_repo.create_issue(*teams) + + cursor.run('DELETE FROM claims WHERE nonce=%s', (nonce,)) + cursor.run('UPDATE teams SET review_url=%s WHERE id=ANY(%s)', (review_url, team_ids,)) + self.app.add_event( cursor + , 'participant' + , dict( id=self.id + , action='finish-claim' + , values=dict(package_ids=package_ids) + ) + ) + + + def get_email(self, address, cursor=None, and_lock=False): + """Return a record for a single email address on file for this participant. + + :param unicode address: the email address for which to get a record + :param Cursor cursor: a database cursor; if ``None``, we'll use ``self.db`` + :param and_lock: if True, we will acquire a write-lock on the email record before returning + :returns: a database record (a named tuple) + + """ + sql = 'SELECT * FROM emails WHERE participant_id=%s AND address=%s' + if and_lock: + sql += ' FOR UPDATE' + return (cursor or self.db).one(sql, (self.id, address)) def get_emails(self): @@ -202,7 +353,10 @@ def remove_email(self, address): if address == self.email_address: raise CannotRemovePrimaryEmail() with self.db.get_cursor() as c: - self.app.add_event(c, 'participant', dict(id=self.id, action='remove', values=dict(email=address))) + self.app.add_event( c + , 'participant' + , dict(id=self.id, action='remove', values=dict(email=address)) + ) c.run("DELETE FROM emails WHERE participant_id=%s AND address=%s", (self.id, address)) diff --git a/gratipay/models/team/__init__.py b/gratipay/models/team/__init__.py index 4f371a3d1e..25fc02ecf5 100644 --- a/gratipay/models/team/__init__.py +++ b/gratipay/models/team/__init__.py @@ -5,16 +5,17 @@ import re from decimal import Decimal -import requests -from aspen import json, log -from gratipay.exceptions import InvalidTeamName +from aspen import Response from postgres.orm import Model from .available import Available from .closing import Closing from .membership import Membership +from .package import Package from .takes import Takes from .tip_migration import TipMigration +from ...exceptions import InvalidTeamName +from ...utils import canonicalize # Should have at least one letter. @@ -36,7 +37,7 @@ def slugize(name): return slug -class Team(Model, Available, Closing, Membership, Takes, TipMigration): +class Team(Model, Available, Closing, Membership, Package, Takes, TipMigration): """Represent a Gratipay team. """ @@ -70,6 +71,13 @@ def __ne__(self, other): nreceiving_from = 0 + @property + def url_path(self): + """The path part of the URL for this team on Gratipay. + """ + return '/{}/'.format(self.slug) + + # Constructors # ============ @@ -98,9 +106,10 @@ def _from_thing(cls, thing, value): @classmethod def insert(cls, owner, **fields): + cursor = fields.pop('_cursor') if '_cursor' in fields else None fields['slug_lower'] = fields['slug'].lower() fields['owner'] = owner.username - return cls.db.one(""" + return (cursor or cls.db).one(""" INSERT INTO teams (slug, slug_lower, name, homepage, @@ -170,8 +179,11 @@ def get_payment_distribution(self): def update(self, **kw): - updateable = frozenset(['name', 'product_or_service', 'homepage', - 'onboarding_url']) + if self.package: + updateable = frozenset(['name', 'product_or_service', 'onboarding_url']) + else: + updateable = frozenset(['name', 'product_or_service', 'homepage', + 'onboarding_url']) cols, vals = zip(*kw.items()) assert set(cols).issubset(updateable) @@ -236,30 +248,6 @@ def get_upcoming_payment(self): """, {'team_id': self.id, 'mcharge': MINIMUM_CHARGE}) - def create_github_review_issue(self): - """POST to GitHub, and return the URL of the new issue. - """ - api_url = "https://api.github.com/repos/{}/issues".format(self.review_repo) - data = json.dumps({ "title": self.name - , "body": "https://gratipay.com/{}/\n\n".format(self.slug) + - "(This application will remain open for at least a week.)" - }) - out = '' - try: - r = requests.post(api_url, auth=self.review_auth, data=data) - if r.status_code == 201: - out = r.json()['html_url'] - else: - log(r.status_code) - log(r.text) - err = str(r.status_code) - except: - err = "eep" - if not out: - out = "https://github.com/gratipay/team-review/issues#error-{}".format(err) - return out - - def set_review_url(self, review_url): self.db.run("UPDATE teams SET review_url=%s WHERE id=%s", (review_url, self.id)) self.set_attributes(review_url=review_url) @@ -306,6 +294,7 @@ def update_receiving(self, cursor=None): , ndistributing_to=r.ndistributing_to ) + @property def status(self): return { None: 'unreviewed' @@ -313,6 +302,7 @@ def status(self): , True: 'approved' }[self.is_approved] + def to_dict(self): return { 'homepage': self.homepage, @@ -367,3 +357,35 @@ def load_image(self, size): with self.db.get_connection() as c: image = c.lobject(oid, mode='rb').read() return image + + +def cast(path_part, state): + """This is an Aspen typecaster. Given a slug and a state dict, raise + Response or return Team. + """ + redirect = state['website'].redirect + request = state['request'] + user = state['user'] + slug = path_part + qs = request.line.uri.querystring + + try: + team = Team.from_slug(slug) + except: + raise Response(400, 'bad slug') + + if team is None: + # Try to redirect to a Participant. + from gratipay.models.participant import Participant # avoid circular import + participant = Participant.from_username(slug) + if participant is not None: + qs = '?' + request.qs.raw if request.qs.raw else '' + redirect('/~' + request.path.raw[1:] + qs) + raise Response(404) + + canonicalize(redirect, request.line.uri.path.raw, '/', team.slug, slug, qs) + + if team.is_closed and not user.ADMIN: + raise Response(410) + + return team diff --git a/gratipay/models/team/closing.py b/gratipay/models/team/closing.py index 74a62c9b83..ae8530bfd4 100644 --- a/gratipay/models/team/closing.py +++ b/gratipay/models/team/closing.py @@ -21,3 +21,5 @@ def close(self): , dict(id=self.id, action='set', values=dict(is_closed=True)) ) self.set_attributes(is_closed=True) + if self.package: + self.package.unlink_team(cursor) diff --git a/gratipay/models/team/package.py b/gratipay/models/team/package.py new file mode 100644 index 0000000000..02c8c4cd17 --- /dev/null +++ b/gratipay/models/team/package.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + + +class Package(object): + """A :py:class:`~gratipay.models.team.Team` can have a + :py:class:`~gratipay.models.package.Package` associated with it. + Linking/unlinking API is over on ``Package``. + """ + + @property + def package(self): + """A computed attribute, the + :py:class:`~gratipay.models.package.Package` linked to this team if + there is one, otherwise ``None``. Makes a database call. + """ + return self.load_package(self.db) + + + def load_package(self, cursor): + """Given a database cursor, return a + :py:class:`~gratipay.models.package.Package` if there is one linked to + this team, or ``None`` if not. + """ + return cursor.one( 'SELECT p.*::packages FROM packages p WHERE p.id=' + '(SELECT package_id FROM teams_to_packages tp WHERE tp.team_id=%s)' + , (self.id,) + ) diff --git a/gratipay/project_review_repo.py b/gratipay/project_review_repo.py new file mode 100644 index 0000000000..50b07c0f2b --- /dev/null +++ b/gratipay/project_review_repo.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import pprint +import requests +import sys + +from aspen import log + +from gratipay.exceptions import NoTeams + + +class ProjectReviewRepo(object): + + def __init__(self, env): + repo = env.project_review_repo + auth = (env.project_review_username, env.project_review_token) + self._poster = GitHubPoster(repo, auth) if repo else ConsolePoster() + + + def create_issue(self, *teams): + """Given team objects, POST to GitHub, and return the URL of the new issue. + """ + if not teams: + raise NoTeams() + nteams = len(teams) + if nteams == 1: + title = teams[0].name + elif nteams == 2: + title = "{} and {}".format(*[t.name for t in teams]) + else: + title = "{} and {} other projects".format(teams[0].name, nteams-1) + body = [] + for team in teams: + body.append('https://gratipay.com{}'.format(team.url_path)) + body.extend(['', '(This application will remain open for at least a week.)']) + data = json.dumps({'title': title, 'body': '\n'.join(body)}) + return self._poster.post(data) + + +class GitHubPoster(object): + """Sends data to GitHub. + """ + + def __init__(self, repo, auth): + self.repo = repo + self.api_url = "https://api.github.com/repos/{}/issues".format(repo) + self.auth = auth + + def post(self, data): + out = '' + try: + r = requests.post(self.api_url, auth=self.auth, data=data) + if r.status_code == 201: + out = r.json()['html_url'] + else: + log(r.status_code) + log(r.text) + err = str(r.status_code) + except: + err = "eep" + if not out: + out = "https://github.com/{}/issues#error-{}".format(self.repo, err) + return out + + +class ConsolePoster(object): + """Dumps data to stdout. + """ + + def __init__(self, fp=sys.stdout): + self.fp = fp + + def post(self, data): + p = lambda *a, **kw: print(*a, file=self.fp) + p('-'*78,) + p(pprint.pformat(json.loads(data))) + p('-'*78) + return 'some-github-issue' diff --git a/gratipay/testing/browser.py b/gratipay/testing/browser.py index 243809251f..f62dff3e55 100644 --- a/gratipay/testing/browser.py +++ b/gratipay/testing/browser.py @@ -108,7 +108,9 @@ def wait_for(self, selector, timeout=2): end_time = time.time() + timeout while time.time() < end_time: if self.has_element(selector): - return self.find_by_css(selector) + element = self.find_by_css(selector) + if element.visible: + return element raise NeverShowedUp(selector) def wait_for_notification(self, type='notice'): diff --git a/gratipay/testing/harness.py b/gratipay/testing/harness.py index 5a14253dad..b8a7f09b09 100644 --- a/gratipay/testing/harness.py +++ b/gratipay/testing/harness.py @@ -17,8 +17,9 @@ from gratipay.exceptions import NoSelfTipping, NoTippee, BadAmount from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.models.exchange_route import ExchangeRoute -from gratipay.models.team import Team +from gratipay.models.package import NPM, Package from gratipay.models.participant import Participant, MAX_TIP, MIN_TIP +from gratipay.models.team import Team from gratipay.security import user from gratipay.testing.vcr import use_cassette from psycopg2 import IntegrityError, InternalError @@ -200,14 +201,15 @@ def make_team(self, *a, **kw): return team - def make_package(self, package_manager='npm', name='foo', description='Foo', + def make_package(self, package_manager=NPM, name='foo', description='Foo fooingly.', emails=['alice@example.com']): """Factory for packages. """ - return self.db.one( 'INSERT INTO packages (package_manager, name, description, emails) ' - 'VALUES (%s, %s, %s, %s) RETURNING *' - , (package_manager, name, description, emails) - ) + self.db.run( 'INSERT INTO packages (package_manager, name, description, emails) ' + 'VALUES (%s, %s, %s, %s) RETURNING *' + , (package_manager, name, description, emails) + ) + return Package.from_names(NPM, name) def make_participant(self, username, **kw): @@ -387,3 +389,12 @@ def get_tip(self, tipper, tippee): LIMIT 1 """, (tipper, tippee), back_as=dict, default=default)['amount'] + + + def add_and_verify_email(self, participant, *emails): + """Given a participant and some email addresses, add and verify them. + """ + for email in emails: + participant.start_email_verification(email) + nonce = participant.get_email(email).nonce + participant.finish_email_verification(email, nonce) diff --git a/gratipay/typecasting.py b/gratipay/typecasting.py new file mode 100644 index 0000000000..8300d73cf6 --- /dev/null +++ b/gratipay/typecasting.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Fork of aspen.typecasting to clean some stuff up. Upstream it, ya? +""" +from __future__ import absolute_import, division, print_function, unicode_literals + +from dependency_injection import resolve_dependencies + + +def cast(website, request, state): + """Implement typecasting (differently from stock Aspen). + + When matching paths, Aspen looks for ``/%foo/`` and then foo is a variable + with the value in the URL path, so ``/bar/`` would end up with + ``foo='bar'``. + + There's a dictionary at ``website.typecasters`` that maps variable names to + functions, dependency-injectable as with ``website.algorithm`` + (state-chain) functions. If an entry exists in ``typecasters`` for a given + path variable, then the value of ``path[part]`` is replaced with the result + of calling the function. + + Before calling your cast function, we add an additional value to the state + dict at ``path_part``: the URL path part that matched, as a string. That is + user input, so handle it carefully. It's your job to raise + ``Response(40x)`` if it's bad input. + + """ + typecasters = website.typecasters + path = request.line.uri.path + + for part in path.keys(): + if part not in typecasters: + continue + state['path_part'] = path[part] + path.popall(part) + func = typecasters[part] + path[part] = func(*resolve_dependencies(func, state).as_args) diff --git a/gratipay/utils/__init__.py b/gratipay/utils/__init__.py index 5dfaf47d95..5faa0632e6 100644 --- a/gratipay/utils/__init__.py +++ b/gratipay/utils/__init__.py @@ -9,7 +9,6 @@ from aspen import Response, json from aspen.utils import to_rfc822, utcnow -from dependency_injection import resolve_dependencies from postgres.cursors import SimpleCursorBase import gratipay @@ -35,6 +34,15 @@ def dict_to_querystring(mapping): def _munge(website, request, url_prefix, fs_prefix): + """Given website and requests objects along with URL and filesystem + prefixes, redirect or modify the request. The idea here is that sometimes + for various reasons the dispatcher can't handle a mapping, so this is a + hack to rewrite the URL to help the dispatcher map to the filesystem. + + If you access the filesystem version directly through the web, we redirect + you to the URL version. If you access the URL version as desired, then we + rewrite so we can find it on the filesystem. + """ if request.path.raw.startswith(fs_prefix): to = url_prefix + request.path.raw[len(fs_prefix):] if request.qs.raw: @@ -109,35 +117,6 @@ def get_participant(state, restrict=True, resolve_unclaimed=True): return participant -def get_team(state): - """Given a Request, raise Response or return Team. - """ - redirect = state['website'].redirect - request = state['request'] - user = state['user'] - slug = request.line.uri.path['team'] - qs = request.line.uri.querystring - - from gratipay.models.team import Team # avoid circular import - team = Team.from_slug(slug) - - if team is None: - # Try to redirect to a Participant. - from gratipay.models.participant import Participant # avoid circular import - participant = Participant.from_username(slug) - if participant is not None: - qs = '?' + request.qs.raw if request.qs.raw else '' - redirect('/~' + request.path.raw[1:] + qs) - raise Response(404) - - canonicalize(redirect, request.line.uri.path.raw, '/', team.slug, slug, qs) - - if team.is_closed and not user.ADMIN: - raise Response(410) - - return team - - def encode_for_querystring(s): """Given a unicode, return a unicode that's safe for transport across a querystring. """ @@ -263,17 +242,6 @@ def to_javascript(obj): return json.dumps(obj).replace('".format(self.__class__.__name__, self) + + def __init__(self, code=400, lazy_body=None, **kw): + Response.__init__(self, code, '', **kw) + if lazy_body: + self.lazy_body = lazy_body + + def render_body(self, state): + f = self.lazy_body + self.body = f(*resolve_dependencies(f, state).as_args) diff --git a/gratipay/utils/icons.py b/gratipay/utils/icons.py index b3517e7797..66c233208d 100644 --- a/gratipay/utils/icons.py +++ b/gratipay/utils/icons.py @@ -1,5 +1,11 @@ -STATUS_ICONS = { "approved": "" - , "unreviewed": "" - , "rejected": "" - , "featured": "" +STATUS_ICONS = { "success": "" + , "warning": "" + , "failure": "" + , "feature": "" } + +REVIEW_MAP = { 'approved': 'success' + , 'unreviewed': 'warning' + , 'rejected': 'failure' + , 'featured': 'feature' + } diff --git a/gratipay/website.py b/gratipay/website.py index 558ce2eaa7..c870512738 100644 --- a/gratipay/website.py +++ b/gratipay/website.py @@ -6,10 +6,11 @@ import aspen from aspen.website import Website as BaseWebsite -from . import utils, security, version +from . import utils, security, typecasting, version from .security import authentication, csrf from .utils import erase_cookie, http_caching, i18n, set_cookie, set_version_header, timer from .renderers import csv_dump, jinja2_htmlescaped, eval_, scss +from .models import team class Website(BaseWebsite): @@ -20,6 +21,7 @@ def __init__(self, app): BaseWebsite.__init__(self) self.app = app self.version = version.get_version() + self.configure_typecasters() self.configure_renderers() # TODO Can't do remaining config here because of lingering wireup @@ -35,6 +37,10 @@ def init_even_more(self): self.monkey_patch_response() + def configure_typecasters(self): + self.typecasters['team'] = team.cast + + def configure_renderers(self): self.renderer_default = 'unspecified' # require explicit renderer, to avoid escaping bugs @@ -86,7 +92,7 @@ def modify_algorithm(self, tell_sentry): http_caching.get_etag_for_file if self.cache_static else noop, http_caching.try_to_serve_304 if self.cache_static else noop, - algorithm['apply_typecasters_to_path'], + typecasting.cast, algorithm['get_resource_for_request'], algorithm['extract_accept_from_request'], algorithm['get_response_for_resource'], diff --git a/gratipay/wireup.py b/gratipay/wireup.py index fa0c140711..ed2eff02fb 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -31,7 +31,6 @@ from gratipay.elsewhere.venmo import Venmo from gratipay.models.account_elsewhere import AccountElsewhere from gratipay.models.participant import Participant, Identity -from gratipay.models.team import Team from gratipay.security.crypto import EncryptingPacker from gratipay.utils import find_files from gratipay.utils.http_caching import asset_etag @@ -79,11 +78,6 @@ def billing(env): ) -def team_review(env): - Team.review_repo = env.team_review_repo - Team.review_auth = (env.team_review_username, env.team_review_token) - - def username_restrictions(website): gratipay.RESTRICTED_USERNAMES = os.listdir(website.www_root) @@ -391,9 +385,9 @@ def env(): SENTRY_DSN = unicode, LOG_METRICS = is_yesish, INCLUDE_PIWIK = is_yesish, - TEAM_REVIEW_REPO = unicode, - TEAM_REVIEW_USERNAME = unicode, - TEAM_REVIEW_TOKEN = unicode, + PROJECT_REVIEW_REPO = unicode, + PROJECT_REVIEW_USERNAME = unicode, + PROJECT_REVIEW_TOKEN = unicode, RAISE_SIGNIN_NOTIFICATIONS = is_yesish, REQUIRE_YAJL = is_yesish, GUNICORN_OPTS = unicode, @@ -417,7 +411,7 @@ def env(): aspen.log_dammit("See ./default_local.env for hints.") aspen.log_dammit("=" * 42) - keys = ', '.join([key for key in env.malformed]) + keys = ', '.join([key for key, value in env.malformed]) raise BadEnvironment("Malformed envvar{}: {}.".format(plural, keys)) if env.missing: diff --git a/img-src/package-default.svg b/img-src/package-default.svg new file mode 100644 index 0000000000..f764213876 --- /dev/null +++ b/img-src/package-default.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/js/gratipay/packages.js b/js/gratipay/packages.js index 675a0c4614..67ad49d1c3 100644 --- a/js/gratipay/packages.js +++ b/js/gratipay/packages.js @@ -1,9 +1,14 @@ Gratipay.packages = {}; +Gratipay.packages.init = function() { + Gratipay.Select('.gratipay-select'); + $('button.apply').on('click', Gratipay.packages.post); +}; + Gratipay.packages.post = function(e) { e.preventDefault(); var $this = $(this); - var action = 'add-email-and-claim-package'; + var action = 'start-verification'; var package_id = $('input[name=package_id]').val(); var email = $('input[name=email]:checked').val(); diff --git a/js/gratipay/select.js b/js/gratipay/select.js new file mode 100644 index 0000000000..25691a5c09 --- /dev/null +++ b/js/gratipay/select.js @@ -0,0 +1,117 @@ +Gratipay.Select = function(selector) { + var $ul = $('ul', selector); + var $labels = $('label', $ul); + + // state for vertical position + var topFactor = 0; // float between 0 and $labels.length-1 + var maxTopFactor = $labels.length - 1; + + // state for hovering + var hoverIndex = 0; // int between 0 and $labels.length-1 + var cursorOffset = 0; // negative or positive int + + function unhover() { + $(this).closest('li').removeClass('hover'); + } + + function hover() { + + // Implement hover here instead of depending on CSS, in order to get + // consistent behavior during scrolling. The :hover pseudoclass doesn't + // always fire after scrolling. + + var $label = $(this); + unhover.call($('.hover'), $ul); + $label.closest('li').addClass('hover'); + if ($ul.hasClass('open')) + cursorOffset = $labels.index($label) - Math.round(topFactor); + } + + function moveTo(t) { + $ul.css({'top': -64 * t}); + + var index = Math.round(t) + cursorOffset; + if (index !== hoverIndex) { + hoverIndex = index; + unhover.call($('.hover'), $ul); + + // Don't call the hover function, because we don't want to + // change cursorOffset. Just apply the class. + $labels.eq(hoverIndex).closest('li').addClass('hover'); + } + topFactor = t; + } + + function open($label) { + $ul.addClass('open'); + moveTo($labels.index($label)); + lockWindowScrolling(); + $ul.mousewheel(scroll); + } + + function close($label) { + if ($label) { + if ($label.closest('li').hasClass('disabled')) return; + $('.selected', $ul).removeClass('selected') + $label.closest('li').addClass('selected').removeClass('hover'); + } + $ul.css({'top': 0}).removeClass('open'); + $ul.unbind('mousewheel'); + unlockWindowScrolling(); + cursorOffset = 0; + } + + function select(e) { + ($ul.hasClass('open') ? close : open)($(this)); + } + + function clear(e) { + if (!$ul.hasClass('open')) return; + if ($ul.is(e.target) || $ul.has(e.target).length > 0) return; + close(); + } + + $('li label', $ul).click(select).hover(hover, unhover); + $('html').click(clear); + + + // http://stackoverflow.com/a/3656618 + + function lockWindowScrolling() { + var scrollPosition = [ + self.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft, + self.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop + ]; + var html = jQuery('html'); + html.data('scroll-position', scrollPosition); + html.data('previous-overflow', html.css('overflow')); + html.css('overflow', 'hidden'); + window.scrollTo(scrollPosition[0], scrollPosition[1]); + } + + function unlockWindowScrolling() { + var html = jQuery('html'); + var scrollPosition = html.data('scroll-position'); + html.css('overflow', html.data('previous-overflow')); + if (scrollPosition !== undefined) + window.scrollTo(scrollPosition[0], scrollPosition[1]) + } + + + // http://stackoverflow.com/a/23961723 + + var scrollThrottle = null; + function scroll(e) { + if (scrollThrottle !== null) return; + window.setTimeout(function() { + var t = topFactor, by = e.deltaY / 128; // divisor arrived at experimentally + by = by * e.deltaFactor; + t = t - by; + if (t < 0) t = 0; + if (t > maxTopFactor) t = maxTopFactor; + moveTo(t); + scrollThrottle = null; + }, 12); // timeout arrived at experimentally; needs to feel instant + // while suppressing extraneous scroll events + } +}; diff --git a/js/jquery.mousewheel.min.js b/js/jquery.mousewheel.min.js new file mode 100644 index 0000000000..03bfd60c5e --- /dev/null +++ b/js/jquery.mousewheel.min.js @@ -0,0 +1,8 @@ +/*! + * jQuery Mousewheel 3.1.13 + * + * Copyright 2015 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + */ +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof exports?module.exports=a:a(jQuery)}(function(a){function b(b){var g=b||window.event,h=i.call(arguments,1),j=0,l=0,m=0,n=0,o=0,p=0;if(b=a.event.fix(g),b.type="mousewheel","detail"in g&&(m=-1*g.detail),"wheelDelta"in g&&(m=g.wheelDelta),"wheelDeltaY"in g&&(m=g.wheelDeltaY),"wheelDeltaX"in g&&(l=-1*g.wheelDeltaX),"axis"in g&&g.axis===g.HORIZONTAL_AXIS&&(l=-1*m,m=0),j=0===m?l:m,"deltaY"in g&&(m=-1*g.deltaY,j=m),"deltaX"in g&&(l=g.deltaX,0===m&&(j=-1*l)),0!==m||0!==l){if(1===g.deltaMode){var q=a.data(this,"mousewheel-line-height");j*=q,m*=q,l*=q}else if(2===g.deltaMode){var r=a.data(this,"mousewheel-page-height");j*=r,m*=r,l*=r}if(n=Math.max(Math.abs(m),Math.abs(l)),(!f||f>n)&&(f=n,d(g,n)&&(f/=40)),d(g,n)&&(j/=40,l/=40,m/=40),j=Math[j>=1?"floor":"ceil"](j/f),l=Math[l>=1?"floor":"ceil"](l/f),m=Math[m>=1?"floor":"ceil"](m/f),k.settings.normalizeOffset&&this.getBoundingClientRect){var s=this.getBoundingClientRect();o=b.clientX-s.left,p=b.clientY-s.top}return b.deltaX=l,b.deltaY=m,b.deltaFactor=f,b.offsetX=o,b.offsetY=p,b.deltaMode=0,h.unshift(b,j,l,m),e&&clearTimeout(e),e=setTimeout(c,200),(a.event.dispatch||a.event.handle).apply(this,h)}}function c(){f=null}function d(a,b){return k.settings.adjustOldDeltas&&"mousewheel"===a.type&&b%120===0}var e,f,g=["wheel","mousewheel","DOMMouseScroll","MozMousePixelScroll"],h="onwheel"in document||document.documentMode>=9?["wheel"]:["mousewheel","DomMouseScroll","MozMousePixelScroll"],i=Array.prototype.slice;if(a.event.fixHooks)for(var j=g.length;j;)a.event.fixHooks[g[--j]]=a.event.mouseHooks;var k=a.event.special.mousewheel={version:"3.1.12",setup:function(){if(this.addEventListener)for(var c=h.length;c;)this.addEventListener(h[--c],b,!1);else this.onmousewheel=b;a.data(this,"mousewheel-line-height",k.getLineHeight(this)),a.data(this,"mousewheel-page-height",k.getPageHeight(this))},teardown:function(){if(this.removeEventListener)for(var c=h.length;c;)this.removeEventListener(h[--c],b,!1);else this.onmousewheel=null;a.removeData(this,"mousewheel-line-height"),a.removeData(this,"mousewheel-page-height")},getLineHeight:function(b){var c=a(b),d=c["offsetParent"in a.fn?"offsetParent":"parent"]();return d.length||(d=a("body")),parseInt(d.css("fontSize"),10)||parseInt(c.css("fontSize"),10)||16},getPageHeight:function(b){return a(b).height()},settings:{adjustOldDeltas:!0,normalizeOffset:!0}};a.fn.extend({mousewheel:function(a){return a?this.bind("mousewheel",a):this.trigger("mousewheel")},unmousewheel:function(a){return this.unbind("mousewheel",a)}})}); \ No newline at end of file diff --git a/scss/components/dropdown.scss b/scss/components/dropdown.scss index 7716c5c08b..c85ec519d0 100644 --- a/scss/components/dropdown.scss +++ b/scss/components/dropdown.scss @@ -29,7 +29,7 @@ button.dropdown-toggle{ width: 0; height: 0; vertical-align: top; - border-top: 4px solid #000000; + border-top: 4px solid $black; border-right: 4px solid transparent; border-left: 4px solid transparent; content: ""; diff --git a/scss/components/listing.scss b/scss/components/listing.scss index 85bd4686bc..54118b4934 100644 --- a/scss/components/listing.scss +++ b/scss/components/listing.scss @@ -1,9 +1,3 @@ -.sorry { - text-align: center; - font: normal 12px/15px $Ideal; - color: $medium-gray; -} - table.listing { width: 100%; @@ -27,17 +21,29 @@ table.listing { background: none !important; color: $green !important; } - img { + img.avatar { width: 48px; height: 48px; position: absolute; top: 8px; left: 0; } - .name { + .package-manager { + position: absolute; + top: 40px; + left: 32px; + width: 16px; + height: 16px; + border-radius: 2px 0 0 0; + background: white; + img { + width: 14px; + height: 14px; + margin: 2px 0 0 2px; + } + } + .listing-name { display: block; - color: $black; - font: bold 20px/24px $Ideal; position: absolute; z-index: 1; top: 0; @@ -46,7 +52,7 @@ table.listing { width: 100%; height: 100%; } - .details { + .listing-details { display: block; /* duplicate width/max-width from layouts#wrapper to get overflow to work */ @@ -58,7 +64,6 @@ table.listing { padding-left: 56px; - font: normal 12px/15px $Ideal; .owner a { color: $medium-gray; position: relative; @@ -72,10 +77,6 @@ table.listing { & * { display: inline; } } - a { - font-weight: normal; - text-decoration: underline; - } } &:hover { @@ -95,6 +96,6 @@ table.listing { } } } -.with-sidebar table.listing td.item .details { +.with-sidebar table.listing td.item .listing-details { max-width: 384px; } diff --git a/scss/components/nav.scss b/scss/components/nav.scss index f98e6c2567..c800830660 100644 --- a/scss/components/nav.scss +++ b/scss/components/nav.scss @@ -123,10 +123,6 @@ padding: 16px 16px 12px; font-weight: normal; - .icon { - font: normal 20px/20px 'icomoon'; - } - color: $black; border-bottom: 4px solid transparent; &:hover { diff --git a/scss/components/select.scss b/scss/components/select.scss new file mode 100644 index 0000000000..0f38900c9f --- /dev/null +++ b/scss/components/select.scss @@ -0,0 +1,103 @@ +.gratipay-select { + + position: relative; + height: 64px; + + ul { + + /* Opening and closing. */ + + li { + display: none; + &.selected { + display: block; + } + } + &.open { + li { + display: block; + } + .arrow { + display: none; + } + } + + .arrow { + display: block; + position: absolute; + z-index: 0; + right: 5px; + width: 0; + height: 0; + border-right: 5px solid transparent; + border-left: 5px solid transparent; + content: ""; + + &.up { + bottom: 22px; + border-top: 8px solid $light-gray; + } + &.down { + top: 22px; + border-bottom: 8px solid $light-gray; + } + } + li.hover .arrow { + &.up { border-top-color: $green; } + &.down { border-bottom-color: $green; } + } + + + /* Everything else. */ + + width: 66%; + background: white; + position: absolute; + left: 17%; + + border-radius: 5px; + box-shadow: 2px 2px 8px $black; + + li { + margin: 0; + list-style: none; + position: relative; + text-align: left; + height: 64px; + + input { + display: none; + } + .listing-wrapper { + height: 64px; + label { + white-space: nowrap; + display: block; + height: 64px; + position: absolute; + top: 0; + left: 0; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + padding: 12px 24px 0 56px; + z-index: 1; + } + .listing-details { + display: block; + padding: 36px 24px 0 56px; + white-space: nowrap; + } + } + &.hover:not(.disabled) label { + cursor: pointer; + } + &.disabled label { + color: $medium-gray ! important; + } + } + &.open .hover:not(.disabled) label { + color: $green ! important; + } + } +} diff --git a/scss/components/sign_in.scss b/scss/components/sign_in.scss index bab07799f0..4488ec5305 100644 --- a/scss/components/sign_in.scss +++ b/scss/components/sign_in.scss @@ -29,7 +29,7 @@ border: 0; box-shadow: none; background: $green; - min-width: 140px; + min-width: 140px; /* centering on unclaimed package page is based on this */ li { margin: 0; list-style: none; @@ -86,10 +86,10 @@ line-height: 16px; .caret { - border-top: 5px solid $black; + border-top: 8px solid $black; border-right: 5px solid transparent; border-left: 5px solid transparent; - margin-top: 6px; + margin-top: 4px; } } .dropdown-menu { diff --git a/scss/components/status-icon.scss b/scss/components/status-icon.scss index 9bd8835285..638c71d282 100644 --- a/scss/components/status-icon.scss +++ b/scss/components/status-icon.scss @@ -2,8 +2,8 @@ display: inline-block; width: 14px; font: normal 14px/14px icomoon; - &.approved { color: $green; } - &.unreviewed { color: $gold; } - &.rejected { color: $red; } - &.featured { color: $blue; } + &.success { color: $green; } + &.warning { color: $gold; } + &.failure { color: $red; } + &.feature { color: $blue; } } diff --git a/scss/components/text-treatments.scss b/scss/components/text-treatments.scss new file mode 100644 index 0000000000..feaa18b99b --- /dev/null +++ b/scss/components/text-treatments.scss @@ -0,0 +1,35 @@ +.icon { + font: normal 20px/20px 'icomoon'; +} + +#content { + .important-thing-at-the-top { + margin: 20px 0 60px; + } + .sorry { + text-align: center; + font: normal 12px/15px $Ideal; + color: $medium-gray; + } + .note { + font: normal 12px/15px $Ideal; + color: $medium-gray; + a { + font-weight: normal; + text-decoration: underline; + color: $medium-gray; + } + } + .listing-name { + color: $black; + font: bold 20px/24px $Ideal; + } + .listing-details { + color: $medium-gray; + font: normal 12px/15px $Ideal; + a { + font-weight: normal; + text-decoration: underline; + } + } +} diff --git a/scss/elements/buttons-knobs.scss b/scss/elements/buttons-knobs.scss index 3cf1222017..ce4e69d49d 100644 --- a/scss/elements/buttons-knobs.scss +++ b/scss/elements/buttons-knobs.scss @@ -27,27 +27,31 @@ button, display: inline; &:disabled { - opacity: .5; + background: $medium-gray; &:hover { - background: $gray; cursor: default; } } } -button:hover, -.button:hover { +button:hover:not(:disabled), +.button:hover:not(:disabled) { background: $darker-gray; } -button.selected, -.button.selected { +button.selected:not(:disabled), +.button.selected:not(:disabled) { background: $green; } -button.selected:hover, button.selected.drag, -.button.selected:hover, .button.selected.drag { +button.selected:hover:not(:disabled), button.selected.drag, +.button.selected:hover:not(:disabled), .button.selected.drag { background: $darker-green; } .buttons button { margin-top: 3px; } +.apply-button { + position: absolute; + top: 0; + right: 0; +} diff --git a/scss/layouts/layout.scss b/scss/layouts/layout.scss index dcba70290d..8df9e35d40 100644 --- a/scss/layouts/layout.scss +++ b/scss/layouts/layout.scss @@ -37,9 +37,6 @@ float: left; a#sign-out { padding-right: 8px; // tweak to make icon placement look right - .icon { - font: normal 20px/20px 'icomoon'; - } } } } diff --git a/scss/layouts/responsiveness.scss b/scss/layouts/responsiveness.scss index 9a3b9b1314..96fce23611 100644 --- a/scss/layouts/responsiveness.scss +++ b/scss/layouts/responsiveness.scss @@ -109,7 +109,7 @@ margin: 10px 0 40px; min-height: 0; } - #search form { + #content .important-thing-at-the-top { margin-top: 50px !important; /* compensate for smaller #main margin */ } #sidebar { diff --git a/scss/pages/homepage.scss b/scss/pages/homepage.scss index 8c0e8c4f3b..c12316d83e 100644 --- a/scss/pages/homepage.scss +++ b/scss/pages/homepage.scss @@ -17,10 +17,4 @@ margin-bottom: 10px; } } - - .apply { - position: absolute; - top: 0; - right: 0; - } } diff --git a/scss/pages/package.scss b/scss/pages/package.scss index d63f050ac8..a583951b7c 100644 --- a/scss/pages/package.scss +++ b/scss/pages/package.scss @@ -1,8 +1,64 @@ -#package { - .emails { - margin: 1em 0; - li { - list-style: none; +#package #content { + text-align: center; + + .instructions { + text-align: center; + margin: 0 0 30px; + font-family: $Ideal; + } + .description { + font: normal 18pt/24pt $Chronicle; + &.long { + font: normal 11pt/15pt $Chronicle; + } + padding: 0 17%; + } + + .selected .icon { display: block; } + .icon { + display: none; + position: absolute; + top: 12px; + left: 16px; + font-size: 24px; + line-height: 24px; + } + .disabled .icon { + color: $medium-gray; + } + + .status-icon { + font-size: 12px; + line-height: 12px; + width: auto; + margin: 0; + } + ul:not(.open) .status-icon { + color: $medium-gray; + } + + .button-wrapper { + margin: 38px auto 30px; + + button.large { + font: normal 16px/32px $Ideal; + padding: 10px 16px; + border-radius: 5px; + + .caret { + border-top: 8px solid #fff; + border-right: 5px solid transparent; + border-left: 5px solid transparent; + margin-top: 13px; + } + } + .dropdown-menu { + top: 50px; + width: 90%; + left: 5%; + border-radius: 0 0 3px 3px; } } + + } diff --git a/scss/pages/profile-edit.scss b/scss/pages/profile-edit.scss index c5714ba2d0..108fae4a69 100644 --- a/scss/pages/profile-edit.scss +++ b/scss/pages/profile-edit.scss @@ -12,23 +12,7 @@ font: normal 12px/12px $Ideal; } } - .cryptocoin { - .address { - font-size: 13px; - } - .edit > .not-empty, .delete { - display: none; - } - &.not-empty { - .empty { - display: none; - } - .edit > .not-empty, .delete { - display: inline-block; - } - } - .buttons { - margin-top: 3px; - } + .projects { + position: relative; } } diff --git a/scss/pages/search.scss b/scss/pages/search.scss index 850723ae7f..fd30e4d167 100644 --- a/scss/pages/search.scss +++ b/scss/pages/search.scss @@ -2,7 +2,6 @@ form { text-align: center; - margin: 20px 0 60px; button { font: normal 14pt/20pt $Ideal; padding: 4pt 10pt; diff --git a/scss/variables.scss b/scss/variables.scss index 2dd63e22fe..9f4029e2b8 100644 --- a/scss/variables.scss +++ b/scss/variables.scss @@ -1,7 +1,7 @@ // Fonts $Mono: Monaco, "Lucida Mono", monospace; -$Ideal: 'Ideal Sans SSm A', 'Ideal Sans SSm B', sans-serif; -$Chronicle: 'Chronicle SSm A', 'Chronicle SSm B', Georgia, serif; +$Ideal: 'Ideal Sans SSm A', 'Ideal Sans SSm B', 'Ideal Sans SSm', sans-serif; +$Chronicle: 'Chronicle SSm A', 'Chronicle SSm B', 'Chronicle SSm', Georgia, serif; // Colors $green: #396; diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..4052aa0ec0 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,17 @@ +BEGIN; + + ALTER TABLE emails ADD CONSTRAINT emails_nonce_key UNIQUE (nonce); + CREATE TABLE claims + ( nonce text NOT NULL REFERENCES emails(nonce) ON DELETE CASCADE + ON UPDATE RESTRICT + , package_id bigint NOT NULL REFERENCES packages(id) ON DELETE RESTRICT + ON UPDATE RESTRICT + , UNIQUE(nonce, package_id) + ); + + CREATE TABLE teams_to_packages + ( team_id bigint UNIQUE REFERENCES teams(id) ON DELETE RESTRICT + , package_id bigint UNIQUE REFERENCES packages(id) ON DELETE RESTRICT + ); + +END; diff --git a/templates/base.html b/templates/base.html index 7f9114db3a..23fbbdba6d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,3 +1,4 @@ +{% from "templates/sign-in-using.html" import sign_in_using with context %} @@ -41,7 +42,7 @@

  • diff --git a/templates/project-listing.html b/templates/project-listing.html index c03d965aa5..d114bd400a 100644 --- a/templates/project-listing.html +++ b/templates/project-listing.html @@ -2,15 +2,18 @@ {% for i, project in enumerate(project_list, start=1) %} - - {{ project.name }} + + {% if project.package %} +
    + {% endif %} + {{ project.name }} -
    +
    {{ i }} · {{ icons.STATUS_ICONS[project.status]|safe }}{{ icons.STATUS_ICONS[icons.REVIEW_MAP[project.status]]|safe }}{{ i18ned_statuses[project.status] }} {% if project.status == 'approved' %} diff --git a/templates/sign-in-using.html b/templates/sign-in-using.html index 11478fd5e2..be0e3fbacd 100644 --- a/templates/sign-in-using.html +++ b/templates/sign-in-using.html @@ -1,8 +1,11 @@ {% from 'templates/auth.html' import auth_button with context %} +{% macro sign_in_using(button_class='') %} +{% endmacro %} diff --git a/templates/team-base.html b/templates/team-base.html index 387015839c..d27fbd0d38 100644 --- a/templates/team-base.html +++ b/templates/team-base.html @@ -3,6 +3,9 @@ {% block banner %}
    + {% if team.package %} + + {% endif %}
    diff --git a/templates/your-payment.html b/templates/your-payment.html index 64cf2e5a80..e0a4408bca 100644 --- a/templates/your-payment.html +++ b/templates/your-payment.html @@ -1,6 +1,7 @@ +{% from "templates/sign-in-using.html" import sign_in_using with context %} {% if user.ANON %} {% else %} diff --git a/tests/py/test_close.py b/tests/py/test_close.py index b05e765358..43c68698b6 100644 --- a/tests/py/test_close.py +++ b/tests/py/test_close.py @@ -208,7 +208,7 @@ def test_clears_personal_information(self): , taking=40 ) alice.upsert_statement('en', 'not forgetting to be awesome!') - alice.add_email('alice@example.net') + alice.start_email_verification('alice@example.net') with self.db.get_cursor() as cursor: alice.clear_personal_information(cursor) diff --git a/tests/py/test_email.py b/tests/py/test_email.py index a6c7f64049..b686ccf5b1 100644 --- a/tests/py/test_email.py +++ b/tests/py/test_email.py @@ -1,14 +1,20 @@ from __future__ import absolute_import, division, print_function, unicode_literals import json +import Queue import sys +import threading +import urllib +import mock from pytest import raises from gratipay.exceptions import CannotRemovePrimaryEmail, EmailTaken, EmailNotVerified -from gratipay.exceptions import TooManyEmailAddresses, Throttled -from gratipay.testing import P +from gratipay.exceptions import TooManyEmailAddresses, Throttled, EmailAlreadyVerified +from gratipay.exceptions import EmailNotOnFile, ProblemChangingEmail +from gratipay.testing import P, Harness from gratipay.testing.email import QueuedEmailHarness, SentEmailHarness +from gratipay.models.package import Package from gratipay.models.participant import email as _email from gratipay.utils import encode_for_querystring from gratipay.cli import queue_branch_email as _queue_branch_email @@ -20,16 +26,42 @@ def setUp(self): QueuedEmailHarness.setUp(self) self.alice = self.make_participant('alice', claimed_time='now') + def add(self, participant, address, _flush=False): + participant.start_email_verification(address) + nonce = participant.get_email(address).nonce + r = participant.finish_email_verification(address, nonce) + assert r == _email.VERIFICATION_SUCCEEDED + if _flush: + self.app.email_queue.flush() + class TestEndpoints(Alice): - def hit_email_spt(self, action, address, user='alice', should_fail=False): + def hit_email_spt(self, action, address, user='alice', package_ids=[], should_fail=False): f = self.client.PxST if should_fail else self.client.POST - data = {'action': action, 'address': address} - headers = {b'HTTP_ACCEPT_LANGUAGE': b'en'} - return f('/~alice/emails/modify.json', data, auth_as=user, **headers) - def verify_email(self, email, nonce, username='alice', should_fail=False): + # Aspen's test client should really support URL-encoding POST data for + # us, but it doesn't (it only supports multipart, which I think maybe + # doesn't work because of other Aspen bugs around multiple package_id + # values in the same POST body in that case?), so let's do that + # ourselves. + + data = [ ('action', action) + , ('address', address) + ] + [('package_id', str(p)) for p in package_ids] + body = urllib.urlencode(data) + + response = f( '/~alice/emails/modify.json' + , body=body + , content_type=b'application/x-www-form-urlencoded' + , auth_as=user + , HTTP_ACCEPT_LANGUAGE=b'en' + ) + if issubclass(response.__class__, (Throttled, ProblemChangingEmail)): + response.render_body({'_': lambda a: a}) + return response + + def finish_email_verification(self, email, nonce, username='alice', should_fail=False): # Email address is encoded in url. url = '/~%s/emails/verify.html?email2=%s&nonce=%s' url %= (username, encode_for_querystring(email), nonce) @@ -39,17 +71,16 @@ def verify_email(self, email, nonce, username='alice', should_fail=False): def verify_and_change_email(self, old_email, new_email, username='alice', _flush=True): self.hit_email_spt('add-email', old_email) nonce = P(username).get_email(old_email).nonce - self.verify_email(old_email, nonce) + self.finish_email_verification(old_email, nonce) self.hit_email_spt('add-email', new_email) if _flush: self.app.email_queue.flush() - def test_participant_can_add_email(self): + def test_participant_can_start_email_verification(self): response = self.hit_email_spt('add-email', 'alice@gratipay.com') - actual = json.loads(response.body) - assert actual + assert json.loads(response.body) == 'Check your inbox for a verification link.' - def test_adding_email_sends_verification_email(self): + def test_starting_email_verification_triggers_verification_email(self): self.hit_email_spt('add-email', 'alice@gratipay.com') assert self.count_email_messages() == 1 last_email = self.get_last_email() @@ -69,7 +100,7 @@ def test_verification_email_doesnt_contain_unsubscribe(self): last_email = self.get_last_email() assert "To stop receiving" not in last_email['body_text'] - def test_adding_second_email_sends_verification_notice(self): + def test_verifying_second_email_sends_verification_notice(self): self.verify_and_change_email('alice1@example.com', 'alice2@example.com', _flush=False) assert self.count_email_messages() == 3 last_email = self.get_last_email() @@ -107,15 +138,15 @@ def test_post_too_quickly_is_400(self): assert 'too quickly' in response.body def test_verify_email_without_adding_email(self): - response = self.verify_email('', 'sample-nonce') + response = self.finish_email_verification('', 'sample-nonce') assert 'Bad Info' in response.body def test_verify_email_wrong_nonce(self): self.hit_email_spt('add-email', 'alice@example.com') nonce = 'fake-nonce' - r = self.alice.verify_email('alice@gratipay.com', nonce) + r = self.alice.finish_email_verification('alice@gratipay.com', nonce) assert r == _email.VERIFICATION_FAILED - self.verify_email('alice@example.com', nonce) + self.finish_email_verification('alice@example.com', nonce) expected = None actual = P('alice').email_address assert expected == actual @@ -124,8 +155,8 @@ def test_verify_email_a_second_time_returns_redundant(self): address = 'alice@example.com' self.hit_email_spt('add-email', address) nonce = self.alice.get_email(address).nonce - r = self.alice.verify_email(address, nonce) - r = self.alice.verify_email(address, nonce) + r = self.alice.finish_email_verification(address, nonce) + r = self.alice.finish_email_verification(address, nonce) assert r == _email.VERIFICATION_REDUNDANT def test_verify_email_expired_nonce(self): @@ -137,18 +168,26 @@ def test_verify_email_expired_nonce(self): WHERE participant_id = %s; """, (self.alice.id,)) nonce = self.alice.get_email(address).nonce - r = self.alice.verify_email(address, nonce) + r = self.alice.finish_email_verification(address, nonce) assert r == _email.VERIFICATION_EXPIRED actual = P('alice').email_address assert actual == None - def test_verify_email(self): + def test_finish_email_verification(self): self.hit_email_spt('add-email', 'alice@example.com') nonce = self.alice.get_email('alice@example.com').nonce - self.verify_email('alice@example.com', nonce) - expected = 'alice@example.com' - actual = P('alice').email_address - assert expected == actual + assert self.finish_email_verification('alice@example.com', nonce).code == 200 + assert P('alice').email_address == 'alice@example.com' + + def test_empty_email_results_in_missing(self): + for empty in ('', ' '): + result = self.alice.finish_email_verification(empty, 'foobar') + assert result == _email.VERIFICATION_MISSING + + def test_empty_nonce_results_in_missing(self): + for empty in ('', ' '): + result = self.alice.finish_email_verification('foobar', empty) + assert result == _email.VERIFICATION_MISSING def test_email_verification_is_backwards_compatible(self): """Test email verification still works with unencoded email in verification link. @@ -175,17 +214,17 @@ def test_get_emails(self): def test_verify_email_after_update(self): self.verify_and_change_email('alice@example.com', 'alice@example.net') nonce = self.alice.get_email('alice@example.net').nonce - self.verify_email('alice@example.net', nonce) + self.finish_email_verification('alice@example.net', nonce) expected = 'alice@example.com' actual = P('alice').email_address assert expected == actual - def test_nonce_is_reused_when_resending_email(self): + def test_nonce_is_not_reused_when_resending_email(self): self.hit_email_spt('add-email', 'alice@example.com') nonce1 = self.alice.get_email('alice@example.com').nonce self.hit_email_spt('resend', 'alice@example.com') nonce2 = self.alice.get_email('alice@example.com').nonce - assert nonce1 == nonce2 + assert nonce1 != nonce2 def test_emails_page_shows_emails(self): self.verify_and_change_email('alice@example.com', 'alice@example.net') @@ -217,96 +256,128 @@ def test_remove_email(self): self.hit_email_spt('remove', 'alice@example.com') -class TestFunctions(Alice): + def test_participant_can_verify_a_package_along_with_email(self): + foo = self.make_package(name='foo', emails=['alice@gratipay.com']) + response = self.hit_email_spt( 'start-verification' + , 'alice@gratipay.com' + , package_ids=[foo.id] + ) + assert json.loads(response.body) == 'Check your inbox for a verification link.' + assert self.db.all('select package_id from claims order by package_id') == [foo.id] + + def test_participant_cant_verify_packages_with_add_email_or_resend(self): + foo = self.make_package(name='foo', emails=['alice@gratipay.com']) + for action in ('add-email', 'resend'): + assert self.hit_email_spt( action + , 'alice@gratipay.com' + , package_ids=[foo.id] + , should_fail=True + ).code == 400 + + def test_participant_can_verify_multiple_packages_along_with_email(self): + package_ids = [self.make_package(name=name, emails=['alice@gratipay.com']).id + for name in ('foo', 'bar', 'baz', 'buz')] + response = self.hit_email_spt( 'start-verification' + , 'alice@gratipay.com' + , package_ids=package_ids + ) + assert json.loads(response.body) == 'Check your inbox for a verification link.' + assert self.db.all('select package_id from claims order by package_id') == package_ids + + def test_package_verification_fails_if_email_not_listed(self): + foo = self.make_package() + response = self.hit_email_spt( 'start-verification' + , 'bob@gratipay.com' + , package_ids=[foo.id] + , should_fail=True + ) + assert response.code == 400 + assert self.db.all('select package_id from claims order by package_id') == [] + + def test_package_verification_fails_package_id_is_garbage(self): + response = self.hit_email_spt( 'start-verification' + , 'bob@gratipay.com' + , package_ids=['cheese monkey'] + , should_fail=True + ) + assert response.code == 400 + assert self.db.all('select package_id from claims order by package_id') == [] - def add_and_verify(self, participant, address): - participant.add_email('alice@gratipay.com') - nonce = participant.get_email('alice@gratipay.com').nonce - r = participant.verify_email('alice@gratipay.com', nonce) - assert r == _email.VERIFICATION_SUCCEEDED + +class TestFunctions(Alice): def test_cannot_update_email_to_already_verified(self): bob = self.make_participant('bob', claimed_time='now') - self.add_and_verify(self.alice, 'alice@gratipay.com') + self.add(self.alice, 'alice@gratipay.com') with self.assertRaises(EmailTaken): - bob.add_email('alice@gratipay.com') + bob.start_email_verification('alice@gratipay.com') nonce = bob.get_email('alice@gratipay.com').nonce - bob.verify_email('alice@gratipay.com', nonce) + bob.finish_email_verification('alice@gratipay.com', nonce) email_alice = P('alice').email_address assert email_alice == 'alice@gratipay.com' - def test_cannot_add_too_many_emails(self): - self.alice.add_email('alice@gratipay.com') - self.alice.add_email('alice@gratipay.net') - self.alice.add_email('alice@gratipay.org') - self.app.email_queue.flush() - self.alice.add_email('alice@gratipay.co.uk') - self.alice.add_email('alice@gratipay.io') - self.alice.add_email('alice@gratipay.co') - self.app.email_queue.flush() - self.alice.add_email('alice@gratipay.eu') - self.alice.add_email('alice@gratipay.asia') - self.alice.add_email('alice@gratipay.museum') - self.app.email_queue.flush() - self.alice.add_email('alice@gratipay.py') - with self.assertRaises(TooManyEmailAddresses): - self.alice.add_email('alice@gratipay.coop') - def test_html_escaping(self): - self.alice.add_email("foo'bar@example.com") + self.alice.start_email_verification("foo'bar@example.com") last_email = self.get_last_email() assert 'foo'bar' in last_email['body_html'] assert ''' not in last_email['body_text'] + def test_npm_package_name_is_handled_safely(self): + foo = self.make_package(name=' {{ super() }} {% endblock %} {% block content %} -

    {{ _( '{npm_package} has not been claimed on Gratipay.' - , npm_package=('' + 'npm/' + package_name + '')|safe - ) }}

    -{% if user.ANON %} -

    {{ _('Is this yours? You can claim it on Gratipay with a couple clicks:') }}

    - {% include "templates/sign-in-using.html" %} -

    {{ _('What is Gratipay?') }}

    -

    {{ _('Gratipay helps companies and others pay for open source.') }} - {{ _("Learn more") }}

    +{% if package.description %} +

    {{ package.description }}

    +{% else %} +

    {{ _("No description available.") }}

    +{% endif %} + +

    + {{ _( 'Apply to accept payments for the {package_link} npm package:' + , package_link=('' + package_name + '')|safe + ) }} +

    + +{% if user.ANON %} +
    + {{ sign_in_using(button_class='large') }} +
    {% else %} -

    {{ _( 'Is this yours? You can claim it on Gratipay using any email address {a}on file{_a} in the maintainers field in the npm registry.' - , a=('')|safe - , _a=''|safe - ) }} - {% if len(package.emails) == 0 %} -

    {{ _("Sorry, we didn't find any email addresses on file.") }}

    + {% if len(emails) == 0 %} +

    {{ _("No email addresses on file.") }}

    {% else %} -
      - {% for i, email in enumerate(package.emails) %} -
    • -
    • +
      +
        + {% for i, (email, group) in enumerate(emails, start=1) %} +
      • + + +
        + +
        + {{ i }} + · + {% if group == OTHER %} + + {{ icons.STATUS_ICONS['failure']|safe }} + {{ _('Linked to a different account') }} + {% else %} + {{ _('Ready to use') }} + {% if group == PRIMARY %} + · + {{ icons.STATUS_ICONS['feature']|safe }} + {{ _('Your primary email address') }} + {% elif group == VERIFIED %} + · + {{ icons.STATUS_ICONS['success']|safe }} + {{ _('Linked to your account') }} + {% elif group == UNVERIFIED %} + · + {{ icons.STATUS_ICONS['warning']|safe }} + {{ _('Half-linked to your account') }} + {% endif %} + {% endif %} +
        +
        + + +
      • {% endfor %} -
      - +
    +
    + +
    + +
    {% endif %} +

    {{ _( 'Addresses are from {a}{code}maintainers{_code}{_a}.' + , a=('')|safe + , _a=''|safe + , code=''|safe + , _code=''|safe + ) }}

    +

    {{ _( 'Out of date? Update {a}at npm{_a} and refresh.' + , a=('')|safe + , _a=''|safe + ) }}

    {% endif %} {% endblock %} diff --git a/www/search.spt b/www/search.spt index 7e6ed84b6b..4890736a7d 100644 --- a/www/search.spt +++ b/www/search.spt @@ -149,7 +149,7 @@ zip = zip {% from 'templates/list-participants.html' import list_participants with context %} {% block content %} -
    + @@ -192,7 +192,7 @@ zip = zip {% endif %} {% if query and not (usernames or statements or emails or projects) %} -

    {{ _("Sorry, we didn't find anything matching your query.") }}

    +

    {{ _("Sorry, we didn't find anything matching your query.") }}

    {% endif %} {% endblock %} diff --git a/www/teams/create.json.spt b/www/teams/create.json.spt index 52763d01c7..fad03a10f4 100644 --- a/www/teams/create.json.spt +++ b/www/teams/create.json.spt @@ -84,7 +84,7 @@ if request.method == 'POST': team.save_image(image, large, small, image_type) - review_url = team.create_github_review_issue() + review_url = website.app.project_review_repo.create_issue(team) team.set_review_url(review_url) team_url = website.env.base_url + '/{}/'.format(team.slug) diff --git a/www/~/%username/emails/modify.json.spt b/www/~/%username/emails/modify.json.spt index 783f94c1b7..9b75a54e4d 100644 --- a/www/~/%username/emails/modify.json.spt +++ b/www/~/%username/emails/modify.json.spt @@ -27,30 +27,28 @@ if not participant.email_lang: participant.set_email_lang(request.headers.get("Accept-Language")) msg = None -if action in ('add-email', 'resend'): - try: - participant.add_email(address) - except EmailTaken: - raise Response(400, _( "{email_address} is already linked to a different Gratipay account." - , email_address=address - )) - except EmailAlreadyVerified: - raise Response(400, _( "You have already added and verified {email_address}." - , email_address=address - )) - except Throttled: - raise Response(400, _("You've initiated too many emails too quickly. Please try again in a minute or two.")) - else: - msg = _("A verification email has been sent to {email_address}.", email_address=address) +if action in ('add-email', 'resend', 'start-verification'): + packages = [] + if action == 'start-verification': + # work around Aspen limitation + package_ids = request.body.all('package_id') if 'package_id' in request.body else [] + + for package_id in package_ids: + try: + package = Package.from_id(package_id) + assert address in package.emails + except: + raise Response(400) + packages.append(package) + elif 'package_id' in request.body: + raise Response(400) + + participant.start_email_verification(address, *packages) + msg = _("Check your inbox for a verification link.") elif action == 'set-primary': - participant.update_email(address) + participant.set_primary_email(address) elif action == 'remove': participant.remove_email(address) -elif action == 'add-email-and-claim-package': - package_id = request.body['package_id'] - package = Package.from_id(package_id) - package.send_confirmation_email(address) - msg = _("Check {email} for a confirmation link.", email=address) else: raise Response(400, 'unknown action "%s"' % action) diff --git a/www/~/%username/emails/verify.html.spt b/www/~/%username/emails/verify.html.spt index d607ed5a72..dbcbafb0a7 100644 --- a/www/~/%username/emails/verify.html.spt +++ b/www/~/%username/emails/verify.html.spt @@ -22,7 +22,7 @@ if participant == user.participant: else: email_address = decode_from_querystring(request.qs.get('email2', ''), default='') nonce = request.qs.get('nonce', '') - result = participant.verify_email(email_address, nonce) + result = participant.finish_email_verification(email_address, nonce) if not participant.email_lang: participant.set_email_lang(request.headers.get("Accept-Language")) @@ -35,7 +35,7 @@ suppress_sidebar = True

    {{ _("Sign in to finish connecting your email.") }}

    -

    {% include "templates/sign-in-using.html" %}

    +

    {{ sign_in_using() }}

    {% elif user.participant != participant %}

    {{ _("Wrong Account") }}

    diff --git a/www/~/%username/index.html.spt b/www/~/%username/index.html.spt index d6764c8814..94a1871b41 100644 --- a/www/~/%username/index.html.spt +++ b/www/~/%username/index.html.spt @@ -63,14 +63,16 @@ i18ned_statuses = { "approved": _("Approved") {% if user.participant == participant %}
    {% include "templates/profile-edit.html" %} +
    +

    {{ _("Projects") }}

    + + + + + {% include "templates/project-listing.html" %} +
    +
    -

    {{ _("Projects") }}

    -
    - -
    - - {% include "templates/project-listing.html" %} -
    {% include "templates/connected-accounts.html" %} {% else %} {% if statement %} diff --git a/www/~/%username/receipts/%exchange_id.int.html.spt b/www/~/%username/receipts/%exchange_id.html.spt similarity index 95% rename from www/~/%username/receipts/%exchange_id.int.html.spt rename to www/~/%username/receipts/%exchange_id.html.spt index 92b0b2617d..55eb6c7521 100644 --- a/www/~/%username/receipts/%exchange_id.int.html.spt +++ b/www/~/%username/receipts/%exchange_id.html.spt @@ -9,13 +9,18 @@ from gratipay.billing.instruments import CreditCard participant = get_participant(state, restrict=True) +try: + exchange_id = int(request.path['exchange_id']) +except ValueError: + raise Response(400) + exchange = website.db.one(""" SELECT * FROM exchanges WHERE id = %s AND participant = %s AND amount > 0 -""", (request.path['exchange_id'], participant.username)) +""", (exchange_id, participant.username)) if exchange is None: raise Response(404) diff --git a/www/~/%username/routes/credit-card.spt b/www/~/%username/routes/credit-card.spt index 0627848de1..dc0ea1bd5a 100644 --- a/www/~/%username/routes/credit-card.spt +++ b/www/~/%username/routes/credit-card.spt @@ -22,25 +22,16 @@ else: {% extends "templates/profile-routes.html" %} {% block scripts %} - -{% if not user.ANON %} -{% endif %} - {{ super() }} {% endblock %} {% block content %} - {% if user.ANON %} - {% include "templates/sign-in-using.html" %} - {{ _("and then you'll be able to add or change your credit card.") }} - {% else %} -
    {% if last_bill_result %}

    {{ _("Failure") }}

    {{ last_bill_result }}

    @@ -115,5 +106,4 @@ else:
    - {% endif %} {% endblock %}