├── oauth ├── __init__.py └── oauth.py ├── dropbox ├── __init__.py ├── rest.py ├── auth.py └── client.py ├── TODO ├── app.yaml ├── index.yaml ├── templates └── main.html ├── drophooks.ini ├── poster ├── __init__.py ├── streaminghttp.py └── encode.py └── main.py /oauth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dropbox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | -simple ping 2 | -detailed ping -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | application: drophooks 2 | version: 1 3 | runtime: python 4 | api_version: 1 5 | 6 | handlers: 7 | - url: .* 8 | script: main.py 9 | -------------------------------------------------------------------------------- /index.yaml: -------------------------------------------------------------------------------- 1 | indexes: 2 | 3 | # AUTOGENERATED 4 | 5 | # This index.yaml is automatically updated whenever the dev_appserver 6 | # detects that a new type of query is run. If you want to manage the 7 | # index.yaml file manually, remove the above marker line (the line 8 | # saying "# AUTOGENERATED"). If you want to manage some indexes 9 | # manually, move them above the marker line. The index.yaml file is 10 | # automatically uploaded to the admin console when you next deploy 11 | # your application using appcfg.py. 12 | -------------------------------------------------------------------------------- /templates/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

DropHooks

7 | {% if user %} 8 |

You're logged in as: {{user.email}} Logout

9 | 10 |

Your webhook:

11 | {{user.callback_url}} 12 | 13 |

Edit webhook:

