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