diff --git a/TODO b/TODO index e17485c40b..0018f70831 100644 --- a/TODO +++ b/TODO @@ -1,3 +1,6 @@ +what about someone who wants to unregister? +don't show locked accounts on unclaimed page +don't show locked accounts on "who I tip" This is here for historical interest. We're now using Github issues to diff --git a/configure-aspen.py b/configure-aspen.py index af52bf53f8..e27e2faa66 100644 --- a/configure-aspen.py +++ b/configure-aspen.py @@ -9,24 +9,17 @@ gittip.wireup.db() gittip.wireup.billing() -website.github_client_id = os.environ['GITHUB_CLIENT_ID'] -website.github_client_secret = os.environ['GITHUB_CLIENT_SECRET'] -website.github_callback = os.environ['GITHUB_CALLBACK'] +website.github_client_id = os.environ['GITHUB_CLIENT_ID'].decode('ASCII') +website.github_client_secret = os.environ['GITHUB_CLIENT_SECRET'].decode('ASCII') +website.github_callback = os.environ['GITHUB_CALLBACK'].decode('ASCII') website.hooks.inbound_early.register(gittip.canonize) website.hooks.inbound_early.register(gittip.authentication.inbound) website.hooks.outbound_late.register(gittip.authentication.outbound) -def github_oauth_url(then=""): - url = "https://github.com/login/oauth/authorize?client_id=%s" - url %= website.github_client_id - if url: - url += "&redirect_uri=%s?then=%s" % (website.github_callback, then) - return url def add_stuff(request): request.context['__version__'] = gittip.__version__ request.context['username'] = None - request.context['github_oauth_url'] = github_oauth_url website.hooks.inbound_early.register(add_stuff) diff --git a/gittip/networks.py b/gittip/networks.py index 3213942f08..80e26dee47 100644 --- a/gittip/networks.py +++ b/gittip/networks.py @@ -1,7 +1,9 @@ import random -from aspen import log +import requests +from aspen import json, log, Response from aspen.utils import typecheck +from aspen.website import Website from gittip import db from psycopg2 import IntegrityError @@ -45,7 +47,75 @@ def upsert(user_info, claim=False): , user_info , claim=claim ) - + + + @staticmethod + def oauth_url(website, action, then=u""): + """Given a website object and a string, return a URL string. + + `action' is one of 'opt-in', 'lock' and 'unlock' + + `then' is either a github username or an URL starting with '/'. It's + where we'll send the user after we get the redirect back from + GitHub. + + """ + typecheck(website, Website, action, unicode, then, unicode) + assert action in [u'opt-in', u'lock', u'unlock'] + url = u"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s" + url %= (website.github_client_id, website.github_callback) + + # Pack action,then into data and base64-encode. Querystring isn't + # available because it's consumed by the initial GitHub request. + + data = u'%s,%s' % (action, then) + data = data.encode('UTF-8').encode('base64').decode('US-ASCII') + url += u'?data=%s' % data + return url + + + @staticmethod + def oauth_dance(website, qs): + """Given a querystring, return a dict of user_info. + + The querystring should be the querystring that we get from GitHub when + we send the user to the return value of oauth_url above. + + See also: + + http://developer.github.com/v3/oauth/ + + """ + + log("Doing an OAuth dance with Github.") + + if 'error' in qs: + raise Response(500, str(qs['error'])) + + data = { 'code': qs['code'].encode('US-ASCII') + , 'client_id': website.github_client_id + , 'client_secret': website.github_client_secret + } + r = requests.post("https://github.com/login/oauth/access_token", data=data) + assert r.status_code == 200, (r.status_code, r.text) + + back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX + if 'error' in back: + raise Response(400, back['error'].encode('utf-8')) + assert back.get('token_type', '') == 'bearer', back + access_token = back['access_token'] + + r = requests.get( "https://api.github.com/user" + , headers={'Authorization': 'token %s' % access_token} + ) + assert r.status_code == 200, (r.status_code, r.text) + user_info = json.loads(r.text) + log("Done with OAuth dance with Github for %s (%s)." + % (user_info['login'], user_info['id'])) + + return user_info + + @staticmethod def resolve(login): """Given two str, return a participant_id. @@ -78,7 +148,8 @@ def upsert(network, user_id, username, user_info, claim=False): primary key in the underlying table in our own db. If claim is True, the return value is the participant_id. Otherwise it is a - tuple: (participant_id [unicode], is_claimed [boolean], balance [Decimal]). + tuple: (participant_id [unicode], is_claimed [boolean], is_locked + [boolean], balance [Decimal]). """ typecheck( network, str @@ -153,7 +224,7 @@ def upsert(network, user_id, username, user_info, claim=False): AND ( (participant_id IS NULL) OR (participant_id=%s) ) - RETURNING participant_id + RETURNING participant_id, is_locked """ @@ -162,9 +233,13 @@ def upsert(network, user_id, username, user_info, claim=False): rows = db.fetchall( ASSOCIATE , (participant_id, network, user_id, participant_id) ) - nrows = len(list(rows)) + rows = list(rows) + nrows = len(rows) assert nrows in (0, 1) - if nrows == 0: + + if nrows == 1: + is_locked = rows[0]['is_locked'] + else: # Against all odds, the account was otherwise associated with another # participant while we weren't looking. Maybe someone paid them money @@ -176,7 +251,8 @@ def upsert(network, user_id, username, user_info, claim=False): , (participant_id,) ) - rec = db.fetchone( "SELECT participant_id FROM social_network_users " + rec = db.fetchone( "SELECT participant_id, is_locked " + "FROM social_network_users " "WHERE network=%s AND user_id=%s" , (network, user_id) ) @@ -185,6 +261,7 @@ def upsert(network, user_id, username, user_info, claim=False): # Use the participant associated with this account. participant_id = rec['participant_id'] + is_locked = rec['is_locked'] assert participant_id is not None else: @@ -217,6 +294,10 @@ def upsert(network, user_id, username, user_info, claim=False): , (participant_id,) ) assert rec is not None - out = (participant_id, rec['claimed_time'] is not None, rec['balance']) + out = ( participant_id + , rec['claimed_time'] is not None + , is_locked + , rec['balance'] + ) return out diff --git a/requirements.txt b/requirements.txt index 02edbba0a7..a935c6386d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -./vendor/aspen-0.18.21.tar.bz2 +./vendor/aspen-0.18.25.tar.bz2 ./vendor/psycopg2-2.4.5.tar.gz ./vendor/simplejson-2.3.2.tar.gz ./vendor/certifi-0.0.8.tar.gz diff --git a/sql/schema.sql b/sql/schema.sql index 99ad95fae4..572f1da5de 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -31,7 +31,7 @@ CREATE TABLE social_network_users , network text NOT NULL , user_id text NOT NULL , user_info hstore -, opted_out boolean DEFAULT FALSE +, is_locked boolean NOT NULL DEFAULT FALSE , participant_id text DEFAULT NULL REFERENCES participants ON DELETE RESTRICT , UNIQUE(network, user_id) ); diff --git a/templates/participant.html b/templates/participant.html index c92761742f..33bc163678 100644 --- a/templates/participant.html +++ b/templates/participant.html @@ -5,8 +5,8 @@ {% if can_tip and user.ANON %} -

Sign in - with +

Sign in using + GitHub to tip {{ username }}.

{% elif can_tip %} {% if user.id != username %} diff --git a/vendor/aspen-0.18.21.tar.bz2 b/vendor/aspen-0.18.21.tar.bz2 deleted file mode 100644 index 9e6c95e090..0000000000 Binary files a/vendor/aspen-0.18.21.tar.bz2 and /dev/null differ diff --git a/vendor/aspen-0.18.25.tar.bz2 b/vendor/aspen-0.18.25.tar.bz2 new file mode 100644 index 0000000000..4958b7b761 Binary files /dev/null and b/vendor/aspen-0.18.25.tar.bz2 differ diff --git a/www/%participant_id/index.html b/www/%participant_id/index.html index 3c916f9c30..27ad393ff2 100644 --- a/www/%participant_id/index.html +++ b/www/%participant_id/index.html @@ -13,6 +13,7 @@ from aspen import json, Response from gittip import AMOUNTS, db, get_tip, get_tips_and_total, get_tipjar from gittip.utils import wrap +from gittip.networks import github tip_suggestions = ('jeresig', 'antirez', 'wycats', 'fabpot', 'mitsuhiko', @@ -56,7 +57,7 @@ my_tip = get_tip(user.id, participant['id']) name = participant['user_info'].get('name', username) -github = participant['user_info'] +github_user_info = participant['user_info'] can_tip = True tipjar = get_tipjar(user.id, pronoun="your", claimed=True) tip_or_pledge = "tip" @@ -217,10 +218,10 @@

{{ participant['id'] }} {{ get_tipjar(participant['id']

Linked Accounts

diff --git a/www/about/leaderboard.html b/www/about/leaderboard.html index 1f5b8e65b0..73faf510b4 100644 --- a/www/about/leaderboard.html +++ b/www/about/leaderboard.html @@ -9,7 +9,9 @@ , tippee FROM tips JOIN participants p ON p.id = tipper + JOIN social_network_users snu ON snu.participant_id = tippee WHERE last_bill_result = '' + AND snu.is_locked = false ORDER BY tipper, tippee, mtime DESC ) AS foo JOIN participants p ON p.id = tippee @@ -67,6 +69,6 @@

Gittip happens every Friday.

The amounts above are what the Gittip community is willing to give as a weekly gift to each person, but only if the person accepts it. Gittip is opt-in. We never collect money on a person’s behalf until that -person opts-in by claiming their account.

+person opts in by claiming their account.

{% end %} diff --git a/www/about/stats.html b/www/about/stats.html index 725baf5093..9936a273cb 100644 --- a/www/about/stats.html +++ b/www/about/stats.html @@ -18,8 +18,10 @@ ncc = db.fetchone("SELECT count(*) AS n FROM participants WHERE last_bill_result = ''")['n'] ncc = 0 if ncc is None else ncc pcc = "%5.1f" % ((ncc * 100.0) / npeople) -statements = list(db.fetchall("SELECT id, statement FROM participants WHERE statement != '' ORDER BY id")) +statements = db.fetchall("SELECT id, statement FROM participants WHERE statement != '' ORDER BY id") +statements = [] if statements is None else list(statements) amount = db.fetchone("SELECT transfer_volume AS v FROM paydays ORDER BY ts_end DESC LIMIT 1")['v'] +amount = 0 if amount is None else amount tip_amounts = db.fetchone(""" SELECT avg(amount), sum(amount) FROM ( SELECT DISTINCT ON (tipper, tippee) amount @@ -35,7 +37,7 @@ average_tip = 0 total_backed_tips = 0 else: - average_tip = tip_amounts['avg'] + average_tip = tip_amounts['avg'] if tip_amounts['avg'] is not None else 0 total_backed_tips = tip_amounts['sum'] average_tippees = db.fetchone("""\ diff --git a/www/about/unclaimed.html b/www/about/unclaimed.html index d7a8336e89..186e78e737 100644 --- a/www/about/unclaimed.html +++ b/www/about/unclaimed.html @@ -11,8 +11,10 @@ FROM tips JOIN participants p ON p.id = tipper JOIN participants p2 ON p2.id = tippee + JOIN social_network_users snu ON snu.participant_id = tippee WHERE p.last_bill_result = '' AND p2.claimed_time IS NULL + AND snu.is_locked = false ORDER BY tipper, tippee, mtime DESC ) AS foo GROUP BY tippee diff --git a/www/assets/%version/gittip.js b/www/assets/%version/gittip.js index 9aff062e7a..357b73a424 100644 --- a/www/assets/%version/gittip.js +++ b/www/assets/%version/gittip.js @@ -343,6 +343,7 @@ Gittip.initTipButtons = function() }); }; + Gittip.initJumpToPerson = function() { function jump(e) diff --git a/www/credit-card.html b/www/credit-card.html index 9a4d020fde..98c590df46 100644 --- a/www/credit-card.html +++ b/www/credit-card.html @@ -3,6 +3,7 @@ import stripe from aspen import json, log, Response from gittip import billing +from gittip.networks import github # ========================================================================== ^L @@ -34,7 +35,7 @@

Credit Card

-

Sign in with +

Sign in with GitHub, and then you’ll be able to add or change your credit card. Thanks! :-)

diff --git a/www/github/%login/index.html b/www/github/%login/index.html index c2759b68cd..7c2f66f2bb 100644 --- a/www/github/%login/index.html +++ b/www/github/%login/index.html @@ -2,7 +2,6 @@ """ import decimal - import requests from aspen import json, Response from gittip import AMOUNTS, db, get_tip, get_tipjar @@ -33,8 +32,9 @@ usertype = userinfo.get("type", "unknown type of account").lower() if usertype == "user": - can_tip = True - participant_id, is_claimed, balance = github.upsert(userinfo) + participant_id, is_claimed, is_locked, balance = github.upsert(userinfo) + can_tip = not is_locked + lock_action = "unlock" if is_locked else "lock" if is_claimed: request.redirect('/%s/' % participant_id) @@ -57,6 +57,18 @@ {% block their_voice %} {% if usertype == "user" %} + {% if is_locked %} + +

{{ username }} has opted out of Gittip.

+ +

If you are {{ username }} on GitHub, you can unlock + your account to allow people to pledge tips to you on Gittip. We never + collect any money on your behalf until you explicitly opt in.

+ + + + {% else %} @@ -68,7 +80,7 @@

{{ name }} has not joined Gittip.

  • Are you {{ userinfo['login'] }} from GitHub?
    - Click here to opt in to Gittip. + Click here to opt in to Gittip.
  • {% else %} @@ -85,11 +97,25 @@

    {{ name }} has not joined Gittip.

    This person {{ get_tipjar(username, claimed=False) }}

    We never collect money on a person’s behalf until that person - opts-in by claiming their account, which {{ username }} has not done. + opts in by claiming their account, which {{ username }} has not done. Any amount above is what the Gittip community is willing to give {{ username }} as a weekly gift, but only if {{ username }} accepts it by opting in.

    + {% if user.ANON %} + +

    Don’t like what you see?

    + +

    If you are {{ username }} you can explicitly opt out of Gittip by + locking this account. We don’t allow new pledges to locked + accounts, and we’ll set aside your username on Gittip to prevent + squatting.

    + + + + {% end %} + {% end %} {% elif usertype == "organization" %}

    {{ name }} is an {{ usertype }} on GitHub.

    diff --git a/www/github/%login/lock-fail.html b/www/github/%login/lock-fail.html new file mode 100644 index 0000000000..00e132fdb3 --- /dev/null +++ b/www/github/%login/lock-fail.html @@ -0,0 +1,16 @@ +username = path['login'] +^L +{% extends templates/base.html %} + +{% block body %} + +
    +

    Are you really {{ username }}?

    + +

    Your attempt to lock or unlock this account failed because you’re +logged into GitHub as someone else. Please log out of GitHub and try again.

    +
    + +{% end %} diff --git a/www/github/associate b/www/github/associate index 9de092d63b..72156a636c 100644 --- a/www/github/associate +++ b/www/github/associate @@ -1,62 +1,74 @@ """Associate a Github account with a Gittip account. First we do the OAuth dance with Github. Once we've authenticated the user -against Github, we record them in our user_infos table. This table contains -information for Github users whether or not they are explicit participants in -the Gittip economy. +against Github, we record them in our social_network_users table. This table +contains information for Github users whether or not they are explicit +participants in the Gittip community. """ -import requests -from aspen import json, log, Response +from aspen import log, Response +from gittip import db from gittip.networks import github from gittip.authentication import User # ========================== ^L +# Load GitHub user info. +user_info = github.oauth_dance(website, qs) -# Do the dance. -# ============= -# http://developer.github.com/v3/oauth/ - -log("Doing an OAuth dance with Github.") - -if 'error' in qs: - raise Response(500, str(qs['error'])) - -data = { 'code': qs['code'].encode('US-ASCII') - , 'client_id': website.github_client_id - , 'client_secret': website.github_client_secret - } -r = requests.post("https://github.com/login/oauth/access_token", data=data) -assert r.status_code == 200, (r.status_code, r.text) - -back = dict([pair.split('=') for pair in r.text.split('&')]) # XXX -if 'error' in back: - raise Response(400, back['error'].encode('utf-8')) -assert back.get('token_type', '') == 'bearer', back -access_token = back['access_token'] - -r = requests.get( "https://api.github.com/user" - , headers={'Authorization': 'token %s' % access_token} - ) -assert r.status_code == 200, (r.status_code, r.text) -user_info = json.loads(r.text) -log("Done with OAuth dance with Github for %s (%s)." - % (user_info['login'], user_info['id'])) - - -# Bind to a participant. -# ====================== -# Redirect to the new participant, or to the one they came from, or wherever -# else they came from. - -participant_id = github.upsert(user_info, claim=True) -user = User.from_id(participant_id) -then = qs.get('then') -if then is not None: - if then.startswith('/'): - request.redirect(then) - participant_id = 'github/' + then -request.redirect('/%s/' % participant_id) +# Determine what we're supposed to do. +data = qs['data'].decode('base64').decode('UTF-8') +action, then = data.split(',', 1) +if action not in [u'opt-in', u'lock', u'unlock']: + raise Response(400) + +# Make sure we have a GitHub login. +login = user_info.get('login') +if login is None: + log(u"We got a user_info from GitHub with no login [%s, %s]" + % (action, then)) + raise Response(400) + +# Do something. +log(u"%s wants to %s" % (login, action)) +if action == 'opt-in': # opt in + participant_id = github.upsert(user_info, claim=True) + user = User.from_id(participant_id) # give them a session +else: # lock or unlock + if then != login: + + # The user could spoof then to match their login, but below we work + # with login (not then), so that the most they can do is lock/unlock + # their own account in a convoluted way. + + then = u'/github/%s/lock-fail.html' % then + + else: + + # Associate the GitHub login with a Gittip participant to prevent + # squatting, but don't claim the Gittip account. + + participant_id, is_claimed, is_locked, balance \ + = github.upsert(user_info, claim=False) + if participant_id != login: + + # XXX When we have multiple networks we'll need to worry about the + # case where someone already claimed this id. For now this is a + # bug. + + raise Response(500, participant_id) + + db.execute( "UPDATE social_network_users " + "SET is_locked=%s " + "WHERE participant_id=%s" + , (action == 'lock', participant_id) + ) + +if then == u'': + then = u'/%s/' % participant_id +if not then.startswith(u'/'): + # Interpret it as a GitHub login. + then = u'/github/%s/' % then +request.redirect(then) # ========================== ^L text/plain diff --git a/www/index.html b/www/index.html index d26730b957..0be11ec273 100644 --- a/www/index.html +++ b/www/index.html @@ -1,4 +1,5 @@ from gittip import AMOUNTS +from gittip.networks import github ^L ^L {% extends templates/base.html %} @@ -24,8 +25,8 @@

    Gittip is a micro-giving community.

    {% end %} {% block body %} {% if user.ANON %} -

    Step 1. Sign in with GitHub.

    +

    Step 1. Sign in using GitHub.

    Step 2. Tip someone!