14 |
15 | 16 | 17 |
18 | 19 | {% else %} 20 | Login with your DropBox account 21 | 22 | {% endif %} 23 | 24 | -------------------------------------------------------------------------------- /drophooks.ini: -------------------------------------------------------------------------------- 1 | [auth] 2 | server = api.dropbox.com 3 | content_server = api-content.dropbox.com 4 | port = 80 5 | 6 | request_token_url = https://api.dropbox.com/0/oauth/request_token 7 | access_token_url = https://api.dropbox.com/0/oauth/access_token 8 | authorization_url = https://www.dropbox.com/0/oauth/authorize 9 | trusted_access_token_url = https://api.dropbox.com/0/token 10 | 11 | # the root of Dropbox operations. should be either dropbox or sandbox, depending on your app's setup 12 | root = dropbox 13 | 14 | # key and secret granted by the service provider for this consumer application - same as the mockoauthdatastore 15 | consumer_key = onycapdreiwhqrk 16 | consumer_secret = c7j4w7clsrcbwsy 17 | 18 | # leave the verifier blank for now 19 | verifier = 20 | 21 | # these two configurations aren't needed in normal operation, 22 | # they're only used during the unit tests to make sure that 23 | # everything is working. 24 | testing_user = progrium+drophooks@gmail.com 25 | testing_password = 532beee243adb5191d2e2c9920f8db99c7cbd241 26 | 27 | -------------------------------------------------------------------------------- /poster/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2011 Chris AtLee 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | """poster module 21 | 22 | Support for streaming HTTP uploads, and multipart/form-data encoding 23 | 24 | ```poster.version``` is a 3-tuple of integers representing the version number. 25 | New releases of poster will always have a version number that compares greater 26 | than an older version of poster. 27 | New in version 0.6.""" 28 | 29 | import poster.streaminghttp 30 | import poster.encode 31 | 32 | version = (0, 8, 1) # Thanks JP! 33 | -------------------------------------------------------------------------------- /dropbox/rest.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple JSON REST request abstraction that is used by the 3 | dropbox.client module. You shouldn't need to use this directly 4 | unless you're implementing unsupport methods. 5 | """ 6 | 7 | 8 | import httplib 9 | import simplejson as json 10 | import urllib 11 | 12 | 13 | class RESTClient(object): 14 | """ 15 | An abstraction on performing JSON REST requests that is used internally 16 | by the Dropbox Client API. It provides just enough gear to make requests 17 | and get responses as JSON data. 18 | 19 | It is not designed well for file uploads. 20 | """ 21 | 22 | def __init__(self, host, port): 23 | self.host = host 24 | self.port = port 25 | 26 | def request(self, method, url, post_params=None, headers=None, raw_response=False): 27 | """ 28 | Given the method and url this will make a JSON REST request to the 29 | configured self.host:self.port and returns a RESTResponse for you. 30 | If you pass in a dict for post_params then it will urlencode them 31 | into the body. If you give in a headers dict then it will add 32 | those to the request headers. 33 | 34 | The raw_response parameter determines if you get a RESTResponse or a 35 | raw HTTPResponse object. In some cases, like getting a file, you 36 | don't want any JSON decoding or extra processing. In that case set 37 | this to True and you'll get a plain HTTPResponse. 38 | """ 39 | params = post_params or {} 40 | headers = headers or {} 41 | 42 | if params: 43 | body = urllib.urlencode(params) 44 | else: 45 | body = None 46 | 47 | if body: 48 | headers["Content-type"] = "application/x-www-form-urlencoded" 49 | 50 | conn = httplib.HTTPConnection(self.host, self.port) 51 | conn.request(method, url, body, headers) 52 | 53 | if raw_response: 54 | return conn.getresponse() 55 | else: 56 | resp = RESTResponse(conn.getresponse()) 57 | conn.close() 58 | 59 | return resp 60 | 61 | def GET(self, url, headers=None): 62 | """Convenience method that just does a GET request.""" 63 | return self.request("GET", url, headers=headers) 64 | 65 | def POST(self, url, params, headers=None): 66 | """Convenience method that just does a POST request.""" 67 | return self.request("POST", url, post_params=params, headers=headers) 68 | 69 | 70 | class RESTResponse(object): 71 | """ 72 | Returned by dropbox.rest.RESTClient wrapping the base http response 73 | object to make it more convenient. It contains the attributes 74 | http_response, status, reason, body, headers. If the body can 75 | be parsed into json, then you get a data attribute too, otherwise 76 | it's set to None. 77 | """ 78 | 79 | def __init__(self, http_resp): 80 | self.http_response = http_resp 81 | self.status = http_resp.status 82 | self.reason = http_resp.reason 83 | self.body = http_resp.read() 84 | self.headers = dict(http_resp.getheaders()) 85 | 86 | try: 87 | self.data = json.loads(self.body) 88 | except ValueError: 89 | # looks like this isn't json, data is None 90 | self.data = None 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import webapp, db 2 | from google.appengine.ext.webapp import util 3 | from google.appengine.ext.webapp import template 4 | from google.appengine.api import urlfetch 5 | from google.appengine.api import taskqueue 6 | from django.utils import simplejson as json 7 | 8 | from dropbox import client, rest, auth 9 | from oauth.oauth import OAuthToken 10 | from google.appengine.api import memcache 11 | 12 | import urllib 13 | import base64 14 | 15 | POLL_INTERVAL = 60 # seconds 16 | 17 | config = auth.Authenticator.load_config("drophooks.ini") 18 | 19 | class DropboxUser(db.Model): 20 | uid = db.StringProperty() 21 | email = db.StringProperty() 22 | oauth_token = db.StringProperty() 23 | oauth_token_secret = db.StringProperty() 24 | callback_url = db.StringProperty(default='') 25 | size = db.StringProperty() 26 | 27 | @classmethod 28 | def get_by_uid(cls, uid): 29 | user = cls.all().filter('uid =', uid).get() 30 | if user is None: 31 | user = cls(uid=uid) 32 | user.put() 33 | return user 34 | 35 | @classmethod 36 | def get_current(cls, handler): 37 | uid = handler.request.cookies.get('uid') 38 | if uid is not None: 39 | return cls.all().filter('uid =', uid).get() 40 | else: 41 | return None 42 | 43 | class MainHandler(webapp.RequestHandler): 44 | def get(self): 45 | user = DropboxUser.get_current(self) 46 | self.response.out.write(template.render('templates/main.html', locals())) 47 | 48 | def post(self): 49 | user = DropboxUser.get_current(self) 50 | if user: 51 | user.callback_url = self.request.get('callback_url') 52 | user.put() 53 | self.redirect('/') 54 | 55 | class LoginHandler(webapp.RequestHandler): 56 | def get(self): 57 | dba = auth.Authenticator(config) 58 | req_token = dba.obtain_request_token() 59 | base64_token = urllib.quote(base64.b64encode(req_token.to_string())) 60 | self.redirect(dba.build_authorize_url(req_token, callback="http://%s/callback/%s" % (self.request.host, base64_token) )) 61 | 62 | class LoginCallbackHandler(webapp.RequestHandler): 63 | def get(self, token): 64 | dba = auth.Authenticator(config) 65 | req_token = OAuthToken.from_string(base64.b64decode(urllib.unquote(token))) 66 | uid = self.request.get('uid') 67 | self.response.headers.add_header('Set-Cookie', 'uid=%s; path=/' % uid) 68 | 69 | token = dba.obtain_access_token(req_token, '') 70 | 71 | db_client = client.DropboxClient(config['server'], config['content_server'], config['port'], dba, token) 72 | account = json.loads(db_client.account_info().body) 73 | 74 | user = DropboxUser.get_by_uid(uid) 75 | user.oauth_token = token.key 76 | user.oauth_token_secret = token.secret 77 | user.email = account['email'] 78 | user.put() 79 | 80 | if user.size is None: 81 | taskqueue.add(url='/tasks/poll', params={'uid': uid}) 82 | 83 | self.redirect('/') 84 | 85 | class LogoutHandler(webapp.RequestHandler): 86 | def get(self): 87 | self.response.headers.add_header('Set-Cookie', 'uid=; path=/; expires=Thu, 01-Jan-1970 00:00:01 GMT;') 88 | self.redirect(self.request.get('redirect_to')) 89 | 90 | class GrueHandler(webapp.RequestHandler): 91 | def get(self): 92 | 93 | user = DropboxUser.get_current(self) 94 | 95 | if user: 96 | token = OAuthToken(user.oauth_token, user.oauth_token_secret) 97 | dba = auth.Authenticator(config) 98 | db_client = client.DropboxClient(config['server'], config['content_server'], config['port'], dba, token) 99 | 100 | dirinfo = json.loads(db_client.metadata('dropbox', '').body) 101 | 102 | if 'contents' in dirinfo: 103 | self.response.out.write(dirinfo['contents']) 104 | else: 105 | self.response.out.write('no contents, bro') 106 | else: 107 | print "There was no user, bro." 108 | 109 | class PollTask(webapp.RequestHandler): 110 | def post(self): 111 | uid = self.request.get('uid') 112 | if uid is not None: 113 | taskqueue.add(url='/tasks/poll', params={'uid': uid}, countdown=POLL_INTERVAL) 114 | user = DropboxUser.get_by_uid(uid) 115 | token = OAuthToken(user.oauth_token, user.oauth_token_secret) 116 | dba = auth.Authenticator(config) 117 | db_client = client.DropboxClient(config['server'], config['content_server'], config['port'], dba, token) 118 | account_info = json.loads(db_client.account_info().body) 119 | size = str(account_info['quota_info']['normal']) 120 | 121 | if user.size != size: 122 | params = {'changed': 'yeah'} 123 | urlfetch.fetch(url=user.callback_url, payload=urllib.urlencode(params), method='POST') 124 | 125 | user.size = size 126 | user.put() 127 | 128 | 129 | 130 | def main(): 131 | application = webapp.WSGIApplication([ 132 | ('/', MainHandler), 133 | ('/grue/rocks', GrueHandler), 134 | ('/tasks/poll', PollTask), 135 | ('/login', LoginHandler), 136 | ('/logout', LogoutHandler), 137 | ('/callback/(.*)', LoginCallbackHandler), ], debug=True) 138 | util.run_wsgi_app(application) 139 | 140 | 141 | if __name__ == '__main__': 142 | main() 143 | -------------------------------------------------------------------------------- /poster/streaminghttp.py: -------------------------------------------------------------------------------- 1 | """Streaming HTTP uploads module. 2 | 3 | This module extends the standard httplib and urllib2 objects so that 4 | iterable objects can be used in the body of HTTP requests. 5 | 6 | In most cases all one should have to do is call :func:`register_openers()` 7 | to register the new streaming http handlers which will take priority over 8 | the default handlers, and then you can use iterable objects in the body 9 | of HTTP requests. 10 | 11 | **N.B.** You must specify a Content-Length header if using an iterable object 12 | since there is no way to determine in advance the total size that will be 13 | yielded, and there is no way to reset an interator. 14 | 15 | Example usage: 16 | 17 | >>> from StringIO import StringIO 18 | >>> import urllib2, poster.streaminghttp 19 | 20 | >>> opener = poster.streaminghttp.register_openers() 21 | 22 | >>> s = "Test file data" 23 | >>> f = StringIO(s) 24 | 25 | >>> req = urllib2.Request("http://localhost:5000", f, 26 | ... {'Content-Length': str(len(s))}) 27 | """ 28 | 29 | import httplib, urllib2, socket 30 | from httplib import NotConnected 31 | 32 | __all__ = ['StreamingHTTPConnection', 'StreamingHTTPRedirectHandler', 33 | 'StreamingHTTPHandler', 'register_openers'] 34 | 35 | if hasattr(httplib, 'HTTPS'): 36 | __all__.extend(['StreamingHTTPSHandler', 'StreamingHTTPSConnection']) 37 | 38 | class _StreamingHTTPMixin: 39 | """Mixin class for HTTP and HTTPS connections that implements a streaming 40 | send method.""" 41 | def send(self, value): 42 | """Send ``value`` to the server. 43 | 44 | ``value`` can be a string object, a file-like object that supports 45 | a .read() method, or an iterable object that supports a .next() 46 | method. 47 | """ 48 | # Based on python 2.6's httplib.HTTPConnection.send() 49 | if self.sock is None: 50 | if self.auto_open: 51 | self.connect() 52 | else: 53 | raise NotConnected() 54 | 55 | # send the data to the server. if we get a broken pipe, then close 56 | # the socket. we want to reconnect when somebody tries to send again. 57 | # 58 | # NOTE: we DO propagate the error, though, because we cannot simply 59 | # ignore the error... the caller will know if they can retry. 60 | if self.debuglevel > 0: 61 | print "send:", repr(value) 62 | try: 63 | blocksize = 8192 64 | if hasattr(value, 'read') : 65 | if hasattr(value, 'seek'): 66 | value.seek(0) 67 | if self.debuglevel > 0: 68 | print "sendIng a read()able" 69 | data = value.read(blocksize) 70 | while data: 71 | self.sock.sendall(data) 72 | data = value.read(blocksize) 73 | elif hasattr(value, 'next'): 74 | if hasattr(value, 'reset'): 75 | value.reset() 76 | if self.debuglevel > 0: 77 | print "sendIng an iterable" 78 | for data in value: 79 | self.sock.sendall(data) 80 | else: 81 | self.sock.sendall(value) 82 | except socket.error, v: 83 | if v[0] == 32: # Broken pipe 84 | self.close() 85 | raise 86 | 87 | class StreamingHTTPConnection(_StreamingHTTPMixin, httplib.HTTPConnection): 88 | """Subclass of `httplib.HTTPConnection` that overrides the `send()` method 89 | to support iterable body objects""" 90 | 91 | class StreamingHTTPRedirectHandler(urllib2.HTTPRedirectHandler): 92 | """Subclass of `urllib2.HTTPRedirectHandler` that overrides the 93 | `redirect_request` method to properly handle redirected POST requests 94 | 95 | This class is required because python 2.5's HTTPRedirectHandler does 96 | not remove the Content-Type or Content-Length headers when requesting 97 | the new resource, but the body of the original request is not preserved. 98 | """ 99 | 100 | handler_order = urllib2.HTTPRedirectHandler.handler_order - 1 101 | 102 | # From python2.6 urllib2's HTTPRedirectHandler 103 | def redirect_request(self, req, fp, code, msg, headers, newurl): 104 | """Return a Request or None in response to a redirect. 105 | 106 | This is called by the http_error_30x methods when a 107 | redirection response is received. If a redirection should 108 | take place, return a new Request to allow http_error_30x to 109 | perform the redirect. Otherwise, raise HTTPError if no-one 110 | else should try to handle this url. Return None if you can't 111 | but another Handler might. 112 | """ 113 | m = req.get_method() 114 | if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") 115 | or code in (301, 302, 303) and m == "POST"): 116 | # Strictly (according to RFC 2616), 301 or 302 in response 117 | # to a POST MUST NOT cause a redirection without confirmation 118 | # from the user (of urllib2, in this case). In practice, 119 | # essentially all clients do redirect in this case, so we 120 | # do the same. 121 | # be conciliant with URIs containing a space 122 | newurl = newurl.replace(' ', '%20') 123 | newheaders = dict((k, v) for k, v in req.headers.items() 124 | if k.lower() not in ( 125 | "content-length", "content-type") 126 | ) 127 | return urllib2.Request(newurl, 128 | headers=newheaders, 129 | origin_req_host=req.get_origin_req_host(), 130 | unverifiable=True) 131 | else: 132 | raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) 133 | 134 | class StreamingHTTPHandler(urllib2.HTTPHandler): 135 | """Subclass of `urllib2.HTTPHandler` that uses 136 | StreamingHTTPConnection as its http connection class.""" 137 | 138 | handler_order = urllib2.HTTPHandler.handler_order - 1 139 | 140 | def http_open(self, req): 141 | """Open a StreamingHTTPConnection for the given request""" 142 | return self.do_open(StreamingHTTPConnection, req) 143 | 144 | def http_request(self, req): 145 | """Handle a HTTP request. Make sure that Content-Length is specified 146 | if we're using an interable value""" 147 | # Make sure that if we're using an iterable object as the request 148 | # body, that we've also specified Content-Length 149 | if req.has_data(): 150 | data = req.get_data() 151 | if hasattr(data, 'read') or hasattr(data, 'next'): 152 | if not req.has_header('Content-length'): 153 | raise ValueError( 154 | "No Content-Length specified for iterable body") 155 | return urllib2.HTTPHandler.do_request_(self, req) 156 | 157 | if hasattr(httplib, 'HTTPS'): 158 | class StreamingHTTPSConnection(_StreamingHTTPMixin, 159 | httplib.HTTPSConnection): 160 | """Subclass of `httplib.HTTSConnection` that overrides the `send()` 161 | method to support iterable body objects""" 162 | 163 | class StreamingHTTPSHandler(urllib2.HTTPSHandler): 164 | """Subclass of `urllib2.HTTPSHandler` that uses 165 | StreamingHTTPSConnection as its http connection class.""" 166 | 167 | handler_order = urllib2.HTTPSHandler.handler_order - 1 168 | 169 | def https_open(self, req): 170 | return self.do_open(StreamingHTTPSConnection, req) 171 | 172 | def https_request(self, req): 173 | # Make sure that if we're using an iterable object as the request 174 | # body, that we've also specified Content-Length 175 | if req.has_data(): 176 | data = req.get_data() 177 | if hasattr(data, 'read') or hasattr(data, 'next'): 178 | if not req.has_header('Content-length'): 179 | raise ValueError( 180 | "No Content-Length specified for iterable body") 181 | return urllib2.HTTPSHandler.do_request_(self, req) 182 | 183 | 184 | def get_handlers(): 185 | handlers = [StreamingHTTPHandler, StreamingHTTPRedirectHandler] 186 | if hasattr(httplib, "HTTPS"): 187 | handlers.append(StreamingHTTPSHandler) 188 | return handlers 189 | 190 | def register_openers(): 191 | """Register the streaming http handlers in the global urllib2 default 192 | opener object. 193 | 194 | Returns the created OpenerDirector object.""" 195 | opener = urllib2.build_opener(*get_handlers()) 196 | 197 | urllib2.install_opener(opener) 198 | 199 | return opener 200 | -------------------------------------------------------------------------------- /dropbox/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | The dropbox.auth module is responsible for making OAuth work for the Dropbox 3 | Client API. It glues together all the separate parts of the Python OAuth 4 | reference implementation and gives a nicer API to it. You'll pass a 5 | configure dropbox.auth.Authenticator object to dropbox.client.DropboxClient 6 | in order to work with the API. 7 | """ 8 | 9 | import httplib 10 | import urllib 11 | import simplejson as json 12 | from oauth import oauth 13 | from ConfigParser import SafeConfigParser 14 | 15 | REALM="No Realm" 16 | HTTP_DEBUG_LEVEL=0 17 | 18 | class SimpleOAuthClient(oauth.OAuthClient): 19 | """ 20 | An implementation of the oauth.OAuthClient class providing OAuth services 21 | for the Dropbox Client API. You shouldn't have to use this, but if you need 22 | to implement your own OAuth, then this is where to look. 23 | 24 | One setting of interest is the HTTP_DEBUG_LEVEL, which you can set to a 25 | larger number to get detailed HTTP output. 26 | """ 27 | def __init__(self, server, port=httplib.HTTP_PORT, request_token_url='', access_token_url='', authorization_url=''): 28 | self.server = server 29 | self.port = port 30 | self.request_token_url = request_token_url 31 | self.access_token_url = access_token_url 32 | self.authorization_url = authorization_url 33 | self.connection = httplib.HTTPConnection(self.server, int(self.port)) 34 | self.connection.set_debuglevel(HTTP_DEBUG_LEVEL) 35 | 36 | def fetch_request_token(self, oauth_request): 37 | """Called by oauth to fetch the request token from Dropbox. Returns an OAuthToken.""" 38 | self.connection.request(oauth_request.http_method, 39 | self.request_token_url, 40 | headers=oauth_request.to_header()) 41 | response = self.connection.getresponse() 42 | data = response.read() 43 | assert response.status == 200, "Invalid response code %d : %r" % (response.status, data) 44 | return oauth.OAuthToken.from_string(data) 45 | 46 | def fetch_access_token(self, oauth_request, trusted_url=None): 47 | """Used to get a access token from Drobpox using the headers. Returns an OauthToken.""" 48 | url = trusted_url if trusted_url else self.access_token_url 49 | 50 | self.connection.request(oauth_request.http_method, url, 51 | headers=oauth_request.to_header()) 52 | 53 | response = self.connection.getresponse() 54 | assert response.status == 200, "Invalid response code %d" % response.status 55 | if trusted_url: 56 | token = json.loads(response.read()) 57 | token['token'] = str(token['token']) 58 | token['secret'] = str(token['secret']) 59 | return oauth.OAuthToken(token['token'], token['secret']) 60 | else: 61 | return oauth.OAuthToken.from_string(response.read()) 62 | 63 | def authorize_token(self, oauth_request): 64 | """ 65 | This is not used in the Drobpox API. 66 | """ 67 | raise NotImplementedError("authorize_token is not implemented via OAuth.") 68 | 69 | def access_resource(self, oauth_request): 70 | """ 71 | Not used by the Dropbox API. 72 | """ 73 | raise NotImplementedError("access_resource is not implemented via OAuth.") 74 | 75 | 76 | 77 | 78 | class Authenticator(object): 79 | """ 80 | The Authenticator puts a thin gloss over the oauth.oauth Python library 81 | so that the dropbox.client.DropboxClient doesn't need to know much about 82 | your configuration and OAuth operations. 83 | 84 | It uses a configuration file in the standard .ini format that ConfigParser 85 | understands. A sample configuration is included in config/testing.ini 86 | which you should copy and put in your own consumer keys and secrets. 87 | 88 | Because different installations may want to store these configurations 89 | differently, you aren't required to configure an Authenticator via 90 | the .ini method. As long as you configure it with a dict with the 91 | same keys you'll be fine. 92 | """ 93 | 94 | def __init__(self, config): 95 | """ 96 | Configures the Authenticator with all the required settings in config. 97 | Typically you'll use Authenticator.load_config() to load these from 98 | a .ini file and then pass the returned dict to here. 99 | """ 100 | self.client = SimpleOAuthClient(config['server'], 101 | config['port'], 102 | config['request_token_url'], 103 | config['access_token_url'], 104 | config['authorization_url']) 105 | 106 | self.trusted_access_token_url = config.get('trusted_access_token_url', None) 107 | 108 | self.consumer = oauth.OAuthConsumer(config['consumer_key'], 109 | config['consumer_secret']) 110 | 111 | self.signature_method_hmac_sha1 = oauth.OAuthSignatureMethod_HMAC_SHA1() 112 | 113 | self.config = config 114 | 115 | 116 | @classmethod 117 | def load_config(self, filename): 118 | """ 119 | Loads a configuration .ini file, and then pulls out the 'auth' key 120 | to make a dict you can pass to Authenticator(). 121 | """ 122 | config = SafeConfigParser() 123 | config_file = open(filename, "r") 124 | config.readfp(config_file) 125 | return dict(config.items('auth')) 126 | 127 | def build_authorize_url(self, req_token, callback=None): 128 | """ 129 | When you send a user to authorize a request token you created, you need 130 | to make the URL correctly. This is the method you use. It will 131 | return a URL that you can then redirect a user at so they can login to 132 | Dropbox and approve this request key. 133 | """ 134 | if callback: 135 | oauth_callback = "&%s" % urllib.urlencode({'oauth_callback': callback}) 136 | else: 137 | oauth_callback = "" 138 | 139 | return "%s?oauth_token=%s%s" % (self.config['authorization_url'], req_token.key, oauth_callback) 140 | 141 | 142 | def obtain_request_token(self): 143 | """ 144 | This is your first step in the OAuth process. You call this to get a 145 | request_token from the Dropbox server that you can then use with 146 | Authenticator.build_authorize_url() to get the user to authorize it. 147 | After it's authorized you use this token with 148 | Authenticator.obtain_access_token() to get an access token. 149 | 150 | NOTE: You should only need to do this once for each user, and then you 151 | store the access token for that user for later operations. 152 | """ 153 | self.oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, 154 | http_url=self.client.request_token_url) 155 | 156 | self.oauth_request.sign_request(self.signature_method_hmac_sha1, self.consumer, None) 157 | 158 | token = self.client.fetch_request_token(self.oauth_request) 159 | 160 | return token 161 | 162 | 163 | def obtain_access_token(self, token, verifier): 164 | """ 165 | After you get a request token, and then send the user to the authorize 166 | URL, you can use the authorized access token with this method to get the 167 | access token to use for future operations. Store this access token with 168 | the user so that you can reuse it on future operations. 169 | 170 | The verifier parameter is not currently used, but will be enforced in 171 | the future to follow the 1.0a version of OAuth. Make it blank for now. 172 | """ 173 | self.oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, 174 | token=token, 175 | http_url=self.client.access_token_url, 176 | verifier=verifier) 177 | self.oauth_request.sign_request(self.signature_method_hmac_sha1, self.consumer, token) 178 | 179 | token = self.client.fetch_access_token(self.oauth_request) 180 | 181 | return token 182 | 183 | def obtain_trusted_access_token(self, user_name, user_password): 184 | """ 185 | This is for trusted partners using a constrained device such as a mobile 186 | or other embedded system. It allows them to use the user's password 187 | directly to obtain an access token, rather than going through all the 188 | usual OAuth steps. 189 | """ 190 | assert user_name, "The user name is required." 191 | assert user_password, "The user password is required." 192 | assert self.trusted_access_token_url, "You must set trusted_access_token_url in your config file." 193 | parameters = {'email': user_name, 'password': user_password} 194 | params = urllib.urlencode(parameters) 195 | assert params, "Didn't get a valid params." 196 | 197 | url = self.trusted_access_token_url + "?" + params 198 | self.oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, http_url=url, parameters=parameters) 199 | self.oauth_request.sign_request(self.signature_method_hmac_sha1, 200 | self.consumer, None) 201 | token = self.client.fetch_access_token(self.oauth_request, url) 202 | return token 203 | 204 | def build_access_headers(self, method, token, resource_url, parameters, callback=None): 205 | """ 206 | This is used internally to build all the required OAuth parameters and 207 | signatures to make an OAuth request. It's provided for debugging 208 | purposes. 209 | """ 210 | params = parameters.copy() 211 | 212 | if callback: 213 | params['oauth_callback'] = callback 214 | 215 | self.oauth_request = oauth.OAuthRequest.from_consumer_and_token(self.consumer, 216 | token=token, http_method=method, 217 | http_url=resource_url, 218 | parameters=parameters) 219 | 220 | self.oauth_request.sign_request(self.signature_method_hmac_sha1, self.consumer, token) 221 | return self.oauth_request.to_header(), params 222 | 223 | -------------------------------------------------------------------------------- /dropbox/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | The main client API you'll be working with most often. You'll need to 3 | configure a dropbox.client.Authenticator for this to work, but otherwise 4 | it's fairly self-explanatory. 5 | """ 6 | 7 | from dropbox import rest 8 | import urllib 9 | import urllib2 10 | import poster 11 | import httplib 12 | 13 | API_VERSION=0 14 | HASH_BLOCK_SIZE=10*1024 15 | 16 | class DropboxClient(object): 17 | """ 18 | The main access point of doing REST calls on Dropbox. You use it 19 | by first creating and configuring a dropbox.auth.Authenticator, 20 | and then configuring a DropboxClient to talk to the service. The 21 | DropboxClient then does all the work of properly calling each API 22 | with the correct OAuth authentication. 23 | """ 24 | 25 | 26 | def __init__(self, api_host, content_host, port, auth, token): 27 | """ 28 | The api_host and content_host are normally 'api.dropbox.com' and 29 | 'api-content.dropbox.com' and will use the same port. 30 | The auth is a dropbox.client.Authenticator that is properly configured. 31 | The token is a valid OAuth `access token` that you got using 32 | dropbox.client.Authenticator.obtain_access_token. 33 | """ 34 | self.api_rest = rest.RESTClient(api_host, port) 35 | self.content_rest = rest.RESTClient(content_host, port) 36 | self.auth = auth 37 | self.token = token 38 | self.api_host = api_host 39 | self.content_host = content_host 40 | self.api_host = api_host 41 | self.port = int(port) 42 | 43 | 44 | def request(self, host, method, target, params, callback): 45 | """ 46 | This is an internal method used to properly craft the url, headers, and 47 | params for a Dropbox API request. It is exposed for you in case you 48 | need craft other API calls not in this library or you want to debug it. 49 | 50 | It is only expected to work for GET or POST parameters. 51 | """ 52 | assert method in ['GET','POST'], "Only 'GET' and 'POST' are allowed for method." 53 | 54 | base = self.build_full_url(host, target) 55 | headers, params = self.auth.build_access_headers(method, self.token, base, params, callback) 56 | 57 | if method == "GET": 58 | url = self.build_url(target, params) 59 | else: 60 | url = self.build_url(target) 61 | 62 | return url, headers, params 63 | 64 | 65 | def account_info(self, status_in_response=False, callback=None): 66 | """ 67 | Retrieve information about the user's account. 68 | 69 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 70 | * status_in_response. Optional. Some clients (e.g., Flash) cannot handle HTTP status codes well. If this parameter is set to true, the service will always return a 200 status and report the relevant status code via additional information in the response body. Default is false. 71 | """ 72 | 73 | params = {'status_in_response': status_in_response} 74 | 75 | url, headers, params = self.request(self.api_host, "GET", "/account/info", params, callback) 76 | 77 | return self.api_rest.GET(url, headers) 78 | 79 | 80 | def put_file(self, root, to_path, file_obj): 81 | """ 82 | Retrieve or upload file contents relative to the user's Dropbox root or 83 | the application's sandbox directory within the user's Dropbox. 84 | 85 | * root is one of "dropbox" or "sandbox", most clients will use "sandbox". 86 | * to_path is the `directory` path to put the file (NOT the full path). 87 | * file_obj is an open and ready to read file object that will be uploaded. 88 | 89 | The filename is taken from the file_obj name currently, so you can't 90 | have the local file named differently than it's target name. This may 91 | change in future versions. 92 | 93 | Finally, this function is not terribly efficient due to Python's 94 | HTTPConnection requiring all of the file be read into ram for the POST. 95 | Future versions will avoid this problem. 96 | """ 97 | assert root in ["dropbox", "sandbox"] 98 | 99 | path = "/files/%s%s" % (root, to_path) 100 | 101 | params = { "file" : file_obj.name, } 102 | 103 | url, headers, params = self.request(self.content_host, "POST", path, params, None) 104 | 105 | params['file'] = file_obj 106 | data, mp_headers = poster.encode.multipart_encode(params) 107 | if 'Content-Length' in mp_headers: 108 | mp_headers['Content-Length'] = str(mp_headers['Content-Length']) 109 | headers.update(mp_headers) 110 | 111 | conn = httplib.HTTPConnection(self.content_host, self.port) 112 | conn.request("POST", url, "".join(data), headers) 113 | 114 | resp = rest.RESTResponse(conn.getresponse()) 115 | conn.close() 116 | file_obj.close() 117 | 118 | return resp 119 | 120 | 121 | def get_file(self, root, from_path): 122 | """ 123 | Retrieves a file from the given root ("dropbox" or "sandbox") based on 124 | from_path as the `full path` to the file. Unlike the other calls, this 125 | one returns a raw HTTPResponse with the connection open. You should 126 | do your read and any processing you need and then close it. 127 | """ 128 | assert root in ["dropbox", "sandbox"] 129 | 130 | path = "/files/%s%s" % (root, from_path) 131 | 132 | url, headers, params = self.request(self.content_host, "GET", path, {}, None) 133 | return self.content_rest.request("GET", url, headers=headers, raw_response=True) 134 | 135 | 136 | def file_copy(self, root, from_path, to_path, callback=None): 137 | """ 138 | Copy a file or folder to a new location. 139 | 140 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 141 | * from_path. Required. from_path specifies either a file or folder to be copied to the location specified by to_path. This path is interpreted relative to the location specified by root. 142 | * root. Required. Specify the root relative to which from_path and to_path are specified. Valid values are dropbox and sandbox. 143 | * to_path. Required. to_path specifies the destination path including the new name for file or folder. This path is interpreted relative to the location specified by root. 144 | """ 145 | assert root in ["dropbox", "sandbox"] 146 | 147 | params = {'root': root, 'from_path': from_path, 'to_path': to_path} 148 | 149 | url, headers, params = self.request(self.api_host, "POST", "/fileops/copy", params, callback) 150 | 151 | return self.api_rest.POST(url, params, headers) 152 | 153 | 154 | def file_create_folder(self, root, path, callback=None): 155 | """ 156 | Create a folder relative to the user's Dropbox root or the user's application sandbox folder. 157 | 158 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 159 | * path. Required. The path to the new folder to create, relative to root. 160 | * root. Required. Specify the root relative to which path is specified. Valid values are dropbox and sandbox. 161 | """ 162 | assert root in ["dropbox", "sandbox"] 163 | params = {'root': root, 'path': path} 164 | 165 | url, headers, params = self.request(self.api_host, "POST", "/fileops/create_folder", params, callback) 166 | 167 | return self.api_rest.POST(url, params, headers) 168 | 169 | 170 | def file_delete(self, root, path, callback=None): 171 | """ 172 | Delete a file or folder. 173 | 174 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 175 | * path. Required. path specifies either a file or folder to be deleted. This path is interpreted relative to the location specified by root. 176 | * root. Required. Specify the root relative to which path is specified. Valid values are dropbox and sandbox. 177 | """ 178 | assert root in ["dropbox", "sandbox"] 179 | 180 | params = {'root': root, 'path': path} 181 | 182 | url, headers, params = self.request(self.api_host, "POST", "/fileops/delete", params, 183 | callback) 184 | 185 | return self.api_rest.POST(url, params, headers) 186 | 187 | 188 | def file_move(self, root, from_path, to_path, callback=None): 189 | """ 190 | Move a file or folder to a new location. 191 | 192 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 193 | * from_path. Required. from_path specifies either a file or folder to be copied to the location specified by to_path. This path is interpreted relative to the location specified by root. 194 | * root. Required. Specify the root relative to which from_path and to_path are specified. Valid values are dropbox and sandbox. 195 | * to_path. Required. to_path specifies the destination path including the new name for file or folder. This path is interpreted relative to the location specified by root. 196 | """ 197 | assert root in ["dropbox", "sandbox"] 198 | 199 | params = {'root': root, 'from_path': from_path, 'to_path': to_path} 200 | 201 | url, headers, params = self.request(self.api_host, "POST", "/fileops/move", params, callback) 202 | 203 | return self.api_rest.POST(url, params, headers) 204 | 205 | 206 | def metadata(self, root, path, file_limit=10000, hash=None, list=True, status_in_response=False, callback=None): 207 | """ 208 | The metadata API location provides the ability to retrieve file and 209 | folder metadata and manipulate the directory structure by moving or 210 | deleting files and folders. 211 | 212 | * callback. Optional. The server will wrap its response of format inside a call to the argument specified by callback. Value must contains only alphanumeric characters and underscores. 213 | * file_limit. Optional. Default is 10000. When listing a directory, the service will not report listings containing more than file_limit files and will instead respond with a 406 (Not Acceptable) status response. 214 | * hash. Optional. Listing return values include a hash representing the state of the directory's contents. If you provide this argument to the metadata call, you give the service an opportunity to respond with a "304 Not Modified" status code instead of a full (potentially very large) directory listing. This argument is ignored if the specified path is associated with a file or if list=false. 215 | * list. Optional. The strings true and false are valid values. true is the default. If true, this call returns a list of metadata representations for the contents of the directory. If false, this call returns the metadata for the directory itself. 216 | * status_in_response. Optional. Some clients (e.g., Flash) cannot handle HTTP status codes well. If this parameter is set to true, the service will always return a 200 status and report the relevant status code via additional information in the response body. Default is false. 217 | """ 218 | 219 | assert root in ["dropbox", "sandbox"] 220 | 221 | path = "/metadata/%s%s" % (root, path) 222 | 223 | params = {'file_limit': file_limit, 224 | 'list': "true" if list else "false", 225 | 'status_in_response': status_in_response} 226 | if hash is not None: 227 | params['hash'] = hash 228 | 229 | url, headers, params = self.request(self.api_host, "GET", path, params, callback) 230 | 231 | return self.api_rest.GET(url, headers) 232 | 233 | def links(self, root, path): 234 | assert root in ["dropbox", "sandbox"] 235 | path = "/links/%s%s" % (root, path) 236 | return self.build_full_url(self.api_host, path) 237 | 238 | 239 | def build_url(self, url, params=None): 240 | """Used internally to build the proper URL from parameters and the API_VERSION.""" 241 | if type(url) == unicode: 242 | url = url.encode("utf8") 243 | target_path = urllib2.quote(url) 244 | 245 | if params: 246 | return "/%d%s?%s" % (API_VERSION, target_path, urllib.urlencode(params)) 247 | else: 248 | return "/%d%s" % (API_VERSION, target_path) 249 | 250 | 251 | def build_full_url(self, host, target): 252 | """Used internally to construct the complete URL to the service.""" 253 | port = "" if self.port == 80 else ":%d" % self.port 254 | base_full_url = "http://%s%s" % (host, port) 255 | return base_full_url + self.build_url(target) 256 | 257 | 258 | def account(self, email='', password='', first_name='', last_name='', source=None): 259 | params = {'email': email, 'password': password, 260 | 'first_name': first_name, 'last_name': last_name} 261 | 262 | url, headers, params = self.request(self.api_host, "POST", "/account", 263 | params, None) 264 | 265 | return self.api_rest.POST(url, params, headers) 266 | 267 | 268 | def thumbnail(self, root, from_path, size='small'): 269 | assert root in ["dropbox", "sandbox"] 270 | assert size in ['small','medium','large'] 271 | 272 | path = "/thumbnails/%s%s" % (root, from_path) 273 | 274 | url, headers, params = self.request(self.content_host, "GET", path, 275 | {'size': size}, None) 276 | return self.content_rest.request("GET", url, headers=headers, raw_response=True) 277 | 278 | -------------------------------------------------------------------------------- /poster/encode.py: -------------------------------------------------------------------------------- 1 | """multipart/form-data encoding module 2 | 3 | This module provides functions that faciliate encoding name/value pairs 4 | as multipart/form-data suitable for a HTTP POST or PUT request. 5 | 6 | multipart/form-data is the standard way to upload files over HTTP""" 7 | 8 | __all__ = ['gen_boundary', 'encode_and_quote', 'MultipartParam', 9 | 'encode_string', 'encode_file_header', 'get_body_size', 'get_headers', 10 | 'multipart_encode'] 11 | 12 | try: 13 | import uuid 14 | def gen_boundary(): 15 | """Returns a random string to use as the boundary for a message""" 16 | return uuid.uuid4().hex 17 | except ImportError: 18 | import random, sha 19 | def gen_boundary(): 20 | """Returns a random string to use as the boundary for a message""" 21 | bits = random.getrandbits(160) 22 | return sha.new(str(bits)).hexdigest() 23 | 24 | import urllib, re, os, mimetypes 25 | try: 26 | from email.header import Header 27 | except ImportError: 28 | # Python 2.4 29 | from email.Header import Header 30 | 31 | def encode_and_quote(data): 32 | """If ``data`` is unicode, return urllib.quote_plus(data.encode("utf-8")) 33 | otherwise return urllib.quote_plus(data)""" 34 | if data is None: 35 | return None 36 | 37 | if isinstance(data, unicode): 38 | data = data.encode("utf-8") 39 | return urllib.quote_plus(data) 40 | 41 | def _strify(s): 42 | """If s is a unicode string, encode it to UTF-8 and return the results, 43 | otherwise return str(s), or None if s is None""" 44 | if s is None: 45 | return None 46 | if isinstance(s, unicode): 47 | return s.encode("utf-8") 48 | return str(s) 49 | 50 | class MultipartParam(object): 51 | """Represents a single parameter in a multipart/form-data request 52 | 53 | ``name`` is the name of this parameter. 54 | 55 | If ``value`` is set, it must be a string or unicode object to use as the 56 | data for this parameter. 57 | 58 | If ``filename`` is set, it is what to say that this parameter's filename 59 | is. Note that this does not have to be the actual filename any local file. 60 | 61 | If ``filetype`` is set, it is used as the Content-Type for this parameter. 62 | If unset it defaults to "text/plain; charset=utf8" 63 | 64 | If ``filesize`` is set, it specifies the length of the file ``fileobj`` 65 | 66 | If ``fileobj`` is set, it must be a file-like object that supports 67 | .read(). 68 | 69 | Both ``value`` and ``fileobj`` must not be set, doing so will 70 | raise a ValueError assertion. 71 | 72 | If ``fileobj`` is set, and ``filesize`` is not specified, then 73 | the file's size will be determined first by stat'ing ``fileobj``'s 74 | file descriptor, and if that fails, by seeking to the end of the file, 75 | recording the current position as the size, and then by seeking back to the 76 | beginning of the file. 77 | 78 | ``cb`` is a callable which will be called from iter_encode with (self, 79 | current, total), representing the current parameter, current amount 80 | transferred, and the total size. 81 | """ 82 | def __init__(self, name, value=None, filename=None, filetype=None, 83 | filesize=None, fileobj=None, cb=None): 84 | self.name = Header(name).encode() 85 | self.value = _strify(value) 86 | if filename is None: 87 | self.filename = None 88 | else: 89 | if isinstance(filename, unicode): 90 | # Encode with XML entities 91 | self.filename = filename.encode("ascii", "xmlcharrefreplace") 92 | else: 93 | self.filename = str(filename) 94 | self.filename = self.filename.encode("string_escape").\ 95 | replace('"', '\\"') 96 | self.filetype = _strify(filetype) 97 | 98 | self.filesize = filesize 99 | self.fileobj = fileobj 100 | self.cb = cb 101 | 102 | if self.value is not None and self.fileobj is not None: 103 | raise ValueError("Only one of value or fileobj may be specified") 104 | 105 | if fileobj is not None and filesize is None: 106 | # Try and determine the file size 107 | try: 108 | self.filesize = os.fstat(fileobj.fileno()).st_size 109 | except (OSError, AttributeError): 110 | try: 111 | fileobj.seek(0, 2) 112 | self.filesize = fileobj.tell() 113 | fileobj.seek(0) 114 | except: 115 | raise ValueError("Could not determine filesize") 116 | 117 | def __cmp__(self, other): 118 | attrs = ['name', 'value', 'filename', 'filetype', 'filesize', 'fileobj'] 119 | myattrs = [getattr(self, a) for a in attrs] 120 | oattrs = [getattr(other, a) for a in attrs] 121 | return cmp(myattrs, oattrs) 122 | 123 | def reset(self): 124 | if self.fileobj is not None: 125 | self.fileobj.seek(0) 126 | elif self.value is None: 127 | raise ValueError("Don't know how to reset this parameter") 128 | 129 | @classmethod 130 | def from_file(cls, paramname, filename): 131 | """Returns a new MultipartParam object constructed from the local 132 | file at ``filename``. 133 | 134 | ``filesize`` is determined by os.path.getsize(``filename``) 135 | 136 | ``filetype`` is determined by mimetypes.guess_type(``filename``)[0] 137 | 138 | ``filename`` is set to os.path.basename(``filename``) 139 | """ 140 | 141 | return cls(paramname, filename=os.path.basename(filename), 142 | filetype=mimetypes.guess_type(filename)[0], 143 | filesize=os.path.getsize(filename), 144 | fileobj=open(filename, "rb")) 145 | 146 | @classmethod 147 | def from_params(cls, params): 148 | """Returns a list of MultipartParam objects from a sequence of 149 | name, value pairs, MultipartParam instances, 150 | or from a mapping of names to values 151 | 152 | The values may be strings or file objects, or MultipartParam objects. 153 | MultipartParam object names must match the given names in the 154 | name,value pairs or mapping, if applicable.""" 155 | if hasattr(params, 'items'): 156 | params = params.items() 157 | 158 | retval = [] 159 | for item in params: 160 | if isinstance(item, cls): 161 | retval.append(item) 162 | continue 163 | name, value = item 164 | if isinstance(value, cls): 165 | assert value.name == name 166 | retval.append(value) 167 | continue 168 | if hasattr(value, 'read'): 169 | # Looks like a file object 170 | filename = getattr(value, 'name', None) 171 | if filename is not None: 172 | filetype = mimetypes.guess_type(filename)[0] 173 | else: 174 | filetype = None 175 | 176 | retval.append(cls(name=name, filename=filename, 177 | filetype=filetype, fileobj=value)) 178 | else: 179 | retval.append(cls(name, value)) 180 | return retval 181 | 182 | def encode_hdr(self, boundary): 183 | """Returns the header of the encoding of this parameter""" 184 | boundary = encode_and_quote(boundary) 185 | 186 | headers = ["--%s" % boundary] 187 | 188 | if self.filename: 189 | disposition = 'form-data; name="%s"; filename="%s"' % (self.name, 190 | self.filename) 191 | else: 192 | disposition = 'form-data; name="%s"' % self.name 193 | 194 | headers.append("Content-Disposition: %s" % disposition) 195 | 196 | if self.filetype: 197 | filetype = self.filetype 198 | else: 199 | filetype = "text/plain; charset=utf-8" 200 | 201 | headers.append("Content-Type: %s" % filetype) 202 | 203 | headers.append("") 204 | headers.append("") 205 | 206 | return "\r\n".join(headers) 207 | 208 | def encode(self, boundary): 209 | """Returns the string encoding of this parameter""" 210 | if self.value is None: 211 | value = self.fileobj.read() 212 | else: 213 | value = self.value 214 | 215 | if re.search("^--%s$" % re.escape(boundary), value, re.M): 216 | raise ValueError("boundary found in encoded string") 217 | 218 | return "%s%s\r\n" % (self.encode_hdr(boundary), value) 219 | 220 | def iter_encode(self, boundary, blocksize=4096): 221 | """Yields the encoding of this parameter 222 | If self.fileobj is set, then blocks of ``blocksize`` bytes are read and 223 | yielded.""" 224 | total = self.get_size(boundary) 225 | current = 0 226 | if self.value is not None: 227 | block = self.encode(boundary) 228 | current += len(block) 229 | yield block 230 | if self.cb: 231 | self.cb(self, current, total) 232 | else: 233 | block = self.encode_hdr(boundary) 234 | current += len(block) 235 | yield block 236 | if self.cb: 237 | self.cb(self, current, total) 238 | last_block = "" 239 | encoded_boundary = "--%s" % encode_and_quote(boundary) 240 | boundary_exp = re.compile("^%s$" % re.escape(encoded_boundary), 241 | re.M) 242 | while True: 243 | block = self.fileobj.read(blocksize) 244 | if not block: 245 | current += 2 246 | yield "\r\n" 247 | if self.cb: 248 | self.cb(self, current, total) 249 | break 250 | last_block += block 251 | if boundary_exp.search(last_block): 252 | raise ValueError("boundary found in file data") 253 | last_block = last_block[-len(encoded_boundary)-2:] 254 | current += len(block) 255 | yield block 256 | if self.cb: 257 | self.cb(self, current, total) 258 | 259 | def get_size(self, boundary): 260 | """Returns the size in bytes that this param will be when encoded 261 | with the given boundary.""" 262 | if self.filesize is not None: 263 | valuesize = self.filesize 264 | else: 265 | valuesize = len(self.value) 266 | 267 | return len(self.encode_hdr(boundary)) + 2 + valuesize 268 | 269 | def encode_string(boundary, name, value): 270 | """Returns ``name`` and ``value`` encoded as a multipart/form-data 271 | variable. ``boundary`` is the boundary string used throughout 272 | a single request to separate variables.""" 273 | 274 | return MultipartParam(name, value).encode(boundary) 275 | 276 | def encode_file_header(boundary, paramname, filesize, filename=None, 277 | filetype=None): 278 | """Returns the leading data for a multipart/form-data field that contains 279 | file data. 280 | 281 | ``boundary`` is the boundary string used throughout a single request to 282 | separate variables. 283 | 284 | ``paramname`` is the name of the variable in this request. 285 | 286 | ``filesize`` is the size of the file data. 287 | 288 | ``filename`` if specified is the filename to give to this field. This 289 | field is only useful to the server for determining the original filename. 290 | 291 | ``filetype`` if specified is the MIME type of this file. 292 | 293 | The actual file data should be sent after this header has been sent. 294 | """ 295 | 296 | return MultipartParam(paramname, filesize=filesize, filename=filename, 297 | filetype=filetype).encode_hdr(boundary) 298 | 299 | def get_body_size(params, boundary): 300 | """Returns the number of bytes that the multipart/form-data encoding 301 | of ``params`` will be.""" 302 | size = sum(p.get_size(boundary) for p in MultipartParam.from_params(params)) 303 | return size + len(boundary) + 6 304 | 305 | def get_headers(params, boundary): 306 | """Returns a dictionary with Content-Type and Content-Length headers 307 | for the multipart/form-data encoding of ``params``.""" 308 | headers = {} 309 | boundary = urllib.quote_plus(boundary) 310 | headers['Content-Type'] = "multipart/form-data; boundary=%s" % boundary 311 | headers['Content-Length'] = str(get_body_size(params, boundary)) 312 | return headers 313 | 314 | class multipart_yielder: 315 | def __init__(self, params, boundary, cb): 316 | self.params = params 317 | self.boundary = boundary 318 | self.cb = cb 319 | 320 | self.i = 0 321 | self.p = None 322 | self.param_iter = None 323 | self.current = 0 324 | self.total = get_body_size(params, boundary) 325 | 326 | def __iter__(self): 327 | return self 328 | 329 | def next(self): 330 | """generator function to yield multipart/form-data representation 331 | of parameters""" 332 | if self.param_iter is not None: 333 | try: 334 | block = self.param_iter.next() 335 | self.current += len(block) 336 | if self.cb: 337 | self.cb(self.p, self.current, self.total) 338 | return block 339 | except StopIteration: 340 | self.p = None 341 | self.param_iter = None 342 | 343 | if self.i is None: 344 | raise StopIteration 345 | elif self.i >= len(self.params): 346 | self.param_iter = None 347 | self.p = None 348 | self.i = None 349 | block = "--%s--\r\n" % self.boundary 350 | self.current += len(block) 351 | if self.cb: 352 | self.cb(self.p, self.current, self.total) 353 | return block 354 | 355 | self.p = self.params[self.i] 356 | self.param_iter = self.p.iter_encode(self.boundary) 357 | self.i += 1 358 | return self.next() 359 | 360 | def reset(self): 361 | self.i = 0 362 | self.current = 0 363 | for param in self.params: 364 | param.reset() 365 | 366 | def multipart_encode(params, boundary=None, cb=None): 367 | """Encode ``params`` as multipart/form-data. 368 | 369 | ``params`` should be a sequence of (name, value) pairs or MultipartParam 370 | objects, or a mapping of names to values. 371 | Values are either strings parameter values, or file-like objects to use as 372 | the parameter value. The file-like objects must support .read() and either 373 | .fileno() or both .seek() and .tell(). 374 | 375 | If ``boundary`` is set, then it as used as the MIME boundary. Otherwise 376 | a randomly generated boundary will be used. In either case, if the 377 | boundary string appears in the parameter values a ValueError will be 378 | raised. 379 | 380 | If ``cb`` is set, it should be a callback which will get called as blocks 381 | of data are encoded. It will be called with (param, current, total), 382 | indicating the current parameter being encoded, the current amount encoded, 383 | and the total amount to encode. 384 | 385 | Returns a tuple of `datagen`, `headers`, where `datagen` is a 386 | generator that will yield blocks of data that make up the encoded 387 | parameters, and `headers` is a dictionary with the assoicated 388 | Content-Type and Content-Length headers. 389 | 390 | Examples: 391 | 392 | >>> datagen, headers = multipart_encode( [("key", "value1"), ("key", "value2")] ) 393 | >>> s = "".join(datagen) 394 | >>> assert "value2" in s and "value1" in s 395 | 396 | >>> p = MultipartParam("key", "value2") 397 | >>> datagen, headers = multipart_encode( [("key", "value1"), p] ) 398 | >>> s = "".join(datagen) 399 | >>> assert "value2" in s and "value1" in s 400 | 401 | >>> datagen, headers = multipart_encode( {"key": "value1"} ) 402 | >>> s = "".join(datagen) 403 | >>> assert "value2" not in s and "value1" in s 404 | 405 | """ 406 | if boundary is None: 407 | boundary = gen_boundary() 408 | else: 409 | boundary = urllib.quote_plus(boundary) 410 | 411 | headers = get_headers(params, boundary) 412 | params = MultipartParam.from_params(params) 413 | 414 | return multipart_yielder(params, boundary, cb), headers 415 | -------------------------------------------------------------------------------- /oauth/oauth.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License 3 | 4 | Copyright (c) 2007 Leah Culver 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | """ 24 | 25 | import cgi 26 | import urllib 27 | import time 28 | import random 29 | import urlparse 30 | import hmac 31 | import binascii 32 | 33 | 34 | VERSION = '1.0' # Hi Blaine! 35 | HTTP_METHOD = 'GET' 36 | SIGNATURE_METHOD = 'PLAINTEXT' 37 | 38 | 39 | class OAuthError(RuntimeError): 40 | """Generic exception class.""" 41 | def __init__(self, message='OAuth error occured.'): 42 | self.message = message 43 | 44 | def build_authenticate_header(realm=''): 45 | """Optional WWW-Authenticate header (401 error)""" 46 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 47 | 48 | def escape(s): 49 | """Escape a URL including any /.""" 50 | return urllib.quote(s, safe='~') 51 | 52 | def _utf8_str(s): 53 | """Convert unicode to utf-8.""" 54 | if isinstance(s, unicode): 55 | return s.encode("utf-8") 56 | else: 57 | return str(s) 58 | 59 | def generate_timestamp(): 60 | """Get seconds since epoch (UTC).""" 61 | return int(time.time()) 62 | 63 | def generate_nonce(length=8): 64 | """Generate pseudorandom number.""" 65 | return ''.join([str(random.randint(0, 9)) for i in range(length)]) 66 | 67 | def generate_verifier(length=8): 68 | """Generate pseudorandom number.""" 69 | return ''.join([str(random.randint(0, 9)) for i in range(length)]) 70 | 71 | 72 | class OAuthConsumer(object): 73 | """Consumer of OAuth authentication. 74 | 75 | OAuthConsumer is a data type that represents the identity of the Consumer 76 | via its shared secret with the Service Provider. 77 | 78 | """ 79 | key = None 80 | secret = None 81 | 82 | def __init__(self, key, secret): 83 | self.key = key 84 | self.secret = secret 85 | 86 | 87 | class OAuthToken(object): 88 | """OAuthToken is a data type that represents an End User via either an access 89 | or request token. 90 | 91 | key -- the token 92 | secret -- the token secret 93 | 94 | """ 95 | key = None 96 | secret = None 97 | callback = None 98 | callback_confirmed = None 99 | verifier = None 100 | 101 | def __init__(self, key, secret): 102 | self.key = key 103 | self.secret = secret 104 | 105 | def set_callback(self, callback): 106 | self.callback = callback 107 | self.callback_confirmed = 'true' 108 | 109 | def set_verifier(self, verifier=None): 110 | if verifier is not None: 111 | self.verifier = verifier 112 | else: 113 | self.verifier = generate_verifier() 114 | 115 | def get_callback_url(self): 116 | if self.callback and self.verifier: 117 | # Append the oauth_verifier. 118 | parts = urlparse.urlparse(self.callback) 119 | scheme, netloc, path, params, query, fragment = parts[:6] 120 | if query: 121 | query = '%s&oauth_verifier=%s' % (query, self.verifier) 122 | else: 123 | query = 'oauth_verifier=%s' % self.verifier 124 | return urlparse.urlunparse((scheme, netloc, path, params, 125 | query, fragment)) 126 | return self.callback 127 | 128 | def to_string(self): 129 | data = { 130 | 'oauth_token': self.key, 131 | 'oauth_token_secret': self.secret, 132 | } 133 | if self.callback_confirmed is not None: 134 | data['oauth_callback_confirmed'] = self.callback_confirmed 135 | return urllib.urlencode(data) 136 | 137 | def from_string(s): 138 | """ Returns a token from something like: 139 | oauth_token_secret=xxx&oauth_token=xxx 140 | """ 141 | params = cgi.parse_qs(s, keep_blank_values=False) 142 | key = params['oauth_token'][0] 143 | secret = params['oauth_token_secret'][0] 144 | token = OAuthToken(key, secret) 145 | try: 146 | token.callback_confirmed = params['oauth_callback_confirmed'][0] 147 | except KeyError: 148 | pass # 1.0, no callback confirmed. 149 | return token 150 | from_string = staticmethod(from_string) 151 | 152 | def __str__(self): 153 | return self.to_string() 154 | 155 | 156 | class OAuthRequest(object): 157 | """OAuthRequest represents the request and can be serialized. 158 | 159 | OAuth parameters: 160 | - oauth_consumer_key 161 | - oauth_token 162 | - oauth_signature_method 163 | - oauth_signature 164 | - oauth_timestamp 165 | - oauth_nonce 166 | - oauth_version 167 | - oauth_verifier 168 | ... any additional parameters, as defined by the Service Provider. 169 | """ 170 | parameters = None # OAuth parameters. 171 | http_method = HTTP_METHOD 172 | http_url = None 173 | version = VERSION 174 | 175 | def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): 176 | self.http_method = http_method 177 | self.http_url = http_url 178 | self.parameters = parameters or {} 179 | 180 | def set_parameter(self, parameter, value): 181 | self.parameters[parameter] = value 182 | 183 | def get_parameter(self, parameter): 184 | try: 185 | return self.parameters[parameter] 186 | except: 187 | raise OAuthError('Parameter not found: %s' % parameter) 188 | 189 | def _get_timestamp_nonce(self): 190 | return self.get_parameter('oauth_timestamp'), self.get_parameter( 191 | 'oauth_nonce') 192 | 193 | def get_nonoauth_parameters(self): 194 | """Get any non-OAuth parameters.""" 195 | parameters = {} 196 | for k, v in self.parameters.iteritems(): 197 | # Ignore oauth parameters. 198 | if k.find('oauth_') < 0: 199 | parameters[k] = v 200 | return parameters 201 | 202 | def to_header(self, realm=''): 203 | """Serialize as a header for an HTTPAuth request.""" 204 | auth_header = 'OAuth realm="%s"' % realm 205 | # Add the oauth parameters. 206 | if self.parameters: 207 | for k, v in self.parameters.iteritems(): 208 | if k[:6] == 'oauth_': 209 | auth_header += ', %s="%s"' % (k, escape(str(v))) 210 | return {'Authorization': auth_header} 211 | 212 | def to_postdata(self): 213 | """Serialize as post data for a POST request.""" 214 | return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \ 215 | for k, v in self.parameters.iteritems()]) 216 | 217 | def to_url(self): 218 | """Serialize as a URL for a GET request.""" 219 | return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) 220 | 221 | def get_normalized_parameters(self): 222 | """Return a string that contains the parameters that must be signed.""" 223 | params = self.parameters 224 | try: 225 | # Exclude the signature if it exists. 226 | del params['oauth_signature'] 227 | except: 228 | pass 229 | # Escape key values before sorting. 230 | key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \ 231 | for k,v in params.items()] 232 | # Sort lexicographically, first after key, then after value. 233 | key_values.sort() 234 | # Combine key value pairs into a string. 235 | return '&'.join(['%s=%s' % (k, v) for k, v in key_values]) 236 | 237 | def get_normalized_http_method(self): 238 | """Uppercases the http method.""" 239 | return self.http_method.upper() 240 | 241 | def get_normalized_http_url(self): 242 | """Parses the URL and rebuilds it to be scheme://host/path.""" 243 | parts = urlparse.urlparse(self.http_url) 244 | scheme, netloc, path = parts[:3] 245 | # Exclude default port numbers. 246 | if scheme == 'http' and netloc[-3:] == ':80': 247 | netloc = netloc[:-3] 248 | elif scheme == 'https' and netloc[-4:] == ':443': 249 | netloc = netloc[:-4] 250 | return '%s://%s%s' % (scheme, netloc, path) 251 | 252 | def sign_request(self, signature_method, consumer, token): 253 | """Set the signature parameter to the result of build_signature.""" 254 | # Set the signature method. 255 | self.set_parameter('oauth_signature_method', 256 | signature_method.get_name()) 257 | # Set the signature. 258 | self.set_parameter('oauth_signature', 259 | self.build_signature(signature_method, consumer, token)) 260 | 261 | def build_signature(self, signature_method, consumer, token): 262 | """Calls the build signature method within the signature method.""" 263 | return signature_method.build_signature(self, consumer, token) 264 | 265 | def from_request(http_method, http_url, headers=None, parameters=None, 266 | query_string=None): 267 | """Combines multiple parameter sources.""" 268 | if parameters is None: 269 | parameters = {} 270 | 271 | # Headers 272 | if headers and 'Authorization' in headers: 273 | auth_header = headers['Authorization'] 274 | # Check that the authorization header is OAuth. 275 | if auth_header[:6] == 'OAuth ': 276 | auth_header = auth_header[6:] 277 | try: 278 | # Get the parameters from the header. 279 | header_params = OAuthRequest._split_header(auth_header) 280 | parameters.update(header_params) 281 | except: 282 | raise OAuthError('Unable to parse OAuth parameters from ' 283 | 'Authorization header.') 284 | 285 | # GET or POST query string. 286 | if query_string: 287 | query_params = OAuthRequest._split_url_string(query_string) 288 | parameters.update(query_params) 289 | 290 | # URL parameters. 291 | param_str = urlparse.urlparse(http_url)[4] # query 292 | url_params = OAuthRequest._split_url_string(param_str) 293 | parameters.update(url_params) 294 | 295 | if parameters: 296 | return OAuthRequest(http_method, http_url, parameters) 297 | 298 | return None 299 | from_request = staticmethod(from_request) 300 | 301 | def from_consumer_and_token(oauth_consumer, token=None, 302 | callback=None, verifier=None, http_method=HTTP_METHOD, 303 | http_url=None, parameters=None): 304 | if not parameters: 305 | parameters = {} 306 | 307 | defaults = { 308 | 'oauth_consumer_key': oauth_consumer.key, 309 | 'oauth_timestamp': generate_timestamp(), 310 | 'oauth_nonce': generate_nonce(), 311 | 'oauth_version': OAuthRequest.version, 312 | } 313 | 314 | defaults.update(parameters) 315 | parameters = defaults 316 | 317 | if token: 318 | parameters['oauth_token'] = token.key 319 | if token.callback: 320 | parameters['oauth_callback'] = token.callback 321 | # 1.0a support for verifier. 322 | if verifier: 323 | parameters['oauth_verifier'] = verifier 324 | elif callback: 325 | # 1.0a support for callback in the request token request. 326 | parameters['oauth_callback'] = callback 327 | 328 | return OAuthRequest(http_method, http_url, parameters) 329 | from_consumer_and_token = staticmethod(from_consumer_and_token) 330 | 331 | def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, 332 | http_url=None, parameters=None): 333 | if not parameters: 334 | parameters = {} 335 | 336 | parameters['oauth_token'] = token.key 337 | 338 | if callback: 339 | parameters['oauth_callback'] = callback 340 | 341 | return OAuthRequest(http_method, http_url, parameters) 342 | from_token_and_callback = staticmethod(from_token_and_callback) 343 | 344 | def _split_header(header): 345 | """Turn Authorization: header into parameters.""" 346 | params = {} 347 | parts = header.split(',') 348 | for param in parts: 349 | # Ignore realm parameter. 350 | if param.find('realm') > -1: 351 | continue 352 | # Remove whitespace. 353 | param = param.strip() 354 | # Split key-value. 355 | param_parts = param.split('=', 1) 356 | # Remove quotes and unescape the value. 357 | params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) 358 | return params 359 | _split_header = staticmethod(_split_header) 360 | 361 | def _split_url_string(param_str): 362 | """Turn URL string into parameters.""" 363 | parameters = cgi.parse_qs(param_str, keep_blank_values=False) 364 | for k, v in parameters.iteritems(): 365 | parameters[k] = urllib.unquote(v[0]) 366 | return parameters 367 | _split_url_string = staticmethod(_split_url_string) 368 | 369 | class OAuthServer(object): 370 | """A worker to check the validity of a request against a data store.""" 371 | timestamp_threshold = 300 # In seconds, five minutes. 372 | version = VERSION 373 | signature_methods = None 374 | data_store = None 375 | 376 | def __init__(self, data_store=None, signature_methods=None): 377 | self.data_store = data_store 378 | self.signature_methods = signature_methods or {} 379 | 380 | def set_data_store(self, data_store): 381 | self.data_store = data_store 382 | 383 | def get_data_store(self): 384 | return self.data_store 385 | 386 | def add_signature_method(self, signature_method): 387 | self.signature_methods[signature_method.get_name()] = signature_method 388 | return self.signature_methods 389 | 390 | def fetch_request_token(self, oauth_request): 391 | """Processes a request_token request and returns the 392 | request token on success. 393 | """ 394 | try: 395 | # Get the request token for authorization. 396 | token = self._get_token(oauth_request, 'request') 397 | except OAuthError: 398 | # No token required for the initial token request. 399 | version = self._get_version(oauth_request) 400 | consumer = self._get_consumer(oauth_request) 401 | try: 402 | callback = self.get_callback(oauth_request) 403 | except OAuthError: 404 | callback = None # 1.0, no callback specified. 405 | self._check_signature(oauth_request, consumer, None) 406 | # Fetch a new token. 407 | token = self.data_store.fetch_request_token(consumer, callback) 408 | return token 409 | 410 | def fetch_access_token(self, oauth_request): 411 | """Processes an access_token request and returns the 412 | access token on success. 413 | """ 414 | version = self._get_version(oauth_request) 415 | consumer = self._get_consumer(oauth_request) 416 | try: 417 | verifier = self._get_verifier(oauth_request) 418 | except OAuthError: 419 | verifier = None 420 | # Get the request token. 421 | token = self._get_token(oauth_request, 'request') 422 | self._check_signature(oauth_request, consumer, token) 423 | new_token = self.data_store.fetch_access_token(consumer, token, verifier) 424 | return new_token 425 | 426 | def verify_request(self, oauth_request): 427 | """Verifies an api call and checks all the parameters.""" 428 | # -> consumer and token 429 | version = self._get_version(oauth_request) 430 | consumer = self._get_consumer(oauth_request) 431 | # Get the access token. 432 | token = self._get_token(oauth_request, 'access') 433 | self._check_signature(oauth_request, consumer, token) 434 | parameters = oauth_request.get_nonoauth_parameters() 435 | return consumer, token, parameters 436 | 437 | def authorize_token(self, token, user): 438 | """Authorize a request token.""" 439 | return self.data_store.authorize_request_token(token, user) 440 | 441 | def get_callback(self, oauth_request): 442 | """Get the callback URL.""" 443 | return oauth_request.get_parameter('oauth_callback') 444 | 445 | def build_authenticate_header(self, realm=''): 446 | """Optional support for the authenticate header.""" 447 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 448 | 449 | def _get_version(self, oauth_request): 450 | """Verify the correct version request for this server.""" 451 | try: 452 | version = oauth_request.get_parameter('oauth_version') 453 | except: 454 | version = VERSION 455 | if version and version != self.version: 456 | raise OAuthError('OAuth version %s not supported.' % str(version)) 457 | return version 458 | 459 | def _get_signature_method(self, oauth_request): 460 | """Figure out the signature with some defaults.""" 461 | try: 462 | signature_method = oauth_request.get_parameter( 463 | 'oauth_signature_method') 464 | except: 465 | signature_method = SIGNATURE_METHOD 466 | try: 467 | # Get the signature method object. 468 | signature_method = self.signature_methods[signature_method] 469 | except: 470 | signature_method_names = ', '.join(self.signature_methods.keys()) 471 | raise OAuthError('Signature method %s not supported try one of the ' 472 | 'following: %s' % (signature_method, signature_method_names)) 473 | 474 | return signature_method 475 | 476 | def _get_consumer(self, oauth_request): 477 | consumer_key = oauth_request.get_parameter('oauth_consumer_key') 478 | consumer = self.data_store.lookup_consumer(consumer_key) 479 | if not consumer: 480 | raise OAuthError('Invalid consumer.') 481 | return consumer 482 | 483 | def _get_token(self, oauth_request, token_type='access'): 484 | """Try to find the token for the provided request token key.""" 485 | token_field = oauth_request.get_parameter('oauth_token') 486 | token = self.data_store.lookup_token(token_type, token_field) 487 | if not token: 488 | raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) 489 | return token 490 | 491 | def _get_verifier(self, oauth_request): 492 | return oauth_request.get_parameter('oauth_verifier') 493 | 494 | def _check_signature(self, oauth_request, consumer, token): 495 | timestamp, nonce = oauth_request._get_timestamp_nonce() 496 | self._check_timestamp(timestamp) 497 | self._check_nonce(consumer, token, nonce) 498 | signature_method = self._get_signature_method(oauth_request) 499 | try: 500 | signature = oauth_request.get_parameter('oauth_signature') 501 | except: 502 | raise OAuthError('Missing signature.') 503 | # Validate the signature. 504 | valid_sig = signature_method.check_signature(oauth_request, consumer, 505 | token, signature) 506 | if not valid_sig: 507 | key, base = signature_method.build_signature_base_string( 508 | oauth_request, consumer, token) 509 | raise OAuthError('Invalid signature. Expected signature base ' 510 | 'string: %s' % base) 511 | built = signature_method.build_signature(oauth_request, consumer, token) 512 | 513 | def _check_timestamp(self, timestamp): 514 | """Verify that timestamp is recentish.""" 515 | timestamp = int(timestamp) 516 | now = int(time.time()) 517 | lapsed = now - timestamp 518 | if lapsed > self.timestamp_threshold: 519 | raise OAuthError('Expired timestamp: given %d and now %s has a ' 520 | 'greater difference than threshold %d' % 521 | (timestamp, now, self.timestamp_threshold)) 522 | 523 | def _check_nonce(self, consumer, token, nonce): 524 | """Verify that the nonce is uniqueish.""" 525 | nonce = self.data_store.lookup_nonce(consumer, token, nonce) 526 | if nonce: 527 | raise OAuthError('Nonce already used: %s' % str(nonce)) 528 | 529 | 530 | class OAuthClient(object): 531 | """OAuthClient is a worker to attempt to execute a request.""" 532 | consumer = None 533 | token = None 534 | 535 | def __init__(self, oauth_consumer, oauth_token): 536 | self.consumer = oauth_consumer 537 | self.token = oauth_token 538 | 539 | def get_consumer(self): 540 | return self.consumer 541 | 542 | def get_token(self): 543 | return self.token 544 | 545 | def fetch_request_token(self, oauth_request): 546 | """-> OAuthToken.""" 547 | raise NotImplementedError 548 | 549 | def fetch_access_token(self, oauth_request): 550 | """-> OAuthToken.""" 551 | raise NotImplementedError 552 | 553 | def access_resource(self, oauth_request): 554 | """-> Some protected resource.""" 555 | raise NotImplementedError 556 | 557 | 558 | class OAuthDataStore(object): 559 | """A database abstraction used to lookup consumers and tokens.""" 560 | 561 | def lookup_consumer(self, key): 562 | """-> OAuthConsumer.""" 563 | raise NotImplementedError 564 | 565 | def lookup_token(self, oauth_consumer, token_type, token_token): 566 | """-> OAuthToken.""" 567 | raise NotImplementedError 568 | 569 | def lookup_nonce(self, oauth_consumer, oauth_token, nonce): 570 | """-> OAuthToken.""" 571 | raise NotImplementedError 572 | 573 | def fetch_request_token(self, oauth_consumer, oauth_callback): 574 | """-> OAuthToken.""" 575 | raise NotImplementedError 576 | 577 | def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): 578 | """-> OAuthToken.""" 579 | raise NotImplementedError 580 | 581 | def authorize_request_token(self, oauth_token, user): 582 | """-> OAuthToken.""" 583 | raise NotImplementedError 584 | 585 | 586 | class OAuthSignatureMethod(object): 587 | """A strategy class that implements a signature method.""" 588 | def get_name(self): 589 | """-> str.""" 590 | raise NotImplementedError 591 | 592 | def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token): 593 | """-> str key, str raw.""" 594 | raise NotImplementedError 595 | 596 | def build_signature(self, oauth_request, oauth_consumer, oauth_token): 597 | """-> str.""" 598 | raise NotImplementedError 599 | 600 | def check_signature(self, oauth_request, consumer, token, signature): 601 | built = self.build_signature(oauth_request, consumer, token) 602 | return built == signature 603 | 604 | 605 | class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): 606 | 607 | def get_name(self): 608 | return 'HMAC-SHA1' 609 | 610 | def build_signature_base_string(self, oauth_request, consumer, token): 611 | sig = ( 612 | escape(oauth_request.get_normalized_http_method()), 613 | escape(oauth_request.get_normalized_http_url()), 614 | escape(oauth_request.get_normalized_parameters()), 615 | ) 616 | 617 | key = '%s&' % escape(consumer.secret) 618 | if token: 619 | key += escape(token.secret) 620 | raw = '&'.join(sig) 621 | return key, raw 622 | 623 | def build_signature(self, oauth_request, consumer, token): 624 | """Builds the base signature string.""" 625 | key, raw = self.build_signature_base_string(oauth_request, consumer, 626 | token) 627 | 628 | # HMAC object. 629 | try: 630 | import hashlib # 2.5 631 | hashed = hmac.new(key, raw, hashlib.sha1) 632 | except: 633 | import sha # Deprecated 634 | hashed = hmac.new(key, raw, sha) 635 | 636 | # Calculate the digest base 64. 637 | return binascii.b2a_base64(hashed.digest())[:-1] 638 | 639 | 640 | class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): 641 | 642 | def get_name(self): 643 | return 'PLAINTEXT' 644 | 645 | def build_signature_base_string(self, oauth_request, consumer, token): 646 | """Concatenates the consumer key and secret.""" 647 | sig = '%s&' % escape(consumer.secret) 648 | if token: 649 | sig = sig + escape(token.secret) 650 | return sig, sig 651 | 652 | def build_signature(self, oauth_request, consumer, token): 653 | key, raw = self.build_signature_base_string(oauth_request, consumer, 654 | token) 655 | return key --------------------------------------------------------------------------------