Skip to content

Commit

Permalink
add medium!
Browse files Browse the repository at this point in the history
  • Loading branch information
snarfed committed Aug 14, 2016
1 parent d1bf24b commit 746dd41
Show file tree
Hide file tree
Showing 11 changed files with 181 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ instagram_client_id
instagram_client_secret
instagram_client_id_local
instagram_client_secret_local
medium_client_id
medium_client_secret
tumblr_app_key
tumblr_app_secret
twitter_app_key
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This is a collection of drop-in
[Google App Engine](https://appengine.google.com/) request handlers for the
initial [OAuth](http://oauth.net/) client flows for many popular sites,
including Blogger, Disqus, Dropbox, Facebook, Flickr, Google+, IndieAuth,
Instagram, Tumblr, Twitter, and WordPress.com.
Instagram, Medium, Tumblr, Twitter, and WordPress.com.

This repo also provides an example demo app, deployed at
http://oauth-dropins.appspot.com/.
Expand Down Expand Up @@ -226,7 +226,8 @@ least one of them, but not all.

- `urlopen(data=None, timeout=None)` wraps `urllib2.urlopen()` and adds the
OAuth credentials to the request. Use this for making direct HTTP request to a
site's REST API.
site's REST API. Some sites may provide `get()` instead, which wraps
`requests.get()`.

- `http()` returns an `httplib2.Http` instance that adds the OAuth credentials
to requests.
Expand Down Expand Up @@ -305,6 +306,9 @@ you have it as a relative directory. pip requires fully qualified directories.
Changelog
---

#### 1.5 - unreleased
* Add [Medium](https://medium.com/).

#### 1.4 - 2016-06-27
* Upgrade Facebook API from v2.2 to v2.6.

Expand Down
3 changes: 3 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from oauth_dropins import googleplus
from oauth_dropins import indieauth
from oauth_dropins import instagram
from oauth_dropins import medium
from oauth_dropins import tumblr
from oauth_dropins import twitter
from oauth_dropins import wordpress_rest
Expand Down Expand Up @@ -52,6 +53,8 @@ def get(self):
('/indieauth/oauth_callback', indieauth.CallbackHandler.to('/')),
('/instagram/start', instagram.StartHandler.to('/instagram/oauth_callback')),
('/instagram/oauth_callback', instagram.CallbackHandler.to('/')),
('/medium/start', medium.StartHandler.to('/medium/oauth_callback')),
('/medium/oauth_callback', medium.CallbackHandler.to('/')),
('/tumblr/start', tumblr.StartHandler.to('/tumblr/oauth_callback')),
('/tumblr/oauth_callback', tumblr.CallbackHandler.to('/')),
('/twitter/start', twitter.StartHandler.to('/twitter/oauth_callback')),
Expand Down
2 changes: 2 additions & 0 deletions oauth_dropins/appengine_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def read(filename):
GOOGLE_CLIENT_ID = read('google_client_id')
GOOGLE_CLIENT_SECRET = read('google_client_secret')
INDIEAUTH_CLIENT_ID = read('indieauth_client_id')
MEDIUM_CLIENT_ID = read('medium_client_id')
MEDIUM_CLIENT_SECRET = read('medium_client_secret')
TUMBLR_APP_KEY = read('tumblr_app_key')
TUMBLR_APP_SECRET = read('tumblr_app_secret')
TWITTER_APP_KEY = read('twitter_app_key')
Expand Down
163 changes: 163 additions & 0 deletions oauth_dropins/medium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Medium OAuth drop-in.
API docs:
https://github.com/Medium/medium-api-docs#contents
https://medium.com/developers/welcome-to-the-medium-api-3418f956552
Medium doesn't let you use a localhost redirect URL. : / A common workaround is
to map an arbitrary host to localhost in your /etc/hosts, e.g.:
127.0.0.1 my.dev.com
You can then test on your local machine by running dev_appserver and opening
http://my.dev.com:8080/ instead of http://localhost:8080/ .
"""

import json
import logging
import urllib

import appengine_config
import handlers
from models import BaseAuth
from webutil import util

from google.appengine.ext import ndb
from webob import exc

# medium is behind cloudflare, which often blocks requests's user agent, so set
# our own.
USER_AGENT = 'oauth-dropins (https://oauth-dropins.appspot.com/)'

# URL templates. Can't (easily) use urllib.urlencode() because I want to keep
# the %(...)s placeholders as is and fill them in later in code.
GET_AUTH_CODE_URL = str('&'.join((
'https://medium.com/m/oauth/authorize?'
'client_id=%(client_id)s',
# https://github.com/Medium/medium-api-docs#user-content-21-browser-based-authentication
# basicProfile, listPublications, publishPost, uploadImage
'scope=%(scope)s',
# redirect_uri here must be the same in the access token request!
'redirect_uri=%(redirect_uri)s',
'state=%(state)s',
'response_type=code',
)))

API_BASE = 'https://api.medium.com/v1/'
GET_ACCESS_TOKEN_URL = API_BASE + 'tokens'
API_USER_URL = API_BASE + 'me'


class MediumAuth(BaseAuth):
"""An authenticated Medium user.
Provides methods that return information about this user and make OAuth-signed
requests to the Medium REST API. Stores OAuth credentials in the datastore.
See models.BaseAuth for usage details.
Medium-specific details: implements get() but not urlopen(), http(), or api().
The key name is the username.
"""
access_token_str = ndb.StringProperty(required=True)
user_json = ndb.TextProperty()

def site_name(self):
return 'Medium'

def user_display_name(self):
"""Returns the user's full name or username.
"""
if self.user_json:
data = json.loads(self.user_json).get('data')
if data:
return data.get('name') or data.get('username')

return self.key.string_id()

def access_token(self):
"""Returns the OAuth access token string.
"""
return self.access_token_str

def get(self, *args, **kwargs):
"""Wraps requests.get() and adds the Bearer token header.
"""
kwargs.setdefault('headers', {}).update({
'Authorization': 'Bearer ' + self.access_token_str,
'User-Agent': USER_AGENT,
})
resp = util.requests_get(*args, **kwargs)
try:
resp.raise_for_status()
except BaseException, e:
util.interpret_http_exception(e)
raise
return resp


class StartHandler(handlers.StartHandler):
"""Starts Medium auth. Requests an auth code and expects a redirect back.
"""
DEFAULT_SCOPE = 'basicProfile'

def redirect_url(self, state=None):
assert (appengine_config.MEDIUM_CLIENT_ID and
appengine_config.MEDIUM_CLIENT_SECRET), (
"Please fill in the medium_client_id and "
"medium_client_secret files in your app's root directory.")
return str(GET_AUTH_CODE_URL % {
'client_id': appengine_config.MEDIUM_CLIENT_ID,
'redirect_uri': urllib.quote_plus(self.to_url()),
# Medium requires non-empty state
'state': urllib.quote_plus(state if state else 'unused'),
'scope': self.scope,
})


class CallbackHandler(handlers.CallbackHandler):
"""The OAuth callback. Fetches an access token and stores it.
"""

def get(self):
# handle errors
error = self.request.get('error')
if error:
error_description = urllib.unquote_plus(
self.request.get('error_description', ''))
if error == 'access_denied':
logging.info('User declined: %s', error_description)
self.finish(None, state=self.request.get('state'))
return
else:
raise exc.HTTPBadRequest('Error: %s %s ' % (error, error_description))

# extract auth code and request access token
auth_code = util.get_required_param(self, 'code')
data = {
'code': auth_code,
'client_id': appengine_config.MEDIUM_CLIENT_ID,
'client_secret': appengine_config.MEDIUM_CLIENT_SECRET,
# redirect_uri here must be the same in the oauth code request!
# (the value here doesn't actually matter since it's requested server side.)
'redirect_uri': self.request.path_url,
'grant_type': 'authorization_code',
}
resp = util.requests_post(
GET_ACCESS_TOKEN_URL, data=urllib.urlencode(data),
headers={'User-Agent': USER_AGENT}).text
logging.debug('Access token response: %s', resp)

try:
resp = json.loads(resp)
access_token = resp['access_token']
# TODO: handle refresh token
except:
logging.exception('Could not decode JSON')
raise

user_json = MediumAuth(access_token_str=access_token).get(API_USER_URL).text
username = json.loads(user_json)['data']['username']
auth = MediumAuth(id=username, access_token_str=access_token, user_json=user_json)
auth.put()

self.finish(auth, state=self.request.get('state'))
Binary file added oauth_dropins/static/medium.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added oauth_dropins/static/medium_button_large.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added oauth_dropins/static/medium_large.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added oauth_dropins/static/medium_logotype.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion oauth_dropins/webutil
Submodule webutil updated 2 files
+23 −0 test/test_util.py
+6 −1 util.py
4 changes: 4 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,13 @@ <h1>oauth-dropins</h1>
<br />

<div class="row">
<div class="col-md-3 col-sm-6"><form method="post" action="/medium/start">
<input type="image" class="shadow" alt="Medium" src="/static/medium.png" />
</form></div>
<div class="col-md-3 col-sm-6"><form method="post" action="/tumblr/start">
<input type="image" class="shadow" alt="Tumblr" src="/static/tumblr.png" />
</form></div>
<div class="clearfix visible-sm"></div>
<div class="col-md-3 col-sm-6"><form method="post" action="/twitter/start">
<input type="image" class="shadow" alt="Twitter" src="/static/twitter.png" />
</form></div>
Expand Down

0 comments on commit 746dd41

Please sign in to comment.