Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | # 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 | |
| 8 | To simplify our servlets and avoid duplication of code, we parse some |
| 9 | info out of the request as soon as we get it and then pass a MonorailRequest |
| 10 | object to the servlet-specific request handler methods. |
| 11 | """ |
| 12 | from __future__ import print_function |
| 13 | from __future__ import division |
| 14 | from __future__ import absolute_import |
| 15 | |
| 16 | import endpoints |
| 17 | import logging |
| 18 | import re |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 19 | from six.moves import urllib |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 20 | |
| 21 | import ezt |
| 22 | import six |
| 23 | |
| 24 | from google.appengine.api import app_identity |
| 25 | from google.appengine.api import oauth |
| 26 | |
| 27 | import webapp2 |
| 28 | |
| 29 | import settings |
| 30 | from businesslogic import work_env |
| 31 | from features import features_constants |
| 32 | from framework import authdata |
| 33 | from framework import exceptions |
| 34 | from framework import framework_bizobj |
| 35 | from framework import framework_constants |
| 36 | from framework import framework_views |
| 37 | from framework import monorailcontext |
| 38 | from framework import permissions |
| 39 | from framework import profiler |
| 40 | from framework import sql |
| 41 | from framework import template_helpers |
| 42 | from proto import api_pb2_v1 |
| 43 | from tracker import tracker_bizobj |
| 44 | from 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. |
| 52 | class 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 | |
| 68 | class 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 | |
| 195 | class 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 |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 243 | self.request_path = request.path |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 244 | self.current_page_url = request.url |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 245 | self.current_page_url_encoded = urllib.parse.quote_plus( |
| 246 | self.current_page_url) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 247 | |
| 248 | # Only accept a hostport from the request that looks valid. |
| 249 | if not _HOSTPORT_RE.match(request.host): |
| 250 | raise exceptions.InputException( |
| 251 | 'request.host looks funny: %r', request.host) |
| 252 | |
| 253 | logging.info('Request: %s', self.current_page_url) |
| 254 | |
| 255 | with self.profiler.Phase('path parsing'): |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 256 | (viewed_user_val, self.project_name, self.hotlist_id, |
| 257 | self.hotlist_name) = _ParsePathIdentifiers(self.request_path) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 258 | self.viewed_username = _GetViewedEmail( |
| 259 | viewed_user_val, self.cnxn, services) |
| 260 | with self.profiler.Phase('qs parsing'): |
| 261 | self._ParseQueryParameters() |
| 262 | with self.profiler.Phase('overrides parsing'): |
| 263 | self._ParseFormOverrides() |
| 264 | |
| 265 | if not self.project: # It can be already set in unit tests. |
| 266 | self._LookupProject(services) |
| 267 | if self.project_id and services.config: |
| 268 | self.config = services.config.GetProjectConfig(self.cnxn, self.project_id) |
| 269 | |
| 270 | if do_user_lookups: |
| 271 | if self.viewed_username: |
| 272 | self._LookupViewedUser(services) |
| 273 | self._LookupLoggedInUser(services) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 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 | |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 290 | def ParseFlaskRequest(self, request, services, do_user_lookups=True): |
| 291 | """Parse tons of useful info from the given flask request object. |
| 292 | |
| 293 | Args: |
| 294 | request: flask Request object w/ path and query params. |
| 295 | services: connections to backend servers including DB. |
| 296 | do_user_lookups: Set to False to disable lookups during testing. |
| 297 | """ |
| 298 | with self.profiler.Phase('basic parsing'): |
| 299 | self.request = request |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 300 | 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] | 301 | self.current_page_url = request.url |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 302 | self.current_page_url_encoded = urllib.parse.quote_plus( |
| 303 | self.current_page_url) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 304 | |
| 305 | # Only accept a hostport from the request that looks valid. |
| 306 | if not _HOSTPORT_RE.match(request.host): |
| 307 | raise exceptions.InputException( |
| 308 | 'request.host looks funny: %r', request.host) |
| 309 | |
| 310 | logging.info('Flask Request: %s', self.current_page_url) |
| 311 | |
| 312 | with self.profiler.Phase('path parsing'): |
| 313 | (viewed_user_val, self.project_name, self.hotlist_id, |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 314 | self.hotlist_name) = _ParsePathIdentifiers(self.request_path) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 315 | self.viewed_username = _GetViewedEmail( |
| 316 | viewed_user_val, self.cnxn, services) |
| 317 | with self.profiler.Phase('qs parsing'): |
| 318 | self._ParseQueryParameters() |
| 319 | with self.profiler.Phase('overrides parsing'): |
| 320 | self._ParseFormOverrides() |
| 321 | |
| 322 | if not self.project: # It can be already set in unit tests. |
| 323 | self._LookupProject(services) |
| 324 | if self.project_id and services.config: |
| 325 | self.config = services.config.GetProjectConfig(self.cnxn, self.project_id) |
| 326 | |
| 327 | if do_user_lookups: |
| 328 | if self.viewed_username: |
| 329 | self._LookupViewedUser(services) |
| 330 | self._LookupLoggedInUser(services) |
| 331 | |
| 332 | if not self.hotlist: |
| 333 | self._LookupHotlist(services) |
| 334 | |
| 335 | if self.query is None: |
| 336 | self.query = self._CalcDefaultQuery() |
| 337 | |
| 338 | prod_debug_allowed = self.perms.HasPerm( |
| 339 | permissions.VIEW_DEBUG, self.auth.user_id, None) |
| 340 | self.debug_enabled = ( |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 341 | request.values.get('debug') and |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 342 | (settings.local_mode or prod_debug_allowed)) |
| 343 | # temporary option for perf testing on staging instance. |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 344 | if request.values.get('disable_cache'): |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 345 | if settings.local_mode or 'staging' in request.host: |
| 346 | self.use_cached_searches = False |
| 347 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 348 | def _CalcDefaultQuery(self): |
| 349 | """When URL has no q= param, return the default for members or ''.""" |
| 350 | if (self.can == 2 and self.project and self.auth.effective_ids and |
| 351 | framework_bizobj.UserIsInProject(self.project, self.auth.effective_ids) |
| 352 | and self.config): |
| 353 | return self.config.member_default_query |
| 354 | else: |
| 355 | return '' |
| 356 | |
| 357 | def _ParseQueryParameters(self): |
| 358 | """Parse and convert all the query string params used in any servlet.""" |
| 359 | self.start = self.GetPositiveIntParam('start', default_value=0) |
| 360 | self.num = self.GetPositiveIntParam( |
| 361 | 'num', default_value=tracker_constants.DEFAULT_RESULTS_PER_PAGE) |
| 362 | # Prevent DoS attacks that try to make us serve really huge result pages. |
| 363 | self.num = min(self.num, settings.max_artifact_search_results_per_page) |
| 364 | |
| 365 | self.invalidation_timestep = self.GetIntParam( |
| 366 | 'invalidation_timestep', default_value=0) |
| 367 | |
| 368 | self.continue_issue_id = self.GetIntParam( |
| 369 | 'continue_issue_id', default_value=0) |
| 370 | self.redir = self.GetParam('redir') |
| 371 | |
| 372 | # Search scope, a.k.a., canned query ID |
| 373 | # TODO(jrobbins): make configurable |
| 374 | self.can = self.GetIntParam( |
| 375 | 'can', default_value=tracker_constants.OPEN_ISSUES_CAN) |
| 376 | |
| 377 | # Search query |
| 378 | self.query = self.GetParam('q') |
| 379 | |
| 380 | # Sorting of search results (needed for result list and flipper) |
| 381 | self.sort_spec = self.GetParam( |
| 382 | 'sort', default_value='', |
| 383 | antitamper_re=framework_constants.SORTSPEC_RE) |
| 384 | self.sort_spec = ' '.join(ParseColSpec(self.sort_spec)) |
| 385 | |
| 386 | # Note: This is set later in request handling by ComputeColSpec(). |
| 387 | self.col_spec = None |
| 388 | |
| 389 | # Grouping of search results (needed for result list and flipper) |
| 390 | self.group_by_spec = self.GetParam( |
| 391 | 'groupby', default_value='', |
| 392 | antitamper_re=framework_constants.SORTSPEC_RE) |
| 393 | self.group_by_spec = ' '.join(ParseColSpec( |
| 394 | self.group_by_spec, ignore=tracker_constants.NOT_USED_IN_GRID_AXES)) |
| 395 | |
| 396 | # For issue list and grid mode. |
| 397 | self.cursor = self.GetParam('cursor') |
| 398 | self.preview = self.GetParam('preview') |
| 399 | self.mode = self.GetParam('mode') or 'list' |
| 400 | self.x = self.GetParam('x', default_value='') |
| 401 | self.y = self.GetParam('y', default_value='') |
| 402 | self.cells = self.GetParam('cells', default_value='ids') |
| 403 | |
| 404 | # For the dashboard and issue lists included in the dashboard. |
| 405 | self.ajah = self.GetParam('ajah') # AJAH = Asychronous Javascript And HTML |
| 406 | self.table_title = self.GetParam('table_title') |
| 407 | self.panel_id = self.GetIntParam('panel') |
| 408 | |
| 409 | # For pagination of updates lists |
| 410 | self.before = self.GetPositiveIntParam('before') |
| 411 | self.after = self.GetPositiveIntParam('after') |
| 412 | |
| 413 | # For cron tasks and backend calls |
| 414 | self.lower_bound = self.GetIntParam('lower_bound') |
| 415 | self.upper_bound = self.GetIntParam('upper_bound') |
| 416 | self.shard_id = self.GetIntParam('shard_id') |
| 417 | |
| 418 | # For specifying which objects to operate on |
| 419 | self.local_id = self.GetIntParam('id') |
| 420 | self.local_id_list = self.GetIntListParam('ids') |
| 421 | self.seq = self.GetIntParam('seq') |
| 422 | self.aid = self.GetIntParam('aid') |
| 423 | self.signed_aid = self.GetParam('signed_aid') |
| 424 | self.specified_user_id = self.GetIntParam('u', default_value=0) |
| 425 | self.specified_logged_in_user_id = self.GetIntParam( |
| 426 | 'logged_in_user_id', default_value=0) |
| 427 | self.specified_me_user_ids = self.GetIntListParam('me_user_ids') |
| 428 | |
| 429 | # TODO(jrobbins): Phase this out after next deployment. If an old |
| 430 | # version of the default GAE module sends a request with the old |
| 431 | # me_user_id= parameter, then accept it. |
| 432 | specified_me_user_id = self.GetIntParam( |
| 433 | 'me_user_id', default_value=0) |
| 434 | if specified_me_user_id: |
| 435 | self.specified_me_user_ids = [specified_me_user_id] |
| 436 | |
| 437 | self.specified_project = self.GetParam('project') |
| 438 | self.specified_project_id = self.GetIntParam('project_id') |
| 439 | self.query_project_names = self.GetListParam('projects', default_value=[]) |
| 440 | self.template_name = self.GetParam('template') |
| 441 | self.component_path = self.GetParam('component') |
| 442 | self.field_name = self.GetParam('field') |
| 443 | |
| 444 | # For image attachments |
| 445 | self.inline = bool(self.GetParam('inline')) |
| 446 | self.thumb = bool(self.GetParam('thumb')) |
| 447 | |
| 448 | # For JS callbacks |
| 449 | self.token = self.GetParam('token') |
| 450 | self.starred = bool(self.GetIntParam('starred')) |
| 451 | |
| 452 | # For issue reindexing utility servlet |
| 453 | self.auto_submit = self.GetParam('auto_submit') |
| 454 | |
| 455 | # For issue dependency reranking servlet |
| 456 | self.parent_id = self.GetIntParam('parent_id') |
| 457 | self.target_id = self.GetIntParam('target_id') |
| 458 | self.moved_ids = self.GetIntListParam('moved_ids') |
| 459 | self.split_above = self.GetBoolParam('split_above') |
| 460 | |
| 461 | # For adding issues to hotlists servlet |
| 462 | self.hotlist_ids_remove = self.GetIntListParam('hotlist_ids_remove') |
| 463 | self.hotlist_ids_add = self.GetIntListParam('hotlist_ids_add') |
| 464 | self.issue_refs = self.GetListParam('issue_refs') |
| 465 | |
| 466 | def _ParseFormOverrides(self): |
| 467 | """Support deep linking by allowing the user to set form fields via QS.""" |
| 468 | allowed_overrides = { |
| 469 | 'template_name': self.GetParam('template_name'), |
| 470 | 'initial_summary': self.GetParam('summary'), |
| 471 | 'initial_description': (self.GetParam('description') or |
| 472 | self.GetParam('comment')), |
| 473 | 'initial_comment': self.GetParam('comment'), |
| 474 | 'initial_status': self.GetParam('status'), |
| 475 | 'initial_owner': self.GetParam('owner'), |
| 476 | 'initial_cc': self.GetParam('cc'), |
| 477 | 'initial_blocked_on': self.GetParam('blockedon'), |
| 478 | 'initial_blocking': self.GetParam('blocking'), |
| 479 | 'initial_merge_into': self.GetIntParam('mergeinto'), |
| 480 | 'initial_components': self.GetParam('components'), |
| 481 | 'initial_hotlists': self.GetParam('hotlists'), |
| 482 | |
| 483 | # For the people pages |
| 484 | 'initial_add_members': self.GetParam('add_members'), |
| 485 | 'initially_expanded_form': ezt.boolean(self.GetParam('expand_form')), |
| 486 | |
| 487 | # For user group admin pages |
| 488 | 'initial_name': (self.GetParam('group_name') or |
| 489 | self.GetParam('proposed_project_name')), |
| 490 | } |
| 491 | |
| 492 | # Only keep the overrides that were actually provided in the query string. |
| 493 | self.form_overrides.update( |
| 494 | (k, v) for (k, v) in allowed_overrides.items() |
| 495 | if v is not None) |
| 496 | |
| 497 | def _LookupViewedUser(self, services): |
| 498 | """Get information about the viewed user (if any) from the request.""" |
| 499 | try: |
| 500 | with self.profiler.Phase('get viewed user, if any'): |
| 501 | self.viewed_user_auth = authdata.AuthData.FromEmail( |
| 502 | self.cnxn, self.viewed_username, services, autocreate=False) |
| 503 | except exceptions.NoSuchUserException: |
| 504 | logging.info('could not find user %r', self.viewed_username) |
| 505 | webapp2.abort(404, 'user not found') |
| 506 | |
| 507 | if not self.viewed_user_auth.user_id: |
| 508 | webapp2.abort(404, 'user not found') |
| 509 | |
| 510 | def _LookupProject(self, services): |
| 511 | """Get information about the current project (if any) from the request. |
| 512 | |
| 513 | Raises: |
| 514 | NoSuchProjectException if there is no project with that name. |
| 515 | """ |
| 516 | logging.info('project_name is %r', self.project_name) |
| 517 | if self.project_name: |
| 518 | self.project = services.project.GetProjectByName( |
| 519 | self.cnxn, self.project_name) |
| 520 | if not self.project: |
| 521 | raise exceptions.NoSuchProjectException() |
| 522 | |
| 523 | def _LookupHotlist(self, services): |
| 524 | """Get information about the current hotlist (if any) from the request.""" |
| 525 | with self.profiler.Phase('get current hotlist, if any'): |
| 526 | if self.hotlist_name: |
| 527 | hotlist_id_dict = services.features.LookupHotlistIDs( |
| 528 | self.cnxn, [self.hotlist_name], [self.viewed_user_auth.user_id]) |
| 529 | try: |
| 530 | self.hotlist_id = hotlist_id_dict[( |
| 531 | self.hotlist_name, self.viewed_user_auth.user_id)] |
| 532 | except KeyError: |
| 533 | webapp2.abort(404, 'invalid hotlist') |
| 534 | |
| 535 | if not self.hotlist_id: |
| 536 | logging.info('no hotlist_id or bad hotlist_name, so no hotlist') |
| 537 | else: |
| 538 | self.hotlist = services.features.GetHotlistByID( |
| 539 | self.cnxn, self.hotlist_id) |
| 540 | if not self.hotlist or ( |
| 541 | self.viewed_user_auth.user_id and |
| 542 | self.viewed_user_auth.user_id not in self.hotlist.owner_ids): |
| 543 | webapp2.abort(404, 'invalid hotlist') |
| 544 | |
| 545 | def _LookupLoggedInUser(self, services): |
| 546 | """Get information about the signed-in user (if any) from the request.""" |
| 547 | self.auth = authdata.AuthData.FromRequest(self.cnxn, services) |
| 548 | self.me_user_id = (self.GetIntParam('me') or |
| 549 | self.viewed_user_auth.user_id or self.auth.user_id) |
| 550 | |
| 551 | self.LookupLoggedInUserPerms(self.project) |
| 552 | |
| 553 | def ComputeColSpec(self, config): |
| 554 | """Set col_spec based on param, default in the config, or site default.""" |
| 555 | if self.col_spec is not None: |
| 556 | return # Already set. |
| 557 | default_col_spec = '' |
| 558 | if config: |
| 559 | default_col_spec = config.default_col_spec |
| 560 | |
| 561 | col_spec = self.GetParam( |
| 562 | 'colspec', default_value=default_col_spec, |
| 563 | antitamper_re=framework_constants.COLSPEC_RE) |
| 564 | cols_lower = col_spec.lower().split() |
| 565 | if self.project and any( |
| 566 | hotlist_col in cols_lower for hotlist_col in [ |
| 567 | 'rank', 'adder', 'added']): |
| 568 | # if the the list is a project list and the 'colspec' is a carry-over |
| 569 | # from hotlists, set col_spec to None so it will be set to default in |
| 570 | # in the next if statement |
| 571 | col_spec = None |
| 572 | |
| 573 | if not col_spec: |
| 574 | # If col spec is still empty then default to the global col spec. |
| 575 | col_spec = tracker_constants.DEFAULT_COL_SPEC |
| 576 | |
| 577 | self.col_spec = ' '.join(ParseColSpec(col_spec, |
| 578 | max_parts=framework_constants.MAX_COL_PARTS)) |
| 579 | |
| 580 | def PrepareForReentry(self, echo_data): |
| 581 | """Expose the results of form processing as if it was a new GET. |
| 582 | |
| 583 | This method is called only when the user submits a form with invalid |
| 584 | information which they are being asked to correct it. Updating the MR |
| 585 | object allows the normal servlet get() method to populate the form with |
| 586 | the entered values and error messages. |
| 587 | |
| 588 | Args: |
| 589 | echo_data: dict of {page_data_key: value_to_reoffer, ...} that will |
| 590 | override whatever HTML form values are nomally shown to the |
| 591 | user when they initially view the form. This allows them to |
| 592 | fix user input that was not valid. |
| 593 | """ |
| 594 | self.form_overrides.update(echo_data) |
| 595 | |
| 596 | def GetParam(self, query_param_name, default_value=None, |
| 597 | antitamper_re=None): |
| 598 | """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] | 599 | value = None |
| 600 | if hasattr(self.request, 'params'): |
| 601 | value = self.request.params.get(query_param_name) |
| 602 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 603 | value = self.request.values.get(query_param_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 604 | assert value is None or isinstance(value, six.text_type) |
| 605 | using_default = value is None |
| 606 | if using_default: |
| 607 | value = default_value |
| 608 | |
| 609 | if antitamper_re and not antitamper_re.match(value): |
| 610 | if using_default: |
| 611 | logging.error('Default value fails antitamper for %s field: %s', |
| 612 | query_param_name, value) |
| 613 | else: |
| 614 | logging.info('User seems to have tampered with %s field: %s', |
| 615 | query_param_name, value) |
| 616 | raise exceptions.InputException() |
| 617 | |
| 618 | return value |
| 619 | |
| 620 | def GetIntParam(self, query_param_name, default_value=None): |
| 621 | """Get an integer param from the URL or default.""" |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 622 | value = None |
| 623 | if hasattr(self.request, 'params'): |
| 624 | value = self.request.params.get(query_param_name) |
| 625 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 626 | value = self.request.values.get(query_param_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 627 | if value is None or value == '': |
| 628 | return default_value |
| 629 | |
| 630 | try: |
| 631 | return int(value) |
| 632 | except (TypeError, ValueError): |
| 633 | raise exceptions.InputException( |
| 634 | 'Invalid value for integer param: %r' % value) |
| 635 | |
| 636 | def GetPositiveIntParam(self, query_param_name, default_value=None): |
| 637 | """Returns 0 if the user-provided value is less than 0.""" |
| 638 | return max(self.GetIntParam(query_param_name, default_value=default_value), |
| 639 | 0) |
| 640 | |
| 641 | def GetListParam(self, query_param_name, default_value=None): |
| 642 | """Get a list of strings from the URL or default.""" |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 643 | params = None |
| 644 | if hasattr(self.request, 'params'): |
| 645 | params = self.request.params.get(query_param_name) |
| 646 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 647 | params = self.request.values.get(query_param_name) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 648 | if params is None: |
| 649 | return default_value |
| 650 | if not params: |
| 651 | return [] |
| 652 | return params.split(',') |
| 653 | |
| 654 | def GetIntListParam(self, query_param_name, default_value=None): |
| 655 | """Get a list of ints from the URL or default.""" |
| 656 | param_list = self.GetListParam(query_param_name) |
| 657 | if param_list is None: |
| 658 | return default_value |
| 659 | |
| 660 | try: |
| 661 | return [int(p) for p in param_list] |
| 662 | except (TypeError, ValueError): |
| 663 | raise exceptions.InputException('Invalid value for integer list param') |
| 664 | |
| 665 | def GetBoolParam(self, query_param_name, default_value=None): |
| 666 | """Get a boolean param from the URL or default.""" |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 667 | value = None |
| 668 | if hasattr(self.request, 'params'): |
| 669 | value = self.request.params.get(query_param_name) |
| 670 | else: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 671 | value = self.request.values.get(query_param_name) |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 672 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 673 | if value is None: |
| 674 | return default_value |
| 675 | |
| 676 | if (not value) or (value.lower() == 'false'): |
| 677 | return False |
| 678 | return True |
| 679 | |
| 680 | |
| 681 | def _ParsePathIdentifiers(path): |
| 682 | """Parse out the workspace being requested (if any). |
| 683 | |
| 684 | Args: |
| 685 | path: A string beginning with the request's path info. |
| 686 | |
| 687 | Returns: |
| 688 | (viewed_user_val, project_name). |
| 689 | """ |
| 690 | viewed_user_val = None |
| 691 | project_name = None |
| 692 | hotlist_id = None |
| 693 | hotlist_name = None |
| 694 | |
| 695 | # Strip off any query params |
| 696 | split_path = path.lstrip('/').split('?')[0].split('/') |
| 697 | if len(split_path) >= 2: |
| 698 | if split_path[0] == 'hotlists': |
| 699 | if split_path[1].isdigit(): |
| 700 | hotlist_id = int(split_path[1]) |
| 701 | if split_path[0] == 'p': |
| 702 | project_name = split_path[1] |
| 703 | if split_path[0] == 'u' or split_path[0] == 'users': |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 704 | viewed_user_val = urllib.parse.unquote(split_path[1]) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 705 | if len(split_path) >= 4 and split_path[2] == 'hotlists': |
| 706 | try: |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 707 | hotlist_id = int(urllib.parse.unquote(split_path[3].split('.')[0])) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 708 | except ValueError: |
| 709 | raw_last_path = (split_path[3][:-3] if |
| 710 | split_path[3].endswith('.do') else split_path[3]) |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 711 | last_path = urllib.parse.unquote(raw_last_path) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 712 | match = framework_bizobj.RE_HOTLIST_NAME.match( |
| 713 | last_path) |
| 714 | if not match: |
| 715 | raise exceptions.InputException( |
| 716 | 'Could not parse hotlist id or name') |
| 717 | else: |
| 718 | hotlist_name = last_path.lower() |
| 719 | |
| 720 | if split_path[0] == 'g': |
Adrià Vilanova Martínez | de94280 | 2022-07-15 14:06:55 +0200 | [diff] [blame] | 721 | viewed_user_val = urllib.parse.unquote(split_path[1]) |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 722 | |
| 723 | return viewed_user_val, project_name, hotlist_id, hotlist_name |
| 724 | |
| 725 | |
| 726 | def _GetViewedEmail(viewed_user_val, cnxn, services): |
| 727 | """Returns the viewed user's email. |
| 728 | |
| 729 | Args: |
| 730 | viewed_user_val: Could be either int (user_id) or str (email). |
| 731 | cnxn: connection to the SQL database. |
| 732 | services: Interface to all persistence storage backends. |
| 733 | |
| 734 | Returns: |
| 735 | viewed_email |
| 736 | """ |
| 737 | if not viewed_user_val: |
| 738 | return None |
| 739 | |
| 740 | try: |
| 741 | viewed_userid = int(viewed_user_val) |
| 742 | viewed_email = services.user.LookupUserEmail(cnxn, viewed_userid) |
| 743 | if not viewed_email: |
| 744 | logging.info('userID %s not found', viewed_userid) |
| 745 | webapp2.abort(404, 'user not found') |
| 746 | except ValueError: |
| 747 | viewed_email = viewed_user_val |
| 748 | |
| 749 | return viewed_email |
| 750 | |
| 751 | |
| 752 | def ParseColSpec( |
| 753 | col_spec, max_parts=framework_constants.MAX_SORT_PARTS, |
| 754 | ignore=None): |
| 755 | """Split a string column spec into a list of column names. |
| 756 | |
| 757 | We dedup col parts because an attacker could try to DoS us or guess |
| 758 | zero or one result by measuring the time to process a request that |
| 759 | has a very long column list. |
| 760 | |
| 761 | Args: |
| 762 | col_spec: a unicode string containing a list of labels. |
| 763 | max_parts: optional int maximum number of parts to consider. |
| 764 | ignore: optional list of column name parts to ignore. |
| 765 | |
| 766 | Returns: |
| 767 | A list of the extracted labels. Non-alphanumeric |
| 768 | characters other than the period will be stripped from the text. |
| 769 | """ |
| 770 | cols = framework_constants.COLSPEC_COL_RE.findall(col_spec) |
| 771 | result = [] # List of column headers with no duplicates. |
| 772 | # Set of column parts that we have processed so far. |
| 773 | seen = set() |
| 774 | if ignore: |
| 775 | seen = set(ignore_col.lower() for ignore_col in ignore) |
| 776 | max_parts += len(ignore) |
| 777 | |
| 778 | for col in cols: |
| 779 | parts = [] |
| 780 | for part in col.split('/'): |
| 781 | if (part.lower() not in seen and len(seen) < max_parts |
| 782 | and len(part) < framework_constants.MAX_COL_LEN): |
| 783 | parts.append(part) |
| 784 | seen.add(part.lower()) |
| 785 | if parts: |
| 786 | result.append('/'.join(parts)) |
| 787 | return result |