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