├── 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 |
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
--------------------------------------------------------------------------------