blob: 1ed6935e9f63b3c034af2cd9ea076ffa9ce83736 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# 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"""Base classes for Monorail servlets.
7
8This base class provides HTTP get() and post() methods that
9conveniently drive the process of parsing the request, checking base
10permissions, gathering common page information, gathering
11page-specific information, and adding on-page debugging information
12(when appropriate). Subclasses can simply implement the page-specific
13logic.
14
15Summary of page classes:
16 Servlet: abstract base class for all Monorail servlets.
17 _ContextDebugItem: displays page_data elements for on-page debugging.
18"""
19from __future__ import print_function
20from __future__ import division
21from __future__ import absolute_import
22
23import gc
24import httplib
25import json
26import logging
27import os
28import random
29import time
30import urllib
31
32import ezt
33from third_party import httpagentparser
34
35from google.appengine.api import app_identity
36from google.appengine.api import modules
37from google.appengine.api import users
38from oauth2client.client import GoogleCredentials
39
40import webapp2
41
42import settings
43from businesslogic import work_env
44from features import savedqueries_helpers
45from features import features_bizobj
46from features import hotlist_views
47from framework import alerts
48from framework import exceptions
49from framework import framework_constants
50from framework import framework_helpers
51from framework import framework_views
52from framework import monorailrequest
53from framework import permissions
54from framework import ratelimiter
55from framework import servlet_helpers
56from framework import template_helpers
57from framework import urls
58from framework import xsrf
59from project import project_constants
60from proto import project_pb2
61from search import query2ast
62from tracker import tracker_views
63
64from infra_libs import ts_mon
65
66NONCE_LENGTH = 32
67
68if not settings.unit_test_mode:
69 import MySQLdb
70
71GC_COUNT = ts_mon.NonCumulativeDistributionMetric(
72 'monorail/servlet/gc_count',
73 'Count of objects in each generation tracked by the GC',
74 [ts_mon.IntegerField('generation')])
75
76GC_EVENT_REQUEST = ts_mon.CounterMetric(
77 'monorail/servlet/gc_event_request',
78 'Counts of requests that triggered at least one GC event',
79 [])
80
81# TODO(crbug/monorail:7084): Find a better home for this code.
82trace_service = None
83# TOD0(crbug/monorail:7082): Re-enable this once we have a solution that doesn't
84# inur clatency, or when we're actively using Cloud Tracing data.
85# if app_identity.get_application_id() != 'testing-app':
86# logging.warning('app id: %s', app_identity.get_application_id())
87# try:
88# credentials = GoogleCredentials.get_application_default()
89# trace_service = discovery.build(
90# 'cloudtrace', 'v1', credentials=credentials)
91# except Exception as e:
92# logging.warning('could not get trace service: %s', e)
93
94
95class MethodNotSupportedError(NotImplementedError):
96 """An exception class for indicating that the method is not supported.
97
98 Used by GatherPageData and ProcessFormData to indicate that GET and POST,
99 respectively, are not supported methods on the given Servlet.
100 """
101 pass
102
103
104class Servlet(webapp2.RequestHandler):
105 """Base class for all Monorail servlets.
106
107 Defines a framework of methods that build up parts of the EZT page data.
108
109 Subclasses should override GatherPageData and/or ProcessFormData to
110 handle requests.
111 """
112
113 _MAIN_TAB_MODE = None # Normally overriden in subclasses to be one of these:
114
115 MAIN_TAB_NONE = 't0'
116 MAIN_TAB_DASHBOARD = 't1'
117 MAIN_TAB_ISSUES = 't2'
118 MAIN_TAB_PEOPLE = 't3'
119 MAIN_TAB_PROCESS = 't4'
120 MAIN_TAB_UPDATES = 't5'
121 MAIN_TAB_ADMIN = 't6'
122 MAIN_TAB_DETAILS = 't7'
123 PROCESS_TAB_SUMMARY = 'st1'
124 PROCESS_TAB_STATUSES = 'st3'
125 PROCESS_TAB_LABELS = 'st4'
126 PROCESS_TAB_RULES = 'st5'
127 PROCESS_TAB_TEMPLATES = 'st6'
128 PROCESS_TAB_COMPONENTS = 'st7'
129 PROCESS_TAB_VIEWS = 'st8'
130 ADMIN_TAB_META = 'st1'
131 ADMIN_TAB_ADVANCED = 'st9'
132 HOTLIST_TAB_ISSUES = 'ht2'
133 HOTLIST_TAB_PEOPLE = 'ht3'
134 HOTLIST_TAB_DETAILS = 'ht4'
135
136 # Most forms require a security token, however if a form is really
137 # just redirecting to a search GET request without writing any data,
138 # subclass can override this to allow anonymous use.
139 CHECK_SECURITY_TOKEN = True
140
141 # Some pages might be posted to by clients outside of Monorail.
142 # ie: The issue entry page, by the issue filing wizard. In these cases,
143 # we can allow an xhr-scoped XSRF token to be used to post to the page.
144 ALLOW_XHR = False
145
146 # Most forms just ignore fields that have value "". Subclasses can override
147 # if needed.
148 KEEP_BLANK_FORM_VALUES = False
149
150 # Most forms use regular forms, but subclasses that accept attached files can
151 # override this to be True.
152 MULTIPART_POST_BODY = False
153
154 # This value should not typically be overridden.
155 _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
156
157 _PAGE_TEMPLATE = None # Normally overriden in subclasses.
158 _ELIMINATE_BLANK_LINES = False
159
160 _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
161
162 def __init__(self, request, response, services=None,
163 content_type='text/html; charset=UTF-8'):
164 """Load and parse the template, saving it for later use."""
165 super(Servlet, self).__init__(request, response)
166 if self._PAGE_TEMPLATE: # specified in subclasses
167 template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
168 self.template = template_helpers.GetTemplate(
169 template_path, eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
170 else:
171 self.template = None
172
173 self._missing_permissions_template = template_helpers.MonorailTemplate(
174 self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
175 self.services = services or self.app.config.get('services')
176 self.content_type = content_type
177 self.mr = None
178 self.ratelimiter = ratelimiter.RateLimiter()
179
180 def dispatch(self):
181 """Do common stuff then dispatch the request to get() or put() methods."""
182 handler_start_time = time.time()
183
184 logging.info('\n\n\nRequest handler: %r', self)
185 count0, count1, count2 = gc.get_count()
186 logging.info('gc counts: %d %d %d', count0, count1, count2)
187 GC_COUNT.add(count0, {'generation': 0})
188 GC_COUNT.add(count1, {'generation': 1})
189 GC_COUNT.add(count2, {'generation': 2})
190
191 self.mr = monorailrequest.MonorailRequest(self.services)
192
193 self.response.headers.add('Strict-Transport-Security',
194 'max-age=31536000; includeSubDomains')
195
196 if 'X-Cloud-Trace-Context' in self.request.headers:
197 self.mr.profiler.trace_context = (
198 self.request.headers.get('X-Cloud-Trace-Context'))
199 # TOD0(crbug/monorail:7082): Re-enable tracing.
200 # if trace_service is not None:
201 # self.mr.profiler.trace_service = trace_service
202
203 if self.services.cache_manager:
204 # TODO(jrobbins): don't do this step if invalidation_timestep was
205 # passed via the request and matches our last timestep
206 try:
207 with self.mr.profiler.Phase('distributed invalidation'):
208 self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
209
210 except MySQLdb.OperationalError as e:
211 logging.exception(e)
212 page_data = {
213 'http_response_code': httplib.SERVICE_UNAVAILABLE,
214 'requested_url': self.request.url,
215 }
216 self.template = template_helpers.GetTemplate(
217 'templates/framework/database-maintenance.ezt',
218 eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
219 self.template.WriteResponse(
220 self.response, page_data, content_type='text/html')
221 return
222
223 try:
224 self.ratelimiter.CheckStart(self.request)
225
226 with self.mr.profiler.Phase('parsing request and doing lookups'):
227 self.mr.ParseRequest(self.request, self.services)
228
229 self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
230 webapp2.RequestHandler.dispatch(self)
231
232 except exceptions.NoSuchUserException as e:
233 logging.warning('Trapped NoSuchUserException %s', e)
234 self.abort(404, 'user not found')
235
236 except exceptions.NoSuchGroupException as e:
237 logging.warning('Trapped NoSuchGroupException %s', e)
238 self.abort(404, 'user group not found')
239
240 except exceptions.InputException as e:
241 logging.info('Rejecting invalid input: %r', e)
242 self.response.status = httplib.BAD_REQUEST
243
244 except exceptions.NoSuchProjectException as e:
245 logging.info('Rejecting invalid request: %r', e)
246 self.response.status = httplib.NOT_FOUND
247
248 except xsrf.TokenIncorrect as e:
249 logging.info('Bad XSRF token: %r', e.message)
250 self.response.status = httplib.BAD_REQUEST
251
252 except permissions.BannedUserException as e:
253 logging.warning('The user has been banned')
254 url = framework_helpers.FormatAbsoluteURL(
255 self.mr, urls.BANNED, include_project=False, copy_params=False)
256 self.redirect(url, abort=True)
257
258 except ratelimiter.RateLimitExceeded as e:
259 logging.info('RateLimitExceeded Exception %s', e)
260 self.response.status = httplib.BAD_REQUEST
261 self.response.body = 'Slow your roll.'
262
263 finally:
264 self.mr.CleanUp()
265 self.ratelimiter.CheckEnd(self.request, time.time(), handler_start_time)
266
267 total_processing_time = time.time() - handler_start_time
268 logging.info(
269 'Processed request in %d ms', int(total_processing_time * 1000))
270
271 end_count0, end_count1, end_count2 = gc.get_count()
272 logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
273 if (end_count0 < count0) or (end_count1 < count1) or (end_count2 < count2):
274 GC_EVENT_REQUEST.increment()
275
276 if settings.enable_profiler_logging:
277 self.mr.profiler.LogStats()
278
279 # TOD0(crbug/monorail:7082, crbug/monorail:7088): Re-enable this when we
280 # have solved the latency, or when we really need the profiler data.
281 # if self.mr.profiler.trace_context is not None:
282 # try:
283 # self.mr.profiler.ReportTrace()
284 # except Exception as ex:
285 # # We never want Cloud Tracing to cause a user-facing error.
286 # logging.warning('Ignoring exception reporting Cloud Trace %s', ex)
287
288 def _AddHelpDebugPageData(self, page_data):
289 with self.mr.profiler.Phase('help and debug data'):
290 page_data.update(self.GatherHelpData(self.mr, page_data))
291 page_data.update(self.GatherDebugData(self.mr, page_data))
292
293 # pylint: disable=unused-argument
294 def get(self, **kwargs):
295 """Collect page-specific and generic info, then render the page.
296
297 Args:
298 Any path components parsed by webapp2 will be in kwargs, but we do
299 our own parsing later anyway, so igore them for now.
300 """
301 page_data = {}
302 nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
303 try:
304 csp_header = 'Content-Security-Policy'
305 csp_scheme = 'https:'
306 if settings.local_mode:
307 csp_header = 'Content-Security-Policy-Report-Only'
308 csp_scheme = 'http:'
309 user_agent_str = self.mr.request.headers.get('User-Agent', '')
310 ua = httpagentparser.detect(user_agent_str)
311 browser, browser_major_version = 'Unknown browser', 0
312 if ua.has_key('browser'):
313 browser = ua['browser']['name']
314 try:
315 browser_major_version = int(ua['browser']['version'].split('.')[0])
316 except ValueError:
317 logging.warn('Could not parse version: %r', ua['browser']['version'])
318 csp_supports_report_sample = (
319 (browser == 'Chrome' and browser_major_version >= 59) or
320 (browser == 'Opera' and browser_major_version >= 46))
321 version_base = _VersionBaseURL(self.mr.request)
322 self.response.headers.add(csp_header,
323 ("default-src %(scheme)s ; "
324 "script-src"
325 " %(rep_samp)s" # Report 40 chars of any inline violation.
326 " 'unsafe-inline'" # Only counts in browsers that lack CSP2.
327 " 'strict-dynamic'" # Allows <script nonce> to load more.
328 " %(version_base)s/static/dist/"
329 " 'self' 'nonce-%(nonce)s'; "
330 "child-src 'none'; "
331 "frame-src accounts.google.com" # All used by gapi.js auth.
332 " content-issuetracker.corp.googleapis.com"
333 " login.corp.google.com up.corp.googleapis.com"
334 # Used by Google Feedback
335 " feedback.googleusercontent.com"
336 " www.google.com; "
337 "img-src %(scheme)s data: blob: ; "
338 "style-src %(scheme)s 'unsafe-inline'; "
339 "object-src 'none'; "
340 "base-uri 'self'; " # Used by Google Feedback
341 "report-uri /csp.do" % {
342 'nonce': nonce,
343 'scheme': csp_scheme,
344 'rep_samp': "'report-sample'" if csp_supports_report_sample else '',
345 'version_base': version_base,
346 }))
347
348 page_data.update(self._GatherFlagData(self.mr))
349
350 # Page-specific work happens in this call.
351 page_data.update(self._DoPageProcessing(self.mr, nonce))
352
353 self._AddHelpDebugPageData(page_data)
354
355 with self.mr.profiler.Phase('rendering template'):
356 self._RenderResponse(page_data)
357
358 except (MethodNotSupportedError, NotImplementedError) as e:
359 # Instead of these pages throwing 500s display the 404 message and log.
360 # The motivation of this is to minimize 500s on the site to keep alerts
361 # meaningful during fuzzing. For more context see
362 # https://bugs.chromium.org/p/monorail/issues/detail?id=659
363 logging.warning('Trapped NotImplementedError %s', e)
364 self.abort(404, 'invalid page')
365 except query2ast.InvalidQueryError as e:
366 logging.warning('Trapped InvalidQueryError: %s', e)
367 logging.exception(e)
368 msg = e.message if e.message else 'invalid query'
369 self.abort(400, msg)
370 except permissions.PermissionException as e:
371 logging.warning('Trapped PermissionException %s', e)
372 logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
373 logging.warning('mr.auth.effective_ids is %s', self.mr.auth.effective_ids)
374 logging.warning('mr.perms is %s', self.mr.perms)
375 if not self.mr.auth.user_id:
376 # If not logged in, let them log in
377 url = _SafeCreateLoginURL(self.mr)
378 self.redirect(url, abort=True)
379 else:
380 # Display the missing permissions template.
381 page_data = {
382 'reason': e.message,
383 'http_response_code': httplib.FORBIDDEN,
384 }
385 with self.mr.profiler.Phase('gather base data'):
386 page_data.update(self.GatherBaseData(self.mr, nonce))
387 self._AddHelpDebugPageData(page_data)
388 self._missing_permissions_template.WriteResponse(
389 self.response, page_data, content_type=self.content_type)
390
391 def SetCacheHeaders(self, response):
392 """Set headers to allow the response to be cached."""
393 headers = framework_helpers.StaticCacheHeaders()
394 for name, value in headers:
395 response.headers[name] = value
396
397 def GetTemplate(self, _page_data):
398 """Get the template to use for writing the http response.
399
400 Defaults to self.template. This method can be overwritten in subclasses
401 to allow dynamic template selection based on page_data.
402
403 Args:
404 _page_data: A dict of data for ezt rendering, containing base ezt
405 data, page data, and debug data.
406
407 Returns:
408 The template to be used for writing the http response.
409 """
410 return self.template
411
412 def _GatherFlagData(self, mr):
413 page_data = {
414 'project_stars_enabled': ezt.boolean(
415 settings.enable_project_stars),
416 'user_stars_enabled': ezt.boolean(settings.enable_user_stars),
417 'can_create_project': ezt.boolean(
418 permissions.CanCreateProject(mr.perms)),
419 'can_create_group': ezt.boolean(
420 permissions.CanCreateGroup(mr.perms)),
421 }
422
423 return page_data
424
425 def _RenderResponse(self, page_data):
426 logging.info('rendering response len(page_data) is %r', len(page_data))
427 self.GetTemplate(page_data).WriteResponse(
428 self.response, page_data, content_type=self.content_type)
429
430 def ProcessFormData(self, mr, post_data):
431 """Handle form data and redirect appropriately.
432
433 Args:
434 mr: commonly used info parsed from the request.
435 post_data: HTML form data from the request.
436
437 Returns:
438 String URL to redirect the user to, or None if response was already sent.
439 """
440 raise MethodNotSupportedError()
441
442 def post(self, **kwargs):
443 """Parse the request, check base perms, and call form-specific code."""
444 try:
445 # Page-specific work happens in this call.
446 self._DoFormProcessing(self.request, self.mr)
447
448 except permissions.PermissionException as e:
449 logging.warning('Trapped permission-related exception "%s".', e)
450 # TODO(jrobbins): can we do better than an error page? not much.
451 self.response.status = httplib.BAD_REQUEST
452
453 def _DoCommonRequestProcessing(self, request, mr):
454 """Do common processing dependent on having the user and project pbs."""
455 with mr.profiler.Phase('basic processing'):
456 self._CheckForMovedProject(mr, request)
457 self.AssertBasePermission(mr)
458
459 def _DoPageProcessing(self, mr, nonce):
460 """Do user lookups and gather page-specific ezt data."""
461 with mr.profiler.Phase('common request data'):
462 self._DoCommonRequestProcessing(self.request, mr)
463 self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
464 page_data = self.GatherBaseData(mr, nonce)
465
466 with mr.profiler.Phase('page processing'):
467 page_data.update(self.GatherPageData(mr))
468 page_data.update(mr.form_overrides)
469 template_helpers.ExpandLabels(page_data)
470 self._RecordVisitTime(mr)
471
472 return page_data
473
474 def _DoFormProcessing(self, request, mr):
475 """Do user lookups and handle form data."""
476 self._DoCommonRequestProcessing(request, mr)
477
478 if self.CHECK_SECURITY_TOKEN:
479 try:
480 xsrf.ValidateToken(
481 request.POST.get('token'), mr.auth.user_id, request.path)
482 except xsrf.TokenIncorrect as err:
483 if self.ALLOW_XHR:
484 xsrf.ValidateToken(request.POST.get('token'), mr.auth.user_id, 'xhr')
485 else:
486 raise err
487
488 redirect_url = self.ProcessFormData(mr, request.POST)
489
490 # Most forms redirect the user to a new URL on success. If no
491 # redirect_url was returned, the form handler must have already
492 # sent a response. E.g., bounced the user back to the form with
493 # invalid form fields higlighted.
494 if redirect_url:
495 self.redirect(redirect_url, abort=True)
496 else:
497 assert self.response.body
498
499 def _CheckForMovedProject(self, mr, request):
500 """If the project moved, redirect there or to an informational page."""
501 if not mr.project:
502 return # We are on a site-wide or user page.
503 if not mr.project.moved_to:
504 return # This project has not moved.
505 admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
506 if request.path.startswith(admin_url):
507 return # It moved, but we are near the page that can un-move it.
508
509 logging.info('project %s has moved: %s', mr.project.project_name,
510 mr.project.moved_to)
511
512 moved_to = mr.project.moved_to
513 if project_constants.RE_PROJECT_NAME.match(moved_to):
514 # Use the redir query parameter to avoid redirect loops.
515 if mr.redir is None:
516 url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
517 if '?' in url:
518 url += '&redir=1'
519 else:
520 url += '?redir=1'
521 logging.info('trusted move to a new project on our site')
522 self.redirect(url, abort=True)
523
524 logging.info('not a trusted move, will display link to user to click')
525 # Attach the project name as a url param instead of generating a /p/
526 # link to the destination project.
527 url = framework_helpers.FormatAbsoluteURL(
528 mr, urls.PROJECT_MOVED,
529 include_project=False, copy_params=False, project=mr.project_name)
530 self.redirect(url, abort=True)
531
532 def _MaybeRedirectToBrandedDomain(self, request, project_name):
533 """If we are live and the project should be branded, check request host."""
534 if request.params.get('redir'):
535 return # Avoid any chance of a redirect loop.
536 if not project_name:
537 return
538 needed_domain = framework_helpers.GetNeededDomain(
539 project_name, request.host)
540 if not needed_domain:
541 return
542
543 url = 'https://%s%s' % (needed_domain, request.path_qs)
544 if '?' in url:
545 url += '&redir=1'
546 else:
547 url += '?redir=1'
548 logging.info('branding redirect to url %r', url)
549 self.redirect(url, abort=True)
550
551 def CheckPerm(self, mr, perm, art=None, granted_perms=None):
552 """Return True if the user can use the requested permission."""
553 return servlet_helpers.CheckPerm(
554 mr, perm, art=art, granted_perms=granted_perms)
555
556 def MakePagePerms(self, mr, art, *perm_list, **kwargs):
557 """Make an EZTItem with a set of permissions needed in a given template.
558
559 Args:
560 mr: commonly used info parsed from the request.
561 art: a project artifact, such as an issue.
562 *perm_list: any number of permission names that are referenced
563 in the EZT template.
564 **kwargs: dictionary that may include 'granted_perms' list of permissions
565 granted to the current user specifically on the current page.
566
567 Returns:
568 An EZTItem with one attribute for each permission and the value
569 of each attribute being an ezt.boolean(). True if the user
570 is permitted to do that action on the given artifact, or
571 False if not.
572 """
573 granted_perms = kwargs.get('granted_perms')
574 page_perms = template_helpers.EZTItem()
575 for perm in perm_list:
576 setattr(
577 page_perms, perm,
578 ezt.boolean(self.CheckPerm(
579 mr, perm, art=art, granted_perms=granted_perms)))
580
581 return page_perms
582
583 def AssertBasePermission(self, mr):
584 """Make sure that the logged in user has permission to view this page.
585
586 Subclasses should call super, then check additional permissions
587 and raise a PermissionException if the user is not authorized to
588 do something.
589
590 Args:
591 mr: commonly used info parsed from the request.
592
593 Raises:
594 PermissionException: If the user does not have permisssion to view
595 the current page.
596 """
597 servlet_helpers.AssertBasePermission(mr)
598
599 def GatherBaseData(self, mr, nonce):
600 """Return a dict of info used on almost all pages."""
601 project = mr.project
602
603 project_summary = ''
604 project_alert = None
605 project_read_only = False
606 project_home_page = ''
607 project_thumbnail_url = ''
608 if project:
609 project_summary = project.summary
610 project_alert = _CalcProjectAlert(project)
611 project_read_only = project.read_only_reason
612 project_home_page = project.home_page
613 project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
614
615 with work_env.WorkEnv(mr, self.services) as we:
616 is_project_starred = False
617 project_view = None
618 if mr.project:
619 if permissions.UserCanViewProject(
620 mr.auth.user_pb, mr.auth.effective_ids, mr.project):
621 is_project_starred = we.IsProjectStarred(mr.project_id)
622 # TODO(jrobbins): should this be a ProjectView?
623 project_view = template_helpers.PBProxy(mr.project)
624
625 grid_x_attr = None
626 grid_y_attr = None
627 hotlist_view = None
628 if mr.hotlist:
629 users_by_id = framework_views.MakeAllUserViews(
630 mr.cnxn, self.services.user,
631 features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
632 hotlist_view = hotlist_views.HotlistView(
633 mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
634 users_by_id, self.services.hotlist_star.IsItemStarredBy(
635 mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
636 grid_x_attr = mr.x.lower()
637 grid_y_attr = mr.y.lower()
638
639 app_version = os.environ.get('CURRENT_VERSION_ID')
640
641 viewed_username = None
642 if mr.viewed_user_auth.user_view:
643 viewed_username = mr.viewed_user_auth.user_view.username
644
645 config = None
646 if mr.project_id and self.services.config:
647 with mr.profiler.Phase('getting config'):
648 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
649 grid_x_attr = (mr.x or config.default_x_attr).lower()
650 grid_y_attr = (mr.y or config.default_y_attr).lower()
651
652 viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
653 offer_saved_queries_subtab = (
654 viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
655
656 login_url = _SafeCreateLoginURL(mr)
657 logout_url = _SafeCreateLogoutURL(mr)
658 logout_url_goto_home = users.create_logout_url('/')
659 version_base = _VersionBaseURL(mr.request)
660
661 base_data = {
662 # EZT does not have constants for True and False, so we pass them in.
663 'True':
664 ezt.boolean(True),
665 'False':
666 ezt.boolean(False),
667 'local_mode':
668 ezt.boolean(settings.local_mode),
669 'site_name':
670 settings.site_name,
671 'show_search_metadata':
672 ezt.boolean(False),
673 'page_template':
674 self._PAGE_TEMPLATE,
675 'main_tab_mode':
676 self._MAIN_TAB_MODE,
677 'project_summary':
678 project_summary,
679 'project_home_page':
680 project_home_page,
681 'project_thumbnail_url':
682 project_thumbnail_url,
683 'hotlist_id':
684 mr.hotlist_id,
685 'hotlist':
686 hotlist_view,
687 'hostport':
688 mr.request.host,
689 'absolute_base_url':
690 '%s://%s' % (mr.request.scheme, mr.request.host),
691 'project_home_url':
692 None,
693 'link_rel_canonical':
694 None, # For specifying <link rel="canonical">
695 'projectname':
696 mr.project_name,
697 'project':
698 project_view,
699 'project_is_restricted':
700 ezt.boolean(_ProjectIsRestricted(mr)),
701 'offer_contributor_list':
702 ezt.boolean(permissions.CanViewContributorList(mr, mr.project)),
703 'logged_in_user':
704 mr.auth.user_view,
705 'form_token':
706 None, # Set to a value below iff the user is logged in.
707 'form_token_path':
708 None,
709 'token_expires_sec':
710 None,
711 'xhr_token':
712 None, # Set to a value below iff the user is logged in.
713 'flag_spam_token':
714 None,
715 'nonce':
716 nonce,
717 'perms':
718 mr.perms,
719 'warnings':
720 mr.warnings,
721 'errors':
722 mr.errors,
723 'viewed_username':
724 viewed_username,
725 'viewed_user':
726 mr.viewed_user_auth.user_view,
727 'viewed_user_pb':
728 template_helpers.PBProxy(mr.viewed_user_auth.user_pb),
729 'viewing_self':
730 ezt.boolean(viewing_self),
731 'viewed_user_id':
732 mr.viewed_user_auth.user_id,
733 'offer_saved_queries_subtab':
734 ezt.boolean(offer_saved_queries_subtab),
735 'currentPageURL':
736 mr.current_page_url,
737 'currentPageURLEncoded':
738 mr.current_page_url_encoded,
739 'login_url':
740 login_url,
741 'logout_url':
742 logout_url,
743 'logout_url_goto_home':
744 logout_url_goto_home,
745 'continue_issue_id':
746 mr.continue_issue_id,
747 'feedback_email':
748 settings.feedback_email,
749 'category_css':
750 None, # Used to specify a category of stylesheet
751 'category2_css':
752 None, # specify a 2nd category of stylesheet if needed.
753 'page_css':
754 None, # Used to add a stylesheet to a specific page.
755 'can':
756 mr.can,
757 'query':
758 mr.query,
759 'colspec':
760 None,
761 'sortspec':
762 mr.sort_spec,
763
764 # Options for issuelist display
765 'grid_x_attr':
766 grid_x_attr,
767 'grid_y_attr':
768 grid_y_attr,
769 'grid_cell_mode':
770 mr.cells,
771 'grid_mode':
772 None,
773 'list_mode':
774 None,
775 'chart_mode':
776 None,
777 'is_cross_project':
778 ezt.boolean(False),
779
780 # for project search (some also used in issue search)
781 'start':
782 mr.start,
783 'num':
784 mr.num,
785 'groupby':
786 mr.group_by_spec,
787 'q_field_size': (min(
788 framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
789 max(framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
790 len(mr.query) + framework_constants.AUTOSIZE_STEP))),
791 'mode':
792 None, # Display mode, e.g., grid mode.
793 'ajah':
794 mr.ajah,
795 'table_title':
796 mr.table_title,
797 'alerts':
798 alerts.AlertsView(mr), # For alert.ezt
799 'project_alert':
800 project_alert,
801 'title':
802 None, # First part of page title
803 'title_summary':
804 None, # Appended to title on artifact detail pages
805
806 # TODO(jrobbins): make sure that the templates use
807 # project_read_only for project-mutative actions and if any
808 # uses of read_only remain.
809 'project_read_only':
810 ezt.boolean(project_read_only),
811 'site_read_only':
812 ezt.boolean(settings.read_only),
813 'banner_time':
814 servlet_helpers.GetBannerTime(settings.banner_time),
815 'read_only':
816 ezt.boolean(settings.read_only or project_read_only),
817 'site_banner_message':
818 settings.banner_message,
819 'robots_no_index':
820 None,
821 'analytics_id':
822 settings.analytics_id,
823 'is_project_starred':
824 ezt.boolean(is_project_starred),
825 'version_base':
826 version_base,
827 'app_version':
828 app_version,
829 'gapi_client_id':
830 settings.gapi_client_id,
831 'viewing_user_page':
832 ezt.boolean(False),
833 'old_ui_url':
834 None,
835 'new_ui_url':
836 None,
837 'is_member':
838 ezt.boolean(False),
839 }
840
841 if mr.project:
842 base_data['project_home_url'] = '/p/%s' % mr.project_name
843
844 # Always add xhr-xsrf token because even anon users need some
845 # pRPC methods, e.g., autocomplete, flipper, and charts.
846 base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
847 base_data['xhr_token'] = xsrf.GenerateToken(
848 mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
849 # Always add other anti-xsrf tokens when the user is logged in.
850 if mr.auth.user_id:
851 form_token_path = self._FormHandlerURL(mr.request.path)
852 base_data['form_token'] = xsrf.GenerateToken(
853 mr.auth.user_id, form_token_path)
854 base_data['form_token_path'] = form_token_path
855
856 return base_data
857
858 def _FormHandlerURL(self, path):
859 """Return the form handler for the main form on a page."""
860 if path.endswith('/'):
861 return path + 'edit.do'
862 elif path.endswith('.do'):
863 return path # This happens as part of PleaseCorrect().
864 else:
865 return path + '.do'
866
867 def GatherPageData(self, mr):
868 """Return a dict of page-specific ezt data."""
869 raise MethodNotSupportedError()
870
871 # pylint: disable=unused-argument
872 def GatherHelpData(self, mr, page_data):
873 """Return a dict of values to drive on-page user help.
874
875 Args:
876 mr: common information parsed from the HTTP request.
877 page_data: Dictionary of base and page template data.
878
879 Returns:
880 A dict of values to drive on-page user help, to be added to page_data.
881 """
882 help_data = {
883 'cue': None, # for cues.ezt
884 'account_cue': None, # for cues.ezt
885 }
886 dismissed = []
887 if mr.auth.user_pb:
888 with work_env.WorkEnv(mr, self.services) as we:
889 userprefs = we.GetUserPrefs(mr.auth.user_id)
890 dismissed = [
891 pv.name for pv in userprefs.prefs if pv.value == 'true']
892 if (mr.auth.user_pb.vacation_message and
893 'you_are_on_vacation' not in dismissed):
894 help_data['cue'] = 'you_are_on_vacation'
895 if (mr.auth.user_pb.email_bounce_timestamp and
896 'your_email_bounced' not in dismissed):
897 help_data['cue'] = 'your_email_bounced'
898 if mr.auth.user_pb.linked_parent_id:
899 # This one is not dismissable.
900 help_data['account_cue'] = 'switch_to_parent_account'
901 parent_email = self.services.user.LookupUserEmail(
902 mr.cnxn, mr.auth.user_pb.linked_parent_id)
903 help_data['parent_email'] = parent_email
904
905 return help_data
906
907 def GatherDebugData(self, mr, page_data):
908 """Return debugging info for display at the very bottom of the page."""
909 if mr.debug_enabled:
910 debug = [_ContextDebugCollection('Page data', page_data)]
911 return {
912 'dbg': 'on',
913 'debug': debug,
914 'profiler': mr.profiler,
915 }
916 else:
917 if '?' in mr.current_page_url:
918 debug_url = mr.current_page_url + '&debug=1'
919 else:
920 debug_url = mr.current_page_url + '?debug=1'
921
922 return {
923 'debug_uri': debug_url,
924 'dbg': 'off',
925 'debug': [('none', 'recorded')],
926 }
927
928 def PleaseCorrect(self, mr, **echo_data):
929 """Show the same form again so that the user can correct their input."""
930 mr.PrepareForReentry(echo_data)
931 self.get()
932
933 def _RecordVisitTime(self, mr, now=None):
934 """Record the signed in user's last visit time, if possible."""
935 now = now or int(time.time())
936 if not settings.read_only and mr.auth.user_id:
937 user_pb = mr.auth.user_pb
938 if (user_pb.last_visit_timestamp <
939 now - framework_constants.VISIT_RESOLUTION):
940 user_pb.last_visit_timestamp = now
941 self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
942
943
944def _CalcProjectAlert(project):
945 """Return a string to be shown as red text explaning the project state."""
946
947 project_alert = None
948
949 if project.read_only_reason:
950 project_alert = 'READ-ONLY: %s.' % project.read_only_reason
951 if project.moved_to:
952 project_alert = 'This project has moved to: %s.' % project.moved_to
953 elif project.delete_time:
954 delay_seconds = project.delete_time - time.time()
955 delay_days = delay_seconds // framework_constants.SECS_PER_DAY
956 if delay_days <= 0:
957 project_alert = 'Scheduled for deletion today.'
958 else:
959 days_word = 'day' if delay_days == 1 else 'days'
960 project_alert = (
961 'Scheduled for deletion in %d %s.' % (delay_days, days_word))
962 elif project.state == project_pb2.ProjectState.ARCHIVED:
963 project_alert = 'Project is archived: read-only by members only.'
964
965 return project_alert
966
967
968class _ContextDebugItem(object):
969 """Wrapper class to generate on-screen debugging output."""
970
971 def __init__(self, key, val):
972 """Store the key and generate a string for the value."""
973 self.key = key
974 if isinstance(val, list):
975 nested_debug_strs = [self.StringRep(v) for v in val]
976 self.val = '[%s]' % ', '.join(nested_debug_strs)
977 else:
978 self.val = self.StringRep(val)
979
980 def StringRep(self, val):
981 """Make a useful string representation of the given value."""
982 try:
983 return val.DebugString()
984 except Exception:
985 try:
986 return str(val.__dict__)
987 except Exception:
988 return repr(val)
989
990
991class _ContextDebugCollection(object):
992 """Attach a title to a dictionary for exporting as a table of debug info."""
993
994 def __init__(self, title, collection):
995 self.title = title
996 self.collection = [_ContextDebugItem(key, collection[key])
997 for key in sorted(collection.keys())]
998
999
1000def _ProjectIsRestricted(mr):
1001 """Return True if the mr has a 'private' project."""
1002 return (mr.project and
1003 mr.project.access != project_pb2.ProjectAccess.ANYONE)
1004
1005
1006def _SafeCreateLoginURL(mr, continue_url=None):
1007 """Make a login URL w/ a detailed continue URL, otherwise use a short one."""
1008 continue_url = continue_url or mr.current_page_url
1009 try:
1010 url = users.create_login_url(continue_url)
1011 except users.RedirectTooLongError:
1012 if mr.project_name:
1013 url = users.create_login_url('/p/%s' % mr.project_name)
1014 else:
1015 url = users.create_login_url('/')
1016
1017 # Give the user a choice of existing accounts in their session
1018 # or the option to add an account, even if they are currently
1019 # signed in to exactly one account.
1020 if mr.auth.user_id:
1021 # Notice: this makes assuptions about the output of users.create_login_url,
1022 # which can change at any time. See https://crbug.com/monorail/3352.
1023 url = url.replace('/ServiceLogin', '/AccountChooser', 1)
1024 return url
1025
1026
1027def _SafeCreateLogoutURL(mr):
1028 """Make a logout URL w/ a detailed continue URL, otherwise use a short one."""
1029 try:
1030 return users.create_logout_url(mr.current_page_url)
1031 except users.RedirectTooLongError:
1032 if mr.project_name:
1033 return users.create_logout_url('/p/%s' % mr.project_name)
1034 else:
1035 return users.create_logout_url('/')
1036
1037
1038def _VersionBaseURL(request):
1039 """Return a version-specific URL that we use to load static assets."""
1040 if settings.local_mode:
1041 version_base = '%s://%s' % (request.scheme, request.host)
1042 else:
1043 version_base = '%s://%s-dot-%s' % (
1044 request.scheme, modules.get_current_version_name(),
1045 app_identity.get_default_version_hostname())
1046
1047 return version_base