blob: e51aa15b69d619e84ae79e71d938ff688fa2dec6 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Classes to hold information parsed from a request.
7
8To simplify our servlets and avoid duplication of code, we parse some
9info out of the request as soon as we get it and then pass a MonorailRequest
10object to the servlet-specific request handler methods.
11"""
12from __future__ import print_function
13from __future__ import division
14from __future__ import absolute_import
15
16import endpoints
17import logging
18import re
19import urllib
20
21import ezt
22import six
23
24from google.appengine.api import app_identity
25from google.appengine.api import oauth
26
27import webapp2
28
29import settings
30from businesslogic import work_env
31from features import features_constants
32from framework import authdata
33from framework import exceptions
34from framework import framework_bizobj
35from framework import framework_constants
36from framework import framework_views
37from framework import monorailcontext
38from framework import permissions
39from framework import profiler
40from framework import sql
41from framework import template_helpers
42from proto import api_pb2_v1
43from tracker import tracker_bizobj
44from tracker import tracker_constants
45
46
47_HOSTPORT_RE = re.compile('^[-a-z0-9.]+(:\d+)?$', re.I)
48
49
50# TODO(jrobbins): Stop extending MonorailContext and change whole servlet
51# framework to pass around separate objects for mc and mr.
52class MonorailRequestBase(monorailcontext.MonorailContext):
53 """A base class with common attributes for internal and external requests."""
54
55 def __init__(self, services, requester=None, cnxn=None):
56 super(MonorailRequestBase, self).__init__(
57 services, cnxn=cnxn, requester=requester)
58
59 self.project_name = None
60 self.project = None
61 self.config = None
62
63 @property
64 def project_id(self):
65 return self.project.project_id if self.project else None
66
67
68class MonorailApiRequest(MonorailRequestBase):
69 """A class to hold information parsed from the Endpoints API request."""
70
71 # pylint: disable=attribute-defined-outside-init
72 def __init__(self, request, services, cnxn=None):
73 requester_object = (
74 endpoints.get_current_user() or
75 oauth.get_current_user(
76 framework_constants.OAUTH_SCOPE))
77 requester = requester_object.email().lower()
78 super(MonorailApiRequest, self).__init__(
79 services, requester=requester, cnxn=cnxn)
80 self.me_user_id = self.auth.user_id
81 self.viewed_username = None
82 self.viewed_user_auth = None
83 self.issue = None
84 self.granted_perms = set()
85
86 # query parameters
87 self.params = {
88 'can': 1,
89 'start': 0,
90 'num': tracker_constants.DEFAULT_RESULTS_PER_PAGE,
91 'q': '',
92 'sort': '',
93 'groupby': '',
94 'projects': [],
95 'hotlists': []
96 }
97 self.use_cached_searches = True
98 self.mode = None
99
100 if hasattr(request, 'projectId'):
101 self.project_name = request.projectId
102 with work_env.WorkEnv(self, services) as we:
103 self.project = we.GetProjectByName(self.project_name)
104 self.params['projects'].append(self.project_name)
105 self.config = we.GetProjectConfig(self.project_id)
106 if hasattr(request, 'additionalProject'):
107 self.params['projects'].extend(request.additionalProject)
108 self.params['projects'] = list(set(self.params['projects']))
109 self.LookupLoggedInUserPerms(self.project)
110 if hasattr(request, 'projectId'):
111 with work_env.WorkEnv(self, services) as we:
112 if hasattr(request, 'issueId'):
113 self.issue = we.GetIssueByLocalID(
114 self.project_id, request.issueId, use_cache=False)
115 self.granted_perms = tracker_bizobj.GetGrantedPerms(
116 self.issue, self.auth.effective_ids, self.config)
117 if hasattr(request, 'userId'):
118 self.viewed_username = request.userId.lower()
119 if self.viewed_username == 'me':
120 self.viewed_username = requester
121 self.viewed_user_auth = authdata.AuthData.FromEmail(
122 self.cnxn, self.viewed_username, services)
123 elif hasattr(request, 'groupName'):
124 self.viewed_username = request.groupName.lower()
125 try:
126 self.viewed_user_auth = authdata.AuthData.FromEmail(
127 self.cnxn, self.viewed_username, services)
128 except exceptions.NoSuchUserException:
129 self.viewed_user_auth = None
130
131 # Build q.
132 if hasattr(request, 'q') and request.q:
133 self.params['q'] = request.q
134 if hasattr(request, 'publishedMax') and request.publishedMax:
135 self.params['q'] += ' opened<=%d' % request.publishedMax
136 if hasattr(request, 'publishedMin') and request.publishedMin:
137 self.params['q'] += ' opened>=%d' % request.publishedMin
138 if hasattr(request, 'updatedMax') and request.updatedMax:
139 self.params['q'] += ' modified<=%d' % request.updatedMax
140 if hasattr(request, 'updatedMin') and request.updatedMin:
141 self.params['q'] += ' modified>=%d' % request.updatedMin
142 if hasattr(request, 'owner') and request.owner:
143 self.params['q'] += ' owner:%s' % request.owner
144 if hasattr(request, 'status') and request.status:
145 self.params['q'] += ' status:%s' % request.status
146 if hasattr(request, 'label') and request.label:
147 self.params['q'] += ' label:%s' % request.label
148
149 if hasattr(request, 'can') and request.can:
150 if request.can == api_pb2_v1.CannedQuery.all:
151 self.params['can'] = 1
152 elif request.can == api_pb2_v1.CannedQuery.new:
153 self.params['can'] = 6
154 elif request.can == api_pb2_v1.CannedQuery.open:
155 self.params['can'] = 2
156 elif request.can == api_pb2_v1.CannedQuery.owned:
157 self.params['can'] = 3
158 elif request.can == api_pb2_v1.CannedQuery.reported:
159 self.params['can'] = 4
160 elif request.can == api_pb2_v1.CannedQuery.starred:
161 self.params['can'] = 5
162 elif request.can == api_pb2_v1.CannedQuery.to_verify:
163 self.params['can'] = 7
164 else: # Endpoints should have caught this.
165 raise exceptions.InputException(
166 'Canned query %s is not supported.', request.can)
167 if hasattr(request, 'startIndex') and request.startIndex:
168 self.params['start'] = request.startIndex
169 if hasattr(request, 'maxResults') and request.maxResults:
170 self.params['num'] = request.maxResults
171 if hasattr(request, 'sort') and request.sort:
172 self.params['sort'] = request.sort
173
174 self.query_project_names = self.GetParam('projects')
175 self.group_by_spec = self.GetParam('groupby')
176 self.group_by_spec = ' '.join(ParseColSpec(
177 self.group_by_spec, ignore=tracker_constants.NOT_USED_IN_GRID_AXES))
178 self.sort_spec = self.GetParam('sort')
179 self.sort_spec = ' '.join(ParseColSpec(self.sort_spec))
180 self.query = self.GetParam('q')
181 self.can = self.GetParam('can')
182 self.start = self.GetParam('start')
183 self.num = self.GetParam('num')
184
185 def GetParam(self, query_param_name, default_value=None,
186 _antitamper_re=None):
187 return self.params.get(query_param_name, default_value)
188
189 def GetPositiveIntParam(self, query_param_name, default_value=None):
190 """Returns 0 if the user-provided value is less than 0."""
191 return max(self.GetParam(query_param_name, default_value=default_value),
192 0)
193
194
195class MonorailRequest(MonorailRequestBase):
196 """A class to hold information parsed from the HTTP request.
197
198 The goal of MonorailRequest is to do almost all URL path and query string
199 procesing in one place, which makes the servlet code simpler.
200
201 Attributes:
202 cnxn: connection to the SQL databases.
203 logged_in_user_id: int user ID of the signed-in user, or None.
204 effective_ids: set of signed-in user ID and all their user group IDs.
205 user_pb: User object for the signed in user.
206 project_name: string name of the current project.
207 project_id: int ID of the current projet.
208 viewed_username: string username of the user whose profile is being viewed.
209 can: int "canned query" number to scope the user's search.
210 num: int number of results to show per pagination page.
211 start: int position in result set to show on this pagination page.
212 etc: there are many more, all read-only.
213 """
214
215 # pylint: disable=attribute-defined-outside-init
216 def __init__(self, services, params=None):
217 """Initialize the MonorailRequest object."""
218 # Note: mr starts off assuming anon until ParseRequest() is called.
219 super(MonorailRequest, self).__init__(services)
220 self.form_overrides = {}
221 if params:
222 self.form_overrides.update(params)
223 self.debug_enabled = False
224 self.use_cached_searches = True
225
226 self.hotlist_id = None
227 self.hotlist = None
228 self.hotlist_name = None
229
230 self.viewed_username = None
231 self.viewed_user_auth = authdata.AuthData()
232
233 def ParseRequest(self, request, services, do_user_lookups=True):
234 """Parse tons of useful info from the given request object.
235
236 Args:
237 request: webapp2 Request object w/ path and query params.
238 services: connections to backend servers including DB.
239 do_user_lookups: Set to False to disable lookups during testing.
240 """
241 with self.profiler.Phase('basic parsing'):
242 self.request = request
243 self.current_page_url = request.url
244 self.current_page_url_encoded = urllib.quote_plus(self.current_page_url)
245
246 # Only accept a hostport from the request that looks valid.
247 if not _HOSTPORT_RE.match(request.host):
248 raise exceptions.InputException(
249 'request.host looks funny: %r', request.host)
250
251 logging.info('Request: %s', self.current_page_url)
252
253 with self.profiler.Phase('path parsing'):
254 (viewed_user_val, self.project_name,
255 self.hotlist_id, self.hotlist_name) = _ParsePathIdentifiers(
256 self.request.path)
257 self.viewed_username = _GetViewedEmail(
258 viewed_user_val, self.cnxn, services)
259 with self.profiler.Phase('qs parsing'):
260 self._ParseQueryParameters()
261 with self.profiler.Phase('overrides parsing'):
262 self._ParseFormOverrides()
263
264 if not self.project: # It can be already set in unit tests.
265 self._LookupProject(services)
266 if self.project_id and services.config:
267 self.config = services.config.GetProjectConfig(self.cnxn, self.project_id)
268
269 if do_user_lookups:
270 if self.viewed_username:
271 self._LookupViewedUser(services)
272 self._LookupLoggedInUser(services)
273 # TODO(jrobbins): re-implement HandleLurkerViewingSelf()
274
275 if not self.hotlist:
276 self._LookupHotlist(services)
277
278 if self.query is None:
279 self.query = self._CalcDefaultQuery()
280
281 prod_debug_allowed = self.perms.HasPerm(
282 permissions.VIEW_DEBUG, self.auth.user_id, None)
283 self.debug_enabled = (request.params.get('debug') and
284 (settings.local_mode or prod_debug_allowed))
285 # temporary option for perf testing on staging instance.
286 if request.params.get('disable_cache'):
287 if settings.local_mode or 'staging' in request.host:
288 self.use_cached_searches = False
289
290 def _CalcDefaultQuery(self):
291 """When URL has no q= param, return the default for members or ''."""
292 if (self.can == 2 and self.project and self.auth.effective_ids and
293 framework_bizobj.UserIsInProject(self.project, self.auth.effective_ids)
294 and self.config):
295 return self.config.member_default_query
296 else:
297 return ''
298
299 def _ParseQueryParameters(self):
300 """Parse and convert all the query string params used in any servlet."""
301 self.start = self.GetPositiveIntParam('start', default_value=0)
302 self.num = self.GetPositiveIntParam(
303 'num', default_value=tracker_constants.DEFAULT_RESULTS_PER_PAGE)
304 # Prevent DoS attacks that try to make us serve really huge result pages.
305 self.num = min(self.num, settings.max_artifact_search_results_per_page)
306
307 self.invalidation_timestep = self.GetIntParam(
308 'invalidation_timestep', default_value=0)
309
310 self.continue_issue_id = self.GetIntParam(
311 'continue_issue_id', default_value=0)
312 self.redir = self.GetParam('redir')
313
314 # Search scope, a.k.a., canned query ID
315 # TODO(jrobbins): make configurable
316 self.can = self.GetIntParam(
317 'can', default_value=tracker_constants.OPEN_ISSUES_CAN)
318
319 # Search query
320 self.query = self.GetParam('q')
321
322 # Sorting of search results (needed for result list and flipper)
323 self.sort_spec = self.GetParam(
324 'sort', default_value='',
325 antitamper_re=framework_constants.SORTSPEC_RE)
326 self.sort_spec = ' '.join(ParseColSpec(self.sort_spec))
327
328 # Note: This is set later in request handling by ComputeColSpec().
329 self.col_spec = None
330
331 # Grouping of search results (needed for result list and flipper)
332 self.group_by_spec = self.GetParam(
333 'groupby', default_value='',
334 antitamper_re=framework_constants.SORTSPEC_RE)
335 self.group_by_spec = ' '.join(ParseColSpec(
336 self.group_by_spec, ignore=tracker_constants.NOT_USED_IN_GRID_AXES))
337
338 # For issue list and grid mode.
339 self.cursor = self.GetParam('cursor')
340 self.preview = self.GetParam('preview')
341 self.mode = self.GetParam('mode') or 'list'
342 self.x = self.GetParam('x', default_value='')
343 self.y = self.GetParam('y', default_value='')
344 self.cells = self.GetParam('cells', default_value='ids')
345
346 # For the dashboard and issue lists included in the dashboard.
347 self.ajah = self.GetParam('ajah') # AJAH = Asychronous Javascript And HTML
348 self.table_title = self.GetParam('table_title')
349 self.panel_id = self.GetIntParam('panel')
350
351 # For pagination of updates lists
352 self.before = self.GetPositiveIntParam('before')
353 self.after = self.GetPositiveIntParam('after')
354
355 # For cron tasks and backend calls
356 self.lower_bound = self.GetIntParam('lower_bound')
357 self.upper_bound = self.GetIntParam('upper_bound')
358 self.shard_id = self.GetIntParam('shard_id')
359
360 # For specifying which objects to operate on
361 self.local_id = self.GetIntParam('id')
362 self.local_id_list = self.GetIntListParam('ids')
363 self.seq = self.GetIntParam('seq')
364 self.aid = self.GetIntParam('aid')
365 self.signed_aid = self.GetParam('signed_aid')
366 self.specified_user_id = self.GetIntParam('u', default_value=0)
367 self.specified_logged_in_user_id = self.GetIntParam(
368 'logged_in_user_id', default_value=0)
369 self.specified_me_user_ids = self.GetIntListParam('me_user_ids')
370
371 # TODO(jrobbins): Phase this out after next deployment. If an old
372 # version of the default GAE module sends a request with the old
373 # me_user_id= parameter, then accept it.
374 specified_me_user_id = self.GetIntParam(
375 'me_user_id', default_value=0)
376 if specified_me_user_id:
377 self.specified_me_user_ids = [specified_me_user_id]
378
379 self.specified_project = self.GetParam('project')
380 self.specified_project_id = self.GetIntParam('project_id')
381 self.query_project_names = self.GetListParam('projects', default_value=[])
382 self.template_name = self.GetParam('template')
383 self.component_path = self.GetParam('component')
384 self.field_name = self.GetParam('field')
385
386 # For image attachments
387 self.inline = bool(self.GetParam('inline'))
388 self.thumb = bool(self.GetParam('thumb'))
389
390 # For JS callbacks
391 self.token = self.GetParam('token')
392 self.starred = bool(self.GetIntParam('starred'))
393
394 # For issue reindexing utility servlet
395 self.auto_submit = self.GetParam('auto_submit')
396
397 # For issue dependency reranking servlet
398 self.parent_id = self.GetIntParam('parent_id')
399 self.target_id = self.GetIntParam('target_id')
400 self.moved_ids = self.GetIntListParam('moved_ids')
401 self.split_above = self.GetBoolParam('split_above')
402
403 # For adding issues to hotlists servlet
404 self.hotlist_ids_remove = self.GetIntListParam('hotlist_ids_remove')
405 self.hotlist_ids_add = self.GetIntListParam('hotlist_ids_add')
406 self.issue_refs = self.GetListParam('issue_refs')
407
408 def _ParseFormOverrides(self):
409 """Support deep linking by allowing the user to set form fields via QS."""
410 allowed_overrides = {
411 'template_name': self.GetParam('template_name'),
412 'initial_summary': self.GetParam('summary'),
413 'initial_description': (self.GetParam('description') or
414 self.GetParam('comment')),
415 'initial_comment': self.GetParam('comment'),
416 'initial_status': self.GetParam('status'),
417 'initial_owner': self.GetParam('owner'),
418 'initial_cc': self.GetParam('cc'),
419 'initial_blocked_on': self.GetParam('blockedon'),
420 'initial_blocking': self.GetParam('blocking'),
421 'initial_merge_into': self.GetIntParam('mergeinto'),
422 'initial_components': self.GetParam('components'),
423 'initial_hotlists': self.GetParam('hotlists'),
424
425 # For the people pages
426 'initial_add_members': self.GetParam('add_members'),
427 'initially_expanded_form': ezt.boolean(self.GetParam('expand_form')),
428
429 # For user group admin pages
430 'initial_name': (self.GetParam('group_name') or
431 self.GetParam('proposed_project_name')),
432 }
433
434 # Only keep the overrides that were actually provided in the query string.
435 self.form_overrides.update(
436 (k, v) for (k, v) in allowed_overrides.items()
437 if v is not None)
438
439 def _LookupViewedUser(self, services):
440 """Get information about the viewed user (if any) from the request."""
441 try:
442 with self.profiler.Phase('get viewed user, if any'):
443 self.viewed_user_auth = authdata.AuthData.FromEmail(
444 self.cnxn, self.viewed_username, services, autocreate=False)
445 except exceptions.NoSuchUserException:
446 logging.info('could not find user %r', self.viewed_username)
447 webapp2.abort(404, 'user not found')
448
449 if not self.viewed_user_auth.user_id:
450 webapp2.abort(404, 'user not found')
451
452 def _LookupProject(self, services):
453 """Get information about the current project (if any) from the request.
454
455 Raises:
456 NoSuchProjectException if there is no project with that name.
457 """
458 logging.info('project_name is %r', self.project_name)
459 if self.project_name:
460 self.project = services.project.GetProjectByName(
461 self.cnxn, self.project_name)
462 if not self.project:
463 raise exceptions.NoSuchProjectException()
464
465 def _LookupHotlist(self, services):
466 """Get information about the current hotlist (if any) from the request."""
467 with self.profiler.Phase('get current hotlist, if any'):
468 if self.hotlist_name:
469 hotlist_id_dict = services.features.LookupHotlistIDs(
470 self.cnxn, [self.hotlist_name], [self.viewed_user_auth.user_id])
471 try:
472 self.hotlist_id = hotlist_id_dict[(
473 self.hotlist_name, self.viewed_user_auth.user_id)]
474 except KeyError:
475 webapp2.abort(404, 'invalid hotlist')
476
477 if not self.hotlist_id:
478 logging.info('no hotlist_id or bad hotlist_name, so no hotlist')
479 else:
480 self.hotlist = services.features.GetHotlistByID(
481 self.cnxn, self.hotlist_id)
482 if not self.hotlist or (
483 self.viewed_user_auth.user_id and
484 self.viewed_user_auth.user_id not in self.hotlist.owner_ids):
485 webapp2.abort(404, 'invalid hotlist')
486
487 def _LookupLoggedInUser(self, services):
488 """Get information about the signed-in user (if any) from the request."""
489 self.auth = authdata.AuthData.FromRequest(self.cnxn, services)
490 self.me_user_id = (self.GetIntParam('me') or
491 self.viewed_user_auth.user_id or self.auth.user_id)
492
493 self.LookupLoggedInUserPerms(self.project)
494
495 def ComputeColSpec(self, config):
496 """Set col_spec based on param, default in the config, or site default."""
497 if self.col_spec is not None:
498 return # Already set.
499 default_col_spec = ''
500 if config:
501 default_col_spec = config.default_col_spec
502
503 col_spec = self.GetParam(
504 'colspec', default_value=default_col_spec,
505 antitamper_re=framework_constants.COLSPEC_RE)
506 cols_lower = col_spec.lower().split()
507 if self.project and any(
508 hotlist_col in cols_lower for hotlist_col in [
509 'rank', 'adder', 'added']):
510 # if the the list is a project list and the 'colspec' is a carry-over
511 # from hotlists, set col_spec to None so it will be set to default in
512 # in the next if statement
513 col_spec = None
514
515 if not col_spec:
516 # If col spec is still empty then default to the global col spec.
517 col_spec = tracker_constants.DEFAULT_COL_SPEC
518
519 self.col_spec = ' '.join(ParseColSpec(col_spec,
520 max_parts=framework_constants.MAX_COL_PARTS))
521
522 def PrepareForReentry(self, echo_data):
523 """Expose the results of form processing as if it was a new GET.
524
525 This method is called only when the user submits a form with invalid
526 information which they are being asked to correct it. Updating the MR
527 object allows the normal servlet get() method to populate the form with
528 the entered values and error messages.
529
530 Args:
531 echo_data: dict of {page_data_key: value_to_reoffer, ...} that will
532 override whatever HTML form values are nomally shown to the
533 user when they initially view the form. This allows them to
534 fix user input that was not valid.
535 """
536 self.form_overrides.update(echo_data)
537
538 def GetParam(self, query_param_name, default_value=None,
539 antitamper_re=None):
540 """Get a query parameter from the URL as a utf8 string."""
541 value = self.request.params.get(query_param_name)
542 assert value is None or isinstance(value, six.text_type)
543 using_default = value is None
544 if using_default:
545 value = default_value
546
547 if antitamper_re and not antitamper_re.match(value):
548 if using_default:
549 logging.error('Default value fails antitamper for %s field: %s',
550 query_param_name, value)
551 else:
552 logging.info('User seems to have tampered with %s field: %s',
553 query_param_name, value)
554 raise exceptions.InputException()
555
556 return value
557
558 def GetIntParam(self, query_param_name, default_value=None):
559 """Get an integer param from the URL or default."""
560 value = self.request.params.get(query_param_name)
561 if value is None or value == '':
562 return default_value
563
564 try:
565 return int(value)
566 except (TypeError, ValueError):
567 raise exceptions.InputException(
568 'Invalid value for integer param: %r' % value)
569
570 def GetPositiveIntParam(self, query_param_name, default_value=None):
571 """Returns 0 if the user-provided value is less than 0."""
572 return max(self.GetIntParam(query_param_name, default_value=default_value),
573 0)
574
575 def GetListParam(self, query_param_name, default_value=None):
576 """Get a list of strings from the URL or default."""
577 params = self.request.params.get(query_param_name)
578 if params is None:
579 return default_value
580 if not params:
581 return []
582 return params.split(',')
583
584 def GetIntListParam(self, query_param_name, default_value=None):
585 """Get a list of ints from the URL or default."""
586 param_list = self.GetListParam(query_param_name)
587 if param_list is None:
588 return default_value
589
590 try:
591 return [int(p) for p in param_list]
592 except (TypeError, ValueError):
593 raise exceptions.InputException('Invalid value for integer list param')
594
595 def GetBoolParam(self, query_param_name, default_value=None):
596 """Get a boolean param from the URL or default."""
597 value = self.request.params.get(query_param_name)
598 if value is None:
599 return default_value
600
601 if (not value) or (value.lower() == 'false'):
602 return False
603 return True
604
605
606def _ParsePathIdentifiers(path):
607 """Parse out the workspace being requested (if any).
608
609 Args:
610 path: A string beginning with the request's path info.
611
612 Returns:
613 (viewed_user_val, project_name).
614 """
615 viewed_user_val = None
616 project_name = None
617 hotlist_id = None
618 hotlist_name = None
619
620 # Strip off any query params
621 split_path = path.lstrip('/').split('?')[0].split('/')
622 if len(split_path) >= 2:
623 if split_path[0] == 'hotlists':
624 if split_path[1].isdigit():
625 hotlist_id = int(split_path[1])
626 if split_path[0] == 'p':
627 project_name = split_path[1]
628 if split_path[0] == 'u' or split_path[0] == 'users':
629 viewed_user_val = urllib.unquote(split_path[1])
630 if len(split_path) >= 4 and split_path[2] == 'hotlists':
631 try:
632 hotlist_id = int(
633 urllib.unquote(split_path[3].split('.')[0]))
634 except ValueError:
635 raw_last_path = (split_path[3][:-3] if
636 split_path[3].endswith('.do') else split_path[3])
637 last_path = urllib.unquote(raw_last_path)
638 match = framework_bizobj.RE_HOTLIST_NAME.match(
639 last_path)
640 if not match:
641 raise exceptions.InputException(
642 'Could not parse hotlist id or name')
643 else:
644 hotlist_name = last_path.lower()
645
646 if split_path[0] == 'g':
647 viewed_user_val = urllib.unquote(split_path[1])
648
649 return viewed_user_val, project_name, hotlist_id, hotlist_name
650
651
652def _GetViewedEmail(viewed_user_val, cnxn, services):
653 """Returns the viewed user's email.
654
655 Args:
656 viewed_user_val: Could be either int (user_id) or str (email).
657 cnxn: connection to the SQL database.
658 services: Interface to all persistence storage backends.
659
660 Returns:
661 viewed_email
662 """
663 if not viewed_user_val:
664 return None
665
666 try:
667 viewed_userid = int(viewed_user_val)
668 viewed_email = services.user.LookupUserEmail(cnxn, viewed_userid)
669 if not viewed_email:
670 logging.info('userID %s not found', viewed_userid)
671 webapp2.abort(404, 'user not found')
672 except ValueError:
673 viewed_email = viewed_user_val
674
675 return viewed_email
676
677
678def ParseColSpec(
679 col_spec, max_parts=framework_constants.MAX_SORT_PARTS,
680 ignore=None):
681 """Split a string column spec into a list of column names.
682
683 We dedup col parts because an attacker could try to DoS us or guess
684 zero or one result by measuring the time to process a request that
685 has a very long column list.
686
687 Args:
688 col_spec: a unicode string containing a list of labels.
689 max_parts: optional int maximum number of parts to consider.
690 ignore: optional list of column name parts to ignore.
691
692 Returns:
693 A list of the extracted labels. Non-alphanumeric
694 characters other than the period will be stripped from the text.
695 """
696 cols = framework_constants.COLSPEC_COL_RE.findall(col_spec)
697 result = [] # List of column headers with no duplicates.
698 # Set of column parts that we have processed so far.
699 seen = set()
700 if ignore:
701 seen = set(ignore_col.lower() for ignore_col in ignore)
702 max_parts += len(ignore)
703
704 for col in cols:
705 parts = []
706 for part in col.split('/'):
707 if (part.lower() not in seen and len(seen) < max_parts
708 and len(part) < framework_constants.MAX_COL_LEN):
709 parts.append(part)
710 seen.add(part.lower())
711 if parts:
712 result.append('/'.join(parts))
713 return result