[IMP] Improve URL format for caching/signing
[cds-indico.git] / indico / web / http_api / handlers.py
blob8a2c613e876ec12f94d2c4bfa124a32f307d3460
1 # -*- coding: utf-8 -*-
2 ##
3 ##
4 ## This file is part of CDS Indico.
5 ## Copyright (C) 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010 CERN.
6 ##
7 ## CDS Indico is free software; you can redistribute it and/or
8 ## modify it under the terms of the GNU General Public License as
9 ## published by the Free Software Foundation; either version 2 of the
10 ## License, or (at your option) any later version.
12 ## CDS Indico is distributed in the hope that it will be useful, but
13 ## WITHOUT ANY WARRANTY; without even the implied warranty of
14 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 ## General Public License for more details.
17 ## You should have received a copy of the GNU General Public License
18 ## along with CDS Indico; if not, write to the Free Software Foundation, Inc.,
19 ## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
21 """
22 HTTP API - Handlers
23 """
25 # python stdlib imports
26 import hashlib
27 import hmac
28 import re
29 import time
30 import urllib
31 from urlparse import parse_qs
32 from ZODB.POSException import ConflictError
33 import pytz
35 # indico imports
36 from indico.web.http_api import ExportInterface, LimitExceededException
37 from indico.web.http_api.auth import APIKeyHolder
38 from indico.web.http_api.cache import RequestCache
39 from indico.web.http_api.responses import HTTPAPIResult, HTTPAPIError
40 from indico.web.http_api.util import remove_lists, get_query_parameter
41 from indico.web.http_api import API_MODE_KEY, API_MODE_ONLYKEY, API_MODE_SIGNED, API_MODE_ONLYKEY_SIGNED, API_MODE_ALL_SIGNED
42 from indico.web.wsgi import webinterface_handler_config as apache
43 from indico.util.metadata.serializer import Serializer
45 # indico legacy imports
46 from MaKaC.common import DBMgr
47 from MaKaC.common.fossilize import fossilizes, fossilize, Fossilizable
48 from MaKaC.accessControl import AccessWrapper
49 from MaKaC.common.info import HelperMaKaCInfo
51 # Maximum number of records that will get exported for each detail level
52 MAX_RECORDS = {
53 'events': 10000,
54 'contributions': 500,
55 'subcontributions': 500,
56 'sessions': 100,
59 # Valid URLs for export handlers. the last group has to be the response type
60 EXPORT_URL_MAP = {
61 r'/export/(event|categ)/(\w+(?:-\w+)*)\.(\w+)$': 'handler_event_categ'
64 # Compile url regexps
65 EXPORT_URL_MAP = dict((re.compile(pathRe), handlerFunc) for pathRe, handlerFunc in EXPORT_URL_MAP.iteritems())
66 # Remove the extension at the end or before the querystring
67 RE_REMOVE_EXTENSION = re.compile(r'\.(\w+)(?:$|(?=\?))')
70 def normalizeQuery(path, query, ts=None, remove=('timestamp', 'signature')):
71 """Normalize request path and query so it can be used for caching and signing
73 Returns a string consisting of path and sorted query string.
74 Dynamic arguments like signature and timestamp are removed from the query string.
75 """
76 qdata = remove_lists(parse_qs(query))
77 if remove:
78 for key in remove:
79 qdata.pop(key, None)
80 if ts is not None:
81 qdata['timestamp'] = ts
82 sortedQuery = sorted(qdata.items(), key=lambda x: x[0].lower())
83 if sortedQuery:
84 return '%s?%s' % (path, urllib.urlencode(sortedQuery))
85 else:
86 return path
89 def validateSignature(key, signature, path, query, timestamp=None):
90 if timestamp is None:
91 timestamp = int(time.time())
92 ts = timestamp / 300
93 candidates = []
94 for i in xrange(-1, 2):
95 h = hmac.new(key, normalizeQuery(path, query, ts + i), hashlib.sha1)
96 candidates.append(h.hexdigest())
97 if signature not in candidates:
98 raise HTTPAPIError('Signature invalid (check system clock)', apache.HTTP_FORBIDDEN)
101 def getAK(apiKey, signature, path, query):
102 minfo = HelperMaKaCInfo.getMaKaCInfoInstance()
103 apiMode = minfo.getAPIMode()
104 if not apiKey:
105 if apiMode in (API_MODE_ONLYKEY, API_MODE_ONLYKEY_SIGNED, API_MODE_ALL_SIGNED):
106 raise HTTPAPIError('API key is missing', apache.HTTP_FORBIDDEN)
107 return None, True
108 akh = APIKeyHolder()
109 if not akh.hasKey(apiKey):
110 raise HTTPAPIError('Invalid API key', apache.HTTP_FORBIDDEN)
111 ak = akh.getById(apiKey)
112 if ak.isBlocked():
113 raise HTTPAPIError('API key is blocked', apache.HTTP_FORBIDDEN)
114 # Signature validation
115 onlyPublic = False
116 if signature:
117 validateSignature(ak.getSignKey(), signature, path, query)
118 elif apiMode in (API_MODE_SIGNED, API_MODE_ALL_SIGNED):
119 raise HTTPAPIError('Signature missing', apache.HTTP_FORBIDDEN)
120 elif apiMode == API_MODE_ONLYKEY_SIGNED:
121 onlyPublic = True
122 return ak, onlyPublic
125 def buildAW(ak, req, onlyPublic=False):
126 aw = AccessWrapper()
127 if ak and not onlyPublic:
128 # If we have an authenticated request, require HTTPS
129 minfo = HelperMaKaCInfo.getMaKaCInfoInstance()
130 if not req.is_https() and minfo.isAPIHTTPSRequired():
131 raise HTTPAPIError('HTTPS is required', apache.HTTP_FORBIDDEN)
132 aw.setUser(ak.getUser())
133 return aw
136 def getExportHandler(path):
137 """Get the export handler, handler args and return type from a path"""
138 func = None
139 match = None
140 for pathRe, handlerFunc in EXPORT_URL_MAP.iteritems():
141 match = pathRe.match(path)
142 if match:
143 func = handlerFunc
144 break
146 groups = match and match.groups()
147 if not match or groups[-1] not in ExportInterface.getAllowedFormats():
148 return None, None, None
149 return globals()[func], groups[:-1], groups[-1]
152 def handler_event_categ(dbi, aw, qdata, dtype, idlist):
153 idlist = idlist.split('-')
155 expInt = ExportInterface(dbi, aw)
156 tzName = get_query_parameter(qdata, ['tz'], None)
157 detail = get_query_parameter(qdata, ['d', 'detail'], 'events')
158 userLimit = get_query_parameter(qdata, ['n', 'limit'], 0, integer=True)
159 offset = get_query_parameter(qdata, ['O', 'offset'], 0, integer=True)
160 orderBy = get_query_parameter(qdata, ['o', 'order'], 'start')
161 descending = get_query_parameter(qdata, ['c', 'descending'], False)
163 if tzName is None:
164 info = HelperMaKaCInfo.getMaKaCInfoInstance()
165 tzName = info.getTimezone()
167 tz = pytz.timezone(tzName)
169 max = MAX_RECORDS.get(detail, 10000)
170 if userLimit > max:
171 raise HTTPAPIError("You can only request up to %d records per request with the detail level '%s" %
172 (max, detail), apache.HTTP_BAD_REQUEST)
174 # impose a hard limit
175 limit = userLimit if userLimit > 0 else max
177 if dtype == 'categ':
178 iterator = expInt.category(idlist, tz, offset, limit, detail, orderBy, descending, qdata)
179 elif dtype == 'event':
180 iterator = expInt.event(idlist, tz, offset, limit, detail, orderBy, descending, qdata)
182 resultList = []
183 complete = True
185 try:
186 for obj in iterator:
187 resultList.append(obj)
188 except LimitExceededException:
189 complete = (limit == userLimit)
191 return resultList, complete
193 def handler(req, **params):
194 path, query = req.URLFields['PATH_INFO'], req.URLFields['QUERY_STRING']
195 # Parse the actual query string
196 qdata = parse_qs(query)
198 dbi = DBMgr.getInstance()
199 dbi.startRequest()
201 cache = RequestCache(HelperMaKaCInfo.getMaKaCInfoInstance().getAPICacheTTL())
203 apiKey = get_query_parameter(qdata, ['ak', 'apikey'], None)
204 signature = get_query_parameter(qdata, ['signature'])
205 no_cache = get_query_parameter(qdata, ['nc', 'nocache'], 'no') == 'yes'
206 pretty = get_query_parameter(qdata, ['p', 'pretty'], 'no') == 'yes'
207 onlyPublic = get_query_parameter(qdata, ['op', 'onlypublic'], 'no') == 'yes'
209 # Get our handler function and its argument and response type
210 func, args, dformat = getExportHandler(path)
211 if func is None or dformat is None:
212 raise apache.SERVER_RETURN, apache.HTTP_NOT_FOUND
214 ak = error = result = None
215 ts = int(time.time())
216 try:
217 # Validate the API key (and its signature)
218 ak, enforceOnlyPublic = getAK(apiKey, signature, path, query)
219 if enforceOnlyPublic:
220 onlyPublic = True
221 # Create an access wrapper for the API key's user
222 aw = buildAW(ak, req, onlyPublic)
223 # Get rid of API key in cache key if we did not impersonate a user
224 if ak and aw.getUser() is None:
225 cache_key = normalizeQuery(path, query, remove=('ak', 'apiKey', 'signature', 'timestamp'))
226 else:
227 cache_key = normalizeQuery(path, query, remove=('signature', 'timestamp'))
229 obj = None
230 add_to_cache = True
231 cache_key = RE_REMOVE_EXTENSION.sub('', cache_key)
232 if not no_cache:
233 obj = cache.loadObject(cache_key)
234 if obj is not None:
235 result, complete = obj.getContent()
236 ts = obj.getTS()
237 add_to_cache = False
238 if result is None:
239 # Perform the actual exporting
240 result, complete = func(dbi, aw, qdata, *args)
241 if result is not None and add_to_cache:
242 cache.cacheObject(cache_key, (result, complete))
243 except HTTPAPIError, e:
244 error = e
245 if e.getCode():
246 req.status = e.getCode()
248 if result is None and error is None:
249 # TODO: usage page
250 raise apache.SERVER_RETURN, apache.HTTP_NOT_FOUND
251 else:
252 if ak and error is None:
253 # Commit only if there was an API key and no error
254 for _retry in xrange(10):
255 dbi.sync()
256 ak.used(req.remote_ip, path, query, not onlyPublic)
257 try:
258 dbi.endRequest(True)
259 except ConflictError:
260 pass # retry
261 else:
262 break
263 else:
264 # No need to commit stuff if we didn't use an API key
265 # (nothing was written)
266 dbi.endRequest(False)
268 serializer = Serializer.create(dformat, pretty=pretty,
269 **remove_lists(qdata))
271 if error:
272 resultFossil = fossilize(error)
273 else:
274 resultFossil = fossilize(HTTPAPIResult(result, path, query, ts, complete))
275 del resultFossil['_fossil']
277 req.headers_out['Content-Type'] = serializer.getMIMEType()
278 return serializer(resultFossil)