blob: fce3eab59718986371dfeeb98bdd9e05f7a1f5ab [file] [log] [blame]
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +02001# Copyright 2022 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"""Base classes for Monorail Flask servlets.
6
7This is derived from servlet.py
8This base class provides handler methods that conveniently drive
9the process of parsing the request, checking base permisssion,
10gathering common page information, gathering page-specific information,
11and adding on-page debugging information (when appropriate).
12Subclasses can simply implement the page-specific logic.
13
14Summary of page classes:
15 FlaskServlet: abstract base class for all Monorail flask servlets.
16"""
17
18import gc
19import os
20import httplib
21import logging
22import time
23from businesslogic import work_env
24
25import ezt
26from features import features_bizobj, hotlist_views
27import flask
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020028import httpagentparser
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020029from project import project_constants
30from proto import project_pb2
31from search import query2ast
32
33import settings
34from framework import alerts, exceptions, framework_helpers, urls
35from framework import framework_views, servlet_helpers
36from framework import framework_constants
37from framework import monorailrequest
38from framework import permissions
39from framework import ratelimiter
40from framework import template_helpers
41from framework import xsrf
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020042
43from google.appengine.api import app_identity
44from google.appengine.api import modules
45from google.appengine.api import users
46from tracker import tracker_views
47
48NONCE_LENGTH = 32
49
50if not settings.unit_test_mode:
51 import MySQLdb
52
53class FlaskServlet(object):
54 """Base class for all Monorail flask servlets.
55
56 Defines a framework of methods that build up parts of the EZT page data.
57
58 Subclasses should override GatherPageData and/or ProcessFormData to
59 handle requests.
60 """
61 _MAIN_TAB_MODE = None # Normally overridden in subclasses to be one of these:
62
63 MAIN_TAB_ISSUES = 't2'
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020064 MAIN_TAB_PEOPLE = 't3'
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020065 IN_TAB_PEOPLE = 't3'
66 MAIN_TAB_PROCESS = 't4'
67 MAIN_TAB_UPDATES = 't5'
68 MAIN_TAB_ADMIN = 't6'
69 PROCESS_TAB_SUMMARY = 'st1'
70 PROCESS_TAB_STATUSES = 'st3'
71 PROCESS_TAB_LABELS = 'st4'
72 PROCESS_TAB_RULES = 'st5'
73 PROCESS_TAB_TEMPLATES = 'st6'
74 PROCESS_TAB_COMPONENTS = 'st7'
75 PROCESS_TAB_VIEWS = 'st8'
76 ADMIN_TAB_META = 'st1'
77 ADMIN_TAB_ADVANCED = 'st9'
78 HOTLIST_TAB_ISSUES = 'ht2'
79 HOTLIST_TAB_PEOPLE = 'ht3'
80 HOTLIST_TAB_DETAILS = 'ht4'
81
82 # Most forms require a security token, however if a form is really
83 # just redirecting to a search GET request without writing any data,
84 # subclass can override this to allow anonymous use.
85 CHECK_SECURITY_TOKEN = True
86
87 # Some pages might be posted to by clients outside of Monorail.
88 # ie: The issue entry page, by the issue filing wizard. In these cases,
89 # we can allow an xhr-scoped XSRF token to be used to post to the page.
90 ALLOW_XHR = False
91
92 # This value should not typically be overridden.
93 _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
94
95 _PAGE_TEMPLATE = None # Normally overridden in subclasses.
96 _ELIMINATE_BLANK_LINES = False
97
98 _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
99
100 def __init__(self, services=None, content_type='text/html; charset=UTF-8'):
101 """Load and parse the template, saving it for later use."""
102 if self._PAGE_TEMPLATE: # specified in subclasses
103 template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
104 self.template = template_helpers.GetTemplate(
105 template_path, eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
106 else:
107 self.template = None
108
109 self._missing_permissions_template = template_helpers.MonorailTemplate(
110 self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
111 self.services = services or flask.current_app.config['services']
112 self.content_type = content_type
113 self.mr = None
114 self.request = flask.request
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200115 self.request_path = None
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200116 self.response = None
117 self.ratelimiter = ratelimiter.RateLimiter()
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200118 self.redirect_url = None
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200119
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200120 # pylint: disable=unused-argument
121 def handler(self, **kwargs):
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200122 """Do common stuff then dispatch the request to get() or put() methods."""
123 self.response = flask.make_response()
124 handler_start_time = time.time()
125 logging.info('\n\n\n Flask Request handler: %r', self)
126
127 #TODO: add the ts_mon.NonCumulativeDistributionMetric
128 # count0, count1, count2 = gc.get_count()
129 # logging.info('gc counts: %d %d %d', count0, count1, count2)
130 # GC_COUNT.add(count0, {'generation': 0})
131 # GC_COUNT.add(count1, {'generation': 1})
132 # GC_COUNT.add(count2, {'generation': 2})
133
134 self.mr = monorailrequest.MonorailRequest(self.services)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200135 self.request_path = self.request.base_url[len(self.request.host_url) - 1:]
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200136 self.response.headers.add(
137 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
138
139 if 'X-Cloud-Trace-Context' in self.request.headers:
140 self.mr.profiler.trace_context = (
141 self.request.headers.get('X-Cloud-Trace-Context'))
142
143 if self.services.cache_manager:
144 try:
145 with self.mr.profiler.Phase('distributed invalidation'):
146 self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
147
148 except MySQLdb.OperationalError as e:
149 logging.exception(e)
150 page_data = {
151 'http_response_code': httplib.SERVICE_UNAVAILABLE,
152 'requested_url': self.request.url,
153 }
154 self.template = template_helpers.GetTemplate(
155 'templates/framework/database-maintenance.ezt',
156 eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
157 self.template.WriteFlaskResponse(
158 self.response, page_data, content_type='text/html')
159 return self.response
160
161 try:
162 self.ratelimiter.CheckStart(self.request)
163
164 with self.mr.profiler.Phase('parsing request and doing lookups'):
165 self.mr.ParseFlaskRequest(self.request, self.services)
166
167 self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
168
169 if self.request.method == 'POST':
170 self.post()
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200171 if self.redirect_url:
172 return self.redirect(self.redirect_url)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200173 elif self.request.method == 'GET':
174 self.get()
175 except exceptions.NoSuchUserException as e:
176 logging.info('Trapped NoSuchUserException %s', e)
177 flask.abort(404, 'user not found')
178
179 except exceptions.NoSuchGroupException as e:
180 logging.warning('Trapped NoSuchGroupException %s', e)
181 flask.abort(404, 'user group not found')
182
183 except exceptions.InputException as e:
184 logging.info('Rejecting invalid input: %r', e)
185 self.response.status_code = httplib.BAD_REQUEST
186
187 except exceptions.NoSuchProjectException as e:
188 logging.info('Rejecting invalid request: %r', e)
189 self.response.status_code = httplib.NOT_FOUND
190
191 except xsrf.TokenIncorrect as e:
192 logging.info('Bad XSRF token: %r', e.message)
193 self.response.status_code = httplib.BAD_REQUEST
194
195 except permissions.BannedUserException as e:
196 logging.warning('The user has been banned')
197 url = framework_helpers.FormatAbsoluteURL(
198 self.mr, urls.BANNED, include_project=False, copy_params=False)
199 self.redirect(url, abort=True)
200
201 except ratelimiter.RateLimitExceeded as e:
202 logging.info('RateLimitExceeded Exception %s', e)
203 self.response.status_code = httplib.BAD_REQUEST
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200204 self.response.set_data('Slow your roll.')
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200205
206 finally:
207 self.mr.CleanUp()
208 self.ratelimiter.CheckEnd(self.request, time.time(), handler_start_time)
209
210 total_processing_time = time.time() - handler_start_time
211 logging.info(
212 'Processed request in %d ms', int(total_processing_time * 1000))
213
214 end_count0, end_count1, end_count2 = gc.get_count()
215 logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
216 # TODO: get the GC event back
217 # if (end_count0 < count0) or (end_count1 < count1) or(end_count2 < count2):
218 # GC_EVENT_REQUEST.increment()
219
220 if settings.enable_profiler_logging:
221 self.mr.profiler.LogStats()
222
223 return self.response
224
225 def get(self):
226 """Collect page-specific and generic info, then render the page.
227
228 Args:
229 Any path components parsed by webapp2 will be in kwargs, but we do
230 our own parsing later anyway, so ignore them for now.
231 """
232 page_data = {}
233 nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
234 try:
235 csp_header = 'Content-Security-Policy'
236 csp_scheme = 'https:'
237 if settings.local_mode:
238 csp_header = 'Content-Security-Policy-Report-Only'
239 csp_scheme = 'http:'
240 user_agent_str = self.mr.request.headers.get('User-Agent', '')
241 ua = httpagentparser.detect(user_agent_str)
242 browser, browser_major_version = 'Unknown browser', 0
243 if ua.has_key('browser'):
244 browser = ua['browser']['name']
245 try:
246 browser_major_version = int(ua['browser']['version'].split('.')[0])
247 except ValueError:
248 logging.warn('Could not parse version: %r', ua['browser']['version'])
249 csp_supports_report_sample = (
250 (browser == 'Chrome' and browser_major_version >= 59) or
251 (browser == 'Opera' and browser_major_version >= 46))
252 version_base = servlet_helpers.VersionBaseURL(self.mr.request)
253 self.response.headers.add(
254 csp_header,
255 (
256 "default-src %(scheme)s ; "
257 "script-src"
258 " %(rep_samp)s" # Report 40 chars of any inline violation.
259 " 'unsafe-inline'" # Only counts in browsers that lack CSP2.
260 " 'strict-dynamic'" # Allows <script nonce> to load more.
261 " %(version_base)s/static/dist/"
262 " 'self' 'nonce-%(nonce)s'; "
263 "child-src 'none'; "
264 "frame-src accounts.google.com" # All used by gapi.js auth.
265 " content-issuetracker.corp.googleapis.com"
266 " login.corp.google.com up.corp.googleapis.com"
267 # Used by Google Feedback
268 " feedback.googleusercontent.com"
269 " www.google.com; "
270 "img-src %(scheme)s data: blob: ; "
271 "style-src %(scheme)s 'unsafe-inline'; "
272 "object-src 'none'; "
273 "base-uri 'self'; " # Used by Google Feedback
274 "report-uri /csp.do" % {
275 'nonce':
276 nonce,
277 'scheme':
278 csp_scheme,
279 'rep_samp':
280 "'report-sample'" if csp_supports_report_sample else '',
281 'version_base':
282 version_base,
283 }))
284
285 # add the function to get data and render page
286 page_data.update(self._GatherFlagData(self.mr))
287
288 # Page-specific work happens in this call.
289 page_data.update(self._DoPageProcessing(self.mr, nonce))
290
291 self._AddHelpDebugPageData(page_data)
292
293 with self.mr.profiler.Phase('rendering template'):
294 self._RenderResponse(page_data)
295
296 except (servlet_helpers.MethodNotSupportedError, NotImplementedError) as e:
297 # Instead of these pages throwing 500s display the 404 message and log.
298 # The motivation of this is to minimize 500s on the site to keep alerts
299 # meaningful during fuzzing. For more context see
300 # https://bugs.chromium.org/p/monorail/issues/detail?id=659
301 logging.warning('Trapped NotImplementedError %s', e)
302 flask.abort(404, 'invalid page')
303 except query2ast.InvalidQueryError as e:
304 logging.warning('Trapped InvalidQueryError: %s', e)
305 logging.exception(e)
306 msg = e.message if e.message else 'invalid query'
307 flask.abort(400, msg)
308 except permissions.PermissionException as e:
309 logging.warning('Trapped PermissionException %s', e)
310 logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
311 logging.warning('mr.auth.effective_ids is %s', self.mr.auth.effective_ids)
312 logging.warning('mr.perms is %s', self.mr.perms)
313 if not self.mr.auth.user_id:
314 # If not logged in, let them log in
315 url = servlet_helpers.SafeCreateLoginURL(self.mr)
316 self.redirect(url, abort=True)
317 else:
318 # Display the missing permissions template.
319 page_data = {
320 'reason': e.message,
321 'http_response_code': httplib.FORBIDDEN,
322 }
323 with self.mr.profiler.Phase('gather base data'):
324 page_data.update(self.GatherBaseData(self.mr, nonce))
325 self._AddHelpDebugPageData(page_data)
326 self._missing_permissions_template.WriteFlaskResponse(
327 self.response, page_data, content_type=self.content_type)
328
329 def post(self):
330 logging.info('process post request')
331 try:
332 # Page-specific work happens in this call.
333 self._DoFormProcessing(self.request, self.mr)
334
335 except permissions.PermissionException as e:
336 logging.warning('Trapped permission-related exception "%s".', e)
337 self.response.status_code = httplib.BAD_REQUEST
338
339 def _RenderResponse(self, page_data):
340 logging.info('rendering response len(page_data) is %r', len(page_data))
341 self.template.WriteFlaskResponse(
342 self.response, page_data, content_type=self.content_type)
343
344 def _GatherFlagData(self, mr):
345 page_data = {
346 'project_stars_enabled':
347 ezt.boolean(settings.enable_project_stars),
348 'user_stars_enabled':
349 ezt.boolean(settings.enable_user_stars),
350 'can_create_project':
351 ezt.boolean(permissions.CanCreateProject(mr.perms)),
352 'can_create_group':
353 ezt.boolean(permissions.CanCreateGroup(mr.perms)),
354 }
355
356 return page_data
357
358 def _DoCommonRequestProcessing(self, request, mr):
359 """Do common processing dependent on having the user and project pbs."""
360 with mr.profiler.Phase('basic processing'):
361 self._CheckForMovedProject(mr, request)
362 self.AssertBasePermission(mr)
363
364 # pylint: disable=unused-argument
365 def _DoPageProcessing(self, mr, nonce):
366 """Do user lookups and gather page-specific ezt data."""
367 with mr.profiler.Phase('common request data'):
368
369 self._DoCommonRequestProcessing(self.request, mr)
370
371 self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
372
373 page_data = self.GatherBaseData(mr, nonce)
374
375 with mr.profiler.Phase('page processing'):
376 page_data.update(self.GatherPageData(mr))
377 page_data.update(mr.form_overrides)
378 template_helpers.ExpandLabels(page_data)
379 self._RecordVisitTime(mr)
380
381 return page_data
382
383 def _DoFormProcessing(self, request, mr):
384 """Do user lookups and handle form data."""
385 self._DoCommonRequestProcessing(request, mr)
386
387 if self.CHECK_SECURITY_TOKEN:
388 try:
389 xsrf.ValidateToken(
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200390 request.values.get('token'), mr.auth.user_id, self.request_path)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200391 except xsrf.TokenIncorrect as err:
392 if self.ALLOW_XHR:
393 xsrf.ValidateToken(
394 request.values.get('token'), mr.auth.user_id, 'xhr')
395 else:
396 raise err
397
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200398 self.redirect_url = self.ProcessFormData(mr, request.values)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200399
400 # Most forms redirect the user to a new URL on success. If no
401 # redirect_url was returned, the form handler must have already
402 # sent a response. E.g., bounced the user back to the form with
403 # invalid form fields highlighted.
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200404 if self.redirect_url:
405 return self.redirect(self.redirect_url, abort=True)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200406 else:
407 assert self.response.response
408
409 def ProcessFormData(self, mr, post_data):
410 """Handle form data and redirect appropriately.
411
412 Args:
413 mr: commonly used info parsed from the request.
414 post_data: HTML form data from the request.
415
416 Returns:
417 String URL to redirect the user to, or None if response was already sent.
418 """
419 raise servlet_helpers.MethodNotSupportedError()
420
421 def _FormHandlerURL(self, path):
422 """Return the form handler for the main form on a page."""
423 if path.endswith('/'):
424 return path + 'edit.do'
425 elif path.endswith('.do'):
426 return path # This happens as part of PleaseCorrect().
427 else:
428 return path + '.do'
429
430 # pylint: disable=unused-argument
431 def GatherPageData(self, mr):
432 """Return a dict of page-specific ezt data."""
433 raise servlet_helpers.MethodNotSupportedError()
434
435 def GatherBaseData(self, mr, nonce):
436 """Return a dict of info used on almost all pages."""
437 project = mr.project
438
439 project_summary = ''
440 project_alert = None
441 project_read_only = False
442 project_home_page = ''
443 project_thumbnail_url = ''
444 if project:
445 project_summary = project.summary
446 project_alert = servlet_helpers.CalcProjectAlert(project)
447 project_read_only = project.read_only_reason
448 project_home_page = project.home_page
449 project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
450
451 with work_env.WorkEnv(mr, self.services) as we:
452 is_project_starred = False
453 project_view = None
454 if mr.project:
455 if permissions.UserCanViewProject(mr.auth.user_pb,
456 mr.auth.effective_ids, mr.project):
457 is_project_starred = we.IsProjectStarred(mr.project_id)
458 project_view = template_helpers.PBProxy(mr.project)
459
460 grid_x_attr = None
461 grid_y_attr = None
462 hotlist_view = None
463 if mr.hotlist:
464 users_by_id = framework_views.MakeAllUserViews(
465 mr.cnxn, self.services.user,
466 features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
467 hotlist_view = hotlist_views.HotlistView(
468 mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
469 users_by_id,
470 self.services.hotlist_star.IsItemStarredBy(
471 mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
472 grid_x_attr = mr.x.lower()
473 grid_y_attr = mr.y.lower()
474
475 app_version = os.environ.get('CURRENT_VERSION_ID')
476
477 viewed_username = None
478 if mr.viewed_user_auth.user_view:
479 viewed_username = mr.viewed_user_auth.user_view.username
480
481 config = None
482 if mr.project_id and self.services.config:
483 with mr.profiler.Phase('getting config'):
484 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
485 grid_x_attr = (mr.x or config.default_x_attr).lower()
486 grid_y_attr = (mr.y or config.default_y_attr).lower()
487
488 viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
489 offer_saved_queries_subtab = (
490 viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
491
492 login_url = servlet_helpers.SafeCreateLoginURL(mr)
493 logout_url = servlet_helpers.SafeCreateLogoutURL(mr)
494 logout_url_goto_home = users.create_logout_url('/')
495 version_base = servlet_helpers.VersionBaseURL(mr.request)
496
497 base_data = {
498 # EZT does not have constants for True and False, so we pass them in.
499 'True':
500 ezt.boolean(True),
501 'False':
502 ezt.boolean(False),
503 'local_mode':
504 ezt.boolean(settings.local_mode),
505 'site_name':
506 settings.site_name,
507 'show_search_metadata':
508 ezt.boolean(False),
509 'page_template':
510 self._PAGE_TEMPLATE,
511 'main_tab_mode':
512 self._MAIN_TAB_MODE,
513 'project_summary':
514 project_summary,
515 'project_home_page':
516 project_home_page,
517 'project_thumbnail_url':
518 project_thumbnail_url,
519 'hotlist_id':
520 mr.hotlist_id,
521 'hotlist':
522 hotlist_view,
523 'hostport':
524 mr.request.host,
525 'absolute_base_url':
526 '%s://%s' % (mr.request.scheme, mr.request.host),
527 'project_home_url':
528 None,
529 'link_rel_canonical':
530 None, # For specifying <link rel="canonical">
531 'projectname':
532 mr.project_name,
533 'project':
534 project_view,
535 'project_is_restricted':
536 ezt.boolean(servlet_helpers.ProjectIsRestricted(mr)),
537 'offer_contributor_list':
538 ezt.boolean(permissions.CanViewContributorList(mr, mr.project)),
539 'logged_in_user':
540 mr.auth.user_view,
541 'form_token':
542 None, # Set to a value below iff the user is logged in.
543 'form_token_path':
544 None,
545 'token_expires_sec':
546 None,
547 'xhr_token':
548 None, # Set to a value below iff the user is logged in.
549 'flag_spam_token':
550 None,
551 'nonce':
552 nonce,
553 'perms':
554 mr.perms,
555 'warnings':
556 mr.warnings,
557 'errors':
558 mr.errors,
559 'viewed_username':
560 viewed_username,
561 'viewed_user':
562 mr.viewed_user_auth.user_view,
563 'viewed_user_pb':
564 template_helpers.PBProxy(mr.viewed_user_auth.user_pb),
565 'viewing_self':
566 ezt.boolean(viewing_self),
567 'viewed_user_id':
568 mr.viewed_user_auth.user_id,
569 'offer_saved_queries_subtab':
570 ezt.boolean(offer_saved_queries_subtab),
571 'currentPageURL':
572 mr.current_page_url,
573 'currentPageURLEncoded':
574 mr.current_page_url_encoded,
575 'login_url':
576 login_url,
577 'logout_url':
578 logout_url,
579 'logout_url_goto_home':
580 logout_url_goto_home,
581 'continue_issue_id':
582 mr.continue_issue_id,
583 'feedback_email':
584 settings.feedback_email,
585 'category_css':
586 None, # Used to specify a category of stylesheet
587 'category2_css':
588 None, # specify a 2nd category of stylesheet if needed.
589 'page_css':
590 None, # Used to add a stylesheet to a specific page.
591 'can':
592 mr.can,
593 'query':
594 mr.query,
595 'colspec':
596 None,
597 'sortspec':
598 mr.sort_spec,
599
600 # Options for issuelist display
601 'grid_x_attr':
602 grid_x_attr,
603 'grid_y_attr':
604 grid_y_attr,
605 'grid_cell_mode':
606 mr.cells,
607 'grid_mode':
608 None,
609 'list_mode':
610 None,
611 'chart_mode':
612 None,
613 'is_cross_project':
614 ezt.boolean(False),
615
616 # for project search (some also used in issue search)
617 'start':
618 mr.start,
619 'num':
620 mr.num,
621 'groupby':
622 mr.group_by_spec,
623 'q_field_size':
624 (
625 min(
626 framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
627 max(
628 framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
629 len(mr.query) + framework_constants.AUTOSIZE_STEP))),
630 'mode':
631 None, # Display mode, e.g., grid mode.
632 'ajah':
633 mr.ajah,
634 'table_title':
635 mr.table_title,
636 'alerts':
637 alerts.AlertsView(mr), # For alert.ezt
638 'project_alert':
639 project_alert,
640 'title':
641 None, # First part of page title
642 'title_summary':
643 None, # Appended to title on artifact detail pages
644 'project_read_only':
645 ezt.boolean(project_read_only),
646 'site_read_only':
647 ezt.boolean(settings.read_only),
648 'banner_time':
649 servlet_helpers.GetBannerTime(settings.banner_time),
650 'read_only':
651 ezt.boolean(settings.read_only or project_read_only),
652 'site_banner_message':
653 settings.banner_message,
654 'robots_no_index':
655 None,
656 'analytics_id':
657 settings.analytics_id,
658 'is_project_starred':
659 ezt.boolean(is_project_starred),
660 'version_base':
661 version_base,
662 'app_version':
663 app_version,
664 'gapi_client_id':
665 settings.gapi_client_id,
666 'viewing_user_page':
667 ezt.boolean(False),
668 'old_ui_url':
669 None,
670 'new_ui_url':
671 None,
672 'is_member':
673 ezt.boolean(False),
674 }
675
676 if mr.project:
677 base_data['project_home_url'] = '/p/%s' % mr.project_name
678
679 # Always add xhr-xsrf token because even anon users need some
680 # pRPC methods, e.g., autocomplete, flipper, and charts.
681 base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
682 base_data['xhr_token'] = xsrf.GenerateToken(
683 mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
684 # Always add other anti-xsrf tokens when the user is logged in.
685 if mr.auth.user_id:
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200686 form_token_path = self._FormHandlerURL(mr.request_path)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200687 base_data['form_token'] = xsrf.GenerateToken(
688 mr.auth.user_id, form_token_path)
689 base_data['form_token_path'] = form_token_path
690
691 return base_data
692
693 def _AddHelpDebugPageData(self, page_data):
694 with self.mr.profiler.Phase('help and debug data'):
695 page_data.update(self.GatherHelpData(self.mr, page_data))
696 page_data.update(self.GatherDebugData(self.mr, page_data))
697
698 # pylint: disable=unused-argument
699 def GatherHelpData(self, mr, page_data):
700 """Return a dict of values to drive on-page user help.
701 Subclasses can override this function
702 Args:
703 mr: common information parsed from the HTTP request.
704 page_data: Dictionary of base and page template data.
705
706 Returns:
707 A dict of values to drive on-page user help, to be added to page_data.
708 """
709 help_data = {
710 'cue': None, # for cues.ezt
711 'account_cue': None, # for cues.ezt
712 }
713 dismissed = []
714 if mr.auth.user_pb:
715 with work_env.WorkEnv(mr, self.services) as we:
716 userprefs = we.GetUserPrefs(mr.auth.user_id)
717 dismissed = [pv.name for pv in userprefs.prefs if pv.value == 'true']
718 if (mr.auth.user_pb.vacation_message and
719 'you_are_on_vacation' not in dismissed):
720 help_data['cue'] = 'you_are_on_vacation'
721 if (mr.auth.user_pb.email_bounce_timestamp and
722 'your_email_bounced' not in dismissed):
723 help_data['cue'] = 'your_email_bounced'
724 if mr.auth.user_pb.linked_parent_id:
725 # This one is not dismissable.
726 help_data['account_cue'] = 'switch_to_parent_account'
727 parent_email = self.services.user.LookupUserEmail(
728 mr.cnxn, mr.auth.user_pb.linked_parent_id)
729 help_data['parent_email'] = parent_email
730
731 return help_data
732
733 def GatherDebugData(self, mr, page_data):
734 """Return debugging info for display at the very bottom of the page."""
735 if mr.debug_enabled:
736 debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
737 debug = [('none', 'recorded')]
738 return {
739 'dbg': 'on',
740 'debug': debug,
741 'profiler': mr.profiler,
742 }
743 else:
744 if '?' in mr.current_page_url:
745 debug_url = mr.current_page_url + '&debug=1'
746 else:
747 debug_url = mr.current_page_url + '?debug=1'
748
749 return {
750 'debug_uri': debug_url,
751 'dbg': 'off',
752 'debug': [('none', 'recorded')],
753 }
754
755 def _CheckForMovedProject(self, mr, request):
756 """If the project moved, redirect there or to an informational page."""
757 if not mr.project:
758 return # We are on a site-wide or user page.
759 if not mr.project.moved_to:
760 return # This project has not moved.
761 admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200762 if self.request_path.startswith(admin_url):
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200763 return # It moved, but we are near the page that can un-move it.
764
765 logging.info(
766 'project %s has moved: %s', mr.project.project_name,
767 mr.project.moved_to)
768
769 moved_to = mr.project.moved_to
770 if project_constants.RE_PROJECT_NAME.match(moved_to):
771 # Use the redir query parameter to avoid redirect loops.
772 if mr.redir is None:
773 url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
774 if '?' in url:
775 url += '&redir=1'
776 else:
777 url += '?redir=1'
778 logging.info('trusted move to a new project on our site')
779 self.redirect(url, abort=True)
780
781 logging.info('not a trusted move, will display link to user to click')
782 # Attach the project name as a url param instead of generating a /p/
783 # link to the destination project.
784 url = framework_helpers.FormatAbsoluteURL(
785 mr,
786 urls.PROJECT_MOVED,
787 include_project=False,
788 copy_params=False,
789 project=mr.project_name)
790 self.redirect(url, abort=True)
791
792 def _MaybeRedirectToBrandedDomain(self, request, project_name):
793 """If we are live and the project should be branded, check request host."""
794 if request.values.get('redir'):
795 return # Avoid any chance of a redirect loop.
796 if not project_name:
797 return
798 needed_domain = framework_helpers.GetNeededDomain(
799 project_name, request.host)
800 if not needed_domain:
801 return
802
803 url = 'https://%s%s' % (needed_domain, request.full_path)
804 if '?' in url:
805 url += '&redir=1'
806 else:
807 url += '?redir=1'
808 logging.info('branding redirect to url %r', url)
809 self.redirect(url, abort=True)
810
811 def AssertBasePermission(self, mr):
812 """Make sure that the logged in user has permission to view this page.
813
814 Subclasses should call super, then check additional permissions
815 and raise a PermissionException if the user is not authorized to
816 do something.
817
818 Args:
819 mr: commonly used info parsed from the request.
820
821 Raises:
822 PermissionException: If the user does not have permisssion to view
823 the current page.
824 """
825 servlet_helpers.AssertBasePermission(mr)
826
827 def CheckPerm(self, mr, perm, art=None, granted_perms=None):
828 """Return True if the user can use the requested permission."""
829 return servlet_helpers.CheckPerm(
830 mr, perm, art=art, granted_perms=granted_perms)
831
832 def MakePagePerms(self, mr, art, *perm_list, **kwargs):
833 """Make an EZTItem with a set of permissions needed in a given template.
834
835 Args:
836 mr: commonly used info parsed from the request.
837 art: a project artifact, such as an issue.
838 *perm_list: any number of permission names that are referenced
839 in the EZT template.
840 **kwargs: dictionary that may include 'granted_perms' list of permissions
841 granted to the current user specifically on the current page.
842
843 Returns:
844 An EZTItem with one attribute for each permission and the value
845 of each attribute being an ezt.boolean(). True if the user
846 is permitted to do that action on the given artifact, or
847 False if not.
848 """
849 granted_perms = kwargs.get('granted_perms')
850 page_perms = template_helpers.EZTItem()
851 for perm in perm_list:
852 setattr(
853 page_perms, perm,
854 ezt.boolean(
855 self.CheckPerm(mr, perm, art=art, granted_perms=granted_perms)))
856
857 return page_perms
858
859 def redirect(self, url, abort=False):
860 if abort:
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200861 return flask.redirect(url, code=302)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200862 else:
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200863 return flask.redirect(url)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200864
865 def PleaseCorrect(self, mr, **echo_data):
866 """Show the same form again so that the user can correct their input."""
867 mr.PrepareForReentry(echo_data)
868 self.get()
869
870 def _RecordVisitTime(self, mr, now=None):
871 """Record the signed in user's last visit time, if possible."""
872 now = now or int(time.time())
873 if not settings.read_only and mr.auth.user_id:
874 user_pb = mr.auth.user_pb
875 if (user_pb.last_visit_timestamp <
876 now - framework_constants.VISIT_RESOLUTION):
877 user_pb.last_visit_timestamp = now
878 self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200879
880 def abort(self, code, context):
881 return flask.abort(code, context)