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