Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 1 | # 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. |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 4 | |
| 5 | """Classes to hold information parsed from a request. |
| 6 | |
| 7 | To simplify our servlets and avoid duplication of code, we parse some |
| 8 | info out of the request as soon as we get it and then pass a MonorailRequest |
| 9 | object to the servlet-specific request handler methods. |
| 10 | """ |
| 11 | from __future__ import print_function |
| 12 | from __future__ import division |
| 13 | from __future__ import absolute_import |
| 14 | |
| 15 | import endpoints |
| 16 | import logging |
| 17 | import re |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 18 | from six.moves import urllib |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 19 | |
| 20 | import ezt |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 21 | import flask |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 22 | import six |
| 23 | |
| 24 | from google.appengine.api import app_identity |
| 25 | from google.appengine.api import oauth |
| 26 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 27 | import settings |
| 28 | from businesslogic import work_env |
| 29 | from features import features_constants |
| 30 | from framework import authdata |
| 31 | from framework import exceptions |
| 32 | from framework import framework_bizobj |
| 33 | from framework import framework_constants |
| 34 | from framework import framework_views |
| 35 | from framework import monorailcontext |
| 36 | from framework import permissions |
| 37 | from framework import profiler |
| 38 | from framework import sql |
| 39 | from framework import template_helpers |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 40 | from mrproto import api_pb2_v1 |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 41 | from tracker import tracker_bizobj |
| 42 | from tracker import tracker_constants |
| 43 | |
| 44 | |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 45 | _HOSTPORT_RE = re.compile(r'^[-a-z0-9.]+(:\d+)?$', re.I) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 46 | |
| 47 | |
| 48 | # TODO(jrobbins): Stop extending MonorailContext and change whole servlet |
| 49 | # framework to pass around separate objects for mc and mr. |
| 50 | class 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 | |
| 66 | class 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 | |
| 193 | class 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 232 | """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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 241 | self.request_path = request.base_url[len(request.host_url) - 1:] |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 242 | self.current_page_url = request.url |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 243 | self.current_page_url_encoded = urllib.parse.quote_plus( |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 244 | six.ensure_str(self.current_page_url)) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 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('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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 255 | self.hotlist_name) = _ParsePathIdentifiers(self.request_path) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 256 | 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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 282 | request.values.get('debug') and |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 283 | (settings.local_mode or prod_debug_allowed)) |
| 284 | # temporary option for perf testing on staging instance. |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 285 | if request.values.get('disable_cache'): |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 286 | if settings.local_mode or 'staging' in request.host: |
| 287 | self.use_cached_searches = False |
| 288 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 289 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 446 | flask.abort(404, 'user not found') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 447 | |
| 448 | if not self.viewed_user_auth.user_id: |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 449 | flask.abort(404, 'user not found') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 450 | |
| 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 474 | flask.abort(404, 'invalid hotlist') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 475 | |
| 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 484 | flask.abort(404, 'invalid hotlist') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 485 | |
| 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 540 | value = None |
| 541 | if hasattr(self.request, 'params'): |
| 542 | value = self.request.params.get(query_param_name) |
| 543 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 544 | value = self.request.values.get(query_param_name) |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 545 | assert value is None or isinstance(value, six.string_types) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 546 | 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 563 | value = None |
| 564 | if hasattr(self.request, 'params'): |
| 565 | value = self.request.params.get(query_param_name) |
| 566 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 567 | value = self.request.values.get(query_param_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 568 | 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 579 | value = self.GetIntParam(query_param_name, default_value=default_value) |
| 580 | if value is None: |
| 581 | return 0 |
| 582 | return max(value, 0) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 583 | |
| 584 | def GetListParam(self, query_param_name, default_value=None): |
| 585 | """Get a list of strings from the URL or default.""" |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 586 | params = None |
| 587 | if hasattr(self.request, 'params'): |
| 588 | params = self.request.params.get(query_param_name) |
| 589 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 590 | params = self.request.values.get(query_param_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 591 | 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 610 | value = None |
| 611 | if hasattr(self.request, 'params'): |
| 612 | value = self.request.params.get(query_param_name) |
| 613 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 614 | value = self.request.values.get(query_param_name) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 615 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 616 | 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 | |
| 624 | def _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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 647 | viewed_user_val = urllib.parse.unquote(split_path[1]) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 648 | if len(split_path) >= 4 and split_path[2] == 'hotlists': |
| 649 | try: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 650 | hotlist_id = int(urllib.parse.unquote(split_path[3].split('.')[0])) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 651 | except ValueError: |
| 652 | raw_last_path = (split_path[3][:-3] if |
| 653 | split_path[3].endswith('.do') else split_path[3]) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 654 | last_path = urllib.parse.unquote(raw_last_path) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 655 | 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ínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 664 | viewed_user_val = urllib.parse.unquote(split_path[1]) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 665 | |
| 666 | return viewed_user_val, project_name, hotlist_id, hotlist_name |
| 667 | |
| 668 | |
| 669 | def _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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 688 | flask.abort(404, 'user not found') |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 689 | except ValueError: |
| 690 | viewed_email = viewed_user_val |
| 691 | |
| 692 | return viewed_email |
| 693 | |
| 694 | |
| 695 | def 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 |