blob: 462939a4c6e7ed7bb00f1d42c5bb8aa9e3420ce5 [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'])
307 csp_supports_report_sample = (
308 (browser == 'Chrome' and browser_major_version >= 59) or
309 (browser == 'Opera' and browser_major_version >= 46))
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200310 version_base = servlet_helpers.VersionBaseURL(self.mr.request)
Copybara854996b2021-09-07 19:36:02 +0000311 self.response.headers.add(csp_header,
312 ("default-src %(scheme)s ; "
313 "script-src"
314 " %(rep_samp)s" # Report 40 chars of any inline violation.
315 " 'unsafe-inline'" # Only counts in browsers that lack CSP2.
316 " 'strict-dynamic'" # Allows <script nonce> to load more.
317 " %(version_base)s/static/dist/"
318 " 'self' 'nonce-%(nonce)s'; "
319 "child-src 'none'; "
320 "frame-src accounts.google.com" # All used by gapi.js auth.
321 " content-issuetracker.corp.googleapis.com"
322 " login.corp.google.com up.corp.googleapis.com"
323 # Used by Google Feedback
324 " feedback.googleusercontent.com"
325 " www.google.com; "
326 "img-src %(scheme)s data: blob: ; "
327 "style-src %(scheme)s 'unsafe-inline'; "
328 "object-src 'none'; "
329 "base-uri 'self'; " # Used by Google Feedback
330 "report-uri /csp.do" % {
331 'nonce': nonce,
332 'scheme': csp_scheme,
333 'rep_samp': "'report-sample'" if csp_supports_report_sample else '',
334 'version_base': version_base,
335 }))
336
337 page_data.update(self._GatherFlagData(self.mr))
338
339 # Page-specific work happens in this call.
340 page_data.update(self._DoPageProcessing(self.mr, nonce))
341
342 self._AddHelpDebugPageData(page_data)
343
344 with self.mr.profiler.Phase('rendering template'):
345 self._RenderResponse(page_data)
346
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200347 except (servlet_helpers.MethodNotSupportedError, NotImplementedError) as e:
Copybara854996b2021-09-07 19:36:02 +0000348 # Instead of these pages throwing 500s display the 404 message and log.
349 # The motivation of this is to minimize 500s on the site to keep alerts
350 # meaningful during fuzzing. For more context see
351 # https://bugs.chromium.org/p/monorail/issues/detail?id=659
352 logging.warning('Trapped NotImplementedError %s', e)
353 self.abort(404, 'invalid page')
354 except query2ast.InvalidQueryError as e:
355 logging.warning('Trapped InvalidQueryError: %s', e)
356 logging.exception(e)
357 msg = e.message if e.message else 'invalid query'
358 self.abort(400, msg)
359 except permissions.PermissionException as e:
360 logging.warning('Trapped PermissionException %s', e)
361 logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
362 logging.warning('mr.auth.effective_ids is %s', self.mr.auth.effective_ids)
363 logging.warning('mr.perms is %s', self.mr.perms)
364 if not self.mr.auth.user_id:
365 # If not logged in, let them log in
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200366 url = servlet_helpers.SafeCreateLoginURL(self.mr)
Copybara854996b2021-09-07 19:36:02 +0000367 self.redirect(url, abort=True)
368 else:
369 # Display the missing permissions template.
370 page_data = {
371 'reason': e.message,
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200372 'http_response_code': http_client.FORBIDDEN,
373 }
Copybara854996b2021-09-07 19:36:02 +0000374 with self.mr.profiler.Phase('gather base data'):
375 page_data.update(self.GatherBaseData(self.mr, nonce))
376 self._AddHelpDebugPageData(page_data)
377 self._missing_permissions_template.WriteResponse(
378 self.response, page_data, content_type=self.content_type)
379
Copybara854996b2021-09-07 19:36:02 +0000380 def GetTemplate(self, _page_data):
381 """Get the template to use for writing the http response.
382
383 Defaults to self.template. This method can be overwritten in subclasses
384 to allow dynamic template selection based on page_data.
385
386 Args:
387 _page_data: A dict of data for ezt rendering, containing base ezt
388 data, page data, and debug data.
389
390 Returns:
391 The template to be used for writing the http response.
392 """
393 return self.template
394
395 def _GatherFlagData(self, mr):
396 page_data = {
397 'project_stars_enabled': ezt.boolean(
398 settings.enable_project_stars),
399 'user_stars_enabled': ezt.boolean(settings.enable_user_stars),
400 'can_create_project': ezt.boolean(
401 permissions.CanCreateProject(mr.perms)),
402 'can_create_group': ezt.boolean(
403 permissions.CanCreateGroup(mr.perms)),
404 }
405
406 return page_data
407
408 def _RenderResponse(self, page_data):
409 logging.info('rendering response len(page_data) is %r', len(page_data))
410 self.GetTemplate(page_data).WriteResponse(
411 self.response, page_data, content_type=self.content_type)
412
413 def ProcessFormData(self, mr, post_data):
414 """Handle form data and redirect appropriately.
415
416 Args:
417 mr: commonly used info parsed from the request.
418 post_data: HTML form data from the request.
419
420 Returns:
421 String URL to redirect the user to, or None if response was already sent.
422 """
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200423 raise servlet_helpers.MethodNotSupportedError()
Copybara854996b2021-09-07 19:36:02 +0000424
425 def post(self, **kwargs):
426 """Parse the request, check base perms, and call form-specific code."""
427 try:
428 # Page-specific work happens in this call.
429 self._DoFormProcessing(self.request, self.mr)
430
431 except permissions.PermissionException as e:
432 logging.warning('Trapped permission-related exception "%s".', e)
433 # TODO(jrobbins): can we do better than an error page? not much.
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200434 self.response.status = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000435
436 def _DoCommonRequestProcessing(self, request, mr):
437 """Do common processing dependent on having the user and project pbs."""
438 with mr.profiler.Phase('basic processing'):
439 self._CheckForMovedProject(mr, request)
440 self.AssertBasePermission(mr)
441
442 def _DoPageProcessing(self, mr, nonce):
443 """Do user lookups and gather page-specific ezt data."""
444 with mr.profiler.Phase('common request data'):
445 self._DoCommonRequestProcessing(self.request, mr)
446 self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
447 page_data = self.GatherBaseData(mr, nonce)
448
449 with mr.profiler.Phase('page processing'):
450 page_data.update(self.GatherPageData(mr))
451 page_data.update(mr.form_overrides)
452 template_helpers.ExpandLabels(page_data)
453 self._RecordVisitTime(mr)
454
455 return page_data
456
457 def _DoFormProcessing(self, request, mr):
458 """Do user lookups and handle form data."""
459 self._DoCommonRequestProcessing(request, mr)
460
461 if self.CHECK_SECURITY_TOKEN:
462 try:
463 xsrf.ValidateToken(
464 request.POST.get('token'), mr.auth.user_id, request.path)
465 except xsrf.TokenIncorrect as err:
466 if self.ALLOW_XHR:
467 xsrf.ValidateToken(request.POST.get('token'), mr.auth.user_id, 'xhr')
468 else:
469 raise err
470
471 redirect_url = self.ProcessFormData(mr, request.POST)
472
473 # Most forms redirect the user to a new URL on success. If no
474 # redirect_url was returned, the form handler must have already
475 # sent a response. E.g., bounced the user back to the form with
476 # invalid form fields higlighted.
477 if redirect_url:
478 self.redirect(redirect_url, abort=True)
479 else:
480 assert self.response.body
481
482 def _CheckForMovedProject(self, mr, request):
483 """If the project moved, redirect there or to an informational page."""
484 if not mr.project:
485 return # We are on a site-wide or user page.
486 if not mr.project.moved_to:
487 return # This project has not moved.
488 admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
489 if request.path.startswith(admin_url):
490 return # It moved, but we are near the page that can un-move it.
491
492 logging.info('project %s has moved: %s', mr.project.project_name,
493 mr.project.moved_to)
494
495 moved_to = mr.project.moved_to
496 if project_constants.RE_PROJECT_NAME.match(moved_to):
497 # Use the redir query parameter to avoid redirect loops.
498 if mr.redir is None:
499 url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
500 if '?' in url:
501 url += '&redir=1'
502 else:
503 url += '?redir=1'
504 logging.info('trusted move to a new project on our site')
505 self.redirect(url, abort=True)
506
507 logging.info('not a trusted move, will display link to user to click')
508 # Attach the project name as a url param instead of generating a /p/
509 # link to the destination project.
510 url = framework_helpers.FormatAbsoluteURL(
511 mr, urls.PROJECT_MOVED,
512 include_project=False, copy_params=False, project=mr.project_name)
513 self.redirect(url, abort=True)
514
515 def _MaybeRedirectToBrandedDomain(self, request, project_name):
516 """If we are live and the project should be branded, check request host."""
517 if request.params.get('redir'):
518 return # Avoid any chance of a redirect loop.
519 if not project_name:
520 return
521 needed_domain = framework_helpers.GetNeededDomain(
522 project_name, request.host)
523 if not needed_domain:
524 return
525
526 url = 'https://%s%s' % (needed_domain, request.path_qs)
527 if '?' in url:
528 url += '&redir=1'
529 else:
530 url += '?redir=1'
531 logging.info('branding redirect to url %r', url)
532 self.redirect(url, abort=True)
533
534 def CheckPerm(self, mr, perm, art=None, granted_perms=None):
535 """Return True if the user can use the requested permission."""
536 return servlet_helpers.CheckPerm(
537 mr, perm, art=art, granted_perms=granted_perms)
538
539 def MakePagePerms(self, mr, art, *perm_list, **kwargs):
540 """Make an EZTItem with a set of permissions needed in a given template.
541
542 Args:
543 mr: commonly used info parsed from the request.
544 art: a project artifact, such as an issue.
545 *perm_list: any number of permission names that are referenced
546 in the EZT template.
547 **kwargs: dictionary that may include 'granted_perms' list of permissions
548 granted to the current user specifically on the current page.
549
550 Returns:
551 An EZTItem with one attribute for each permission and the value
552 of each attribute being an ezt.boolean(). True if the user
553 is permitted to do that action on the given artifact, or
554 False if not.
555 """
556 granted_perms = kwargs.get('granted_perms')
557 page_perms = template_helpers.EZTItem()
558 for perm in perm_list:
559 setattr(
560 page_perms, perm,
561 ezt.boolean(self.CheckPerm(
562 mr, perm, art=art, granted_perms=granted_perms)))
563
564 return page_perms
565
566 def AssertBasePermission(self, mr):
567 """Make sure that the logged in user has permission to view this page.
568
569 Subclasses should call super, then check additional permissions
570 and raise a PermissionException if the user is not authorized to
571 do something.
572
573 Args:
574 mr: commonly used info parsed from the request.
575
576 Raises:
577 PermissionException: If the user does not have permisssion to view
578 the current page.
579 """
580 servlet_helpers.AssertBasePermission(mr)
581
582 def GatherBaseData(self, mr, nonce):
583 """Return a dict of info used on almost all pages."""
584 project = mr.project
585
586 project_summary = ''
587 project_alert = None
588 project_read_only = False
589 project_home_page = ''
590 project_thumbnail_url = ''
591 if project:
592 project_summary = project.summary
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200593 project_alert = servlet_helpers.CalcProjectAlert(project)
Copybara854996b2021-09-07 19:36:02 +0000594 project_read_only = project.read_only_reason
595 project_home_page = project.home_page
596 project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
597
598 with work_env.WorkEnv(mr, self.services) as we:
599 is_project_starred = False
600 project_view = None
601 if mr.project:
602 if permissions.UserCanViewProject(
603 mr.auth.user_pb, mr.auth.effective_ids, mr.project):
604 is_project_starred = we.IsProjectStarred(mr.project_id)
605 # TODO(jrobbins): should this be a ProjectView?
606 project_view = template_helpers.PBProxy(mr.project)
607
608 grid_x_attr = None
609 grid_y_attr = None
610 hotlist_view = None
611 if mr.hotlist:
612 users_by_id = framework_views.MakeAllUserViews(
613 mr.cnxn, self.services.user,
614 features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
615 hotlist_view = hotlist_views.HotlistView(
616 mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
617 users_by_id, self.services.hotlist_star.IsItemStarredBy(
618 mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
619 grid_x_attr = mr.x.lower()
620 grid_y_attr = mr.y.lower()
621
622 app_version = os.environ.get('CURRENT_VERSION_ID')
623
624 viewed_username = None
625 if mr.viewed_user_auth.user_view:
626 viewed_username = mr.viewed_user_auth.user_view.username
627
628 config = None
629 if mr.project_id and self.services.config:
630 with mr.profiler.Phase('getting config'):
631 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
632 grid_x_attr = (mr.x or config.default_x_attr).lower()
633 grid_y_attr = (mr.y or config.default_y_attr).lower()
634
635 viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
636 offer_saved_queries_subtab = (
637 viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
638
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200639 login_url = servlet_helpers.SafeCreateLoginURL(mr)
640 logout_url = servlet_helpers.SafeCreateLogoutURL(mr)
Copybara854996b2021-09-07 19:36:02 +0000641 logout_url_goto_home = users.create_logout_url('/')
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200642 version_base = servlet_helpers.VersionBaseURL(mr.request)
Copybara854996b2021-09-07 19:36:02 +0000643
644 base_data = {
645 # EZT does not have constants for True and False, so we pass them in.
646 'True':
647 ezt.boolean(True),
648 'False':
649 ezt.boolean(False),
650 'local_mode':
651 ezt.boolean(settings.local_mode),
652 'site_name':
653 settings.site_name,
654 'show_search_metadata':
655 ezt.boolean(False),
656 'page_template':
657 self._PAGE_TEMPLATE,
658 'main_tab_mode':
659 self._MAIN_TAB_MODE,
660 'project_summary':
661 project_summary,
662 'project_home_page':
663 project_home_page,
664 'project_thumbnail_url':
665 project_thumbnail_url,
666 'hotlist_id':
667 mr.hotlist_id,
668 'hotlist':
669 hotlist_view,
670 'hostport':
671 mr.request.host,
672 'absolute_base_url':
673 '%s://%s' % (mr.request.scheme, mr.request.host),
674 'project_home_url':
675 None,
676 'link_rel_canonical':
677 None, # For specifying <link rel="canonical">
678 'projectname':
679 mr.project_name,
680 'project':
681 project_view,
682 'project_is_restricted':
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200683 ezt.boolean(servlet_helpers.ProjectIsRestricted(mr)),
Copybara854996b2021-09-07 19:36:02 +0000684 'offer_contributor_list':
685 ezt.boolean(permissions.CanViewContributorList(mr, mr.project)),
686 'logged_in_user':
687 mr.auth.user_view,
688 'form_token':
689 None, # Set to a value below iff the user is logged in.
690 'form_token_path':
691 None,
692 'token_expires_sec':
693 None,
694 'xhr_token':
695 None, # Set to a value below iff the user is logged in.
696 'flag_spam_token':
697 None,
698 'nonce':
699 nonce,
700 'perms':
701 mr.perms,
702 'warnings':
703 mr.warnings,
704 'errors':
705 mr.errors,
706 'viewed_username':
707 viewed_username,
708 'viewed_user':
709 mr.viewed_user_auth.user_view,
710 'viewed_user_pb':
711 template_helpers.PBProxy(mr.viewed_user_auth.user_pb),
712 'viewing_self':
713 ezt.boolean(viewing_self),
714 'viewed_user_id':
715 mr.viewed_user_auth.user_id,
716 'offer_saved_queries_subtab':
717 ezt.boolean(offer_saved_queries_subtab),
718 'currentPageURL':
719 mr.current_page_url,
720 'currentPageURLEncoded':
721 mr.current_page_url_encoded,
722 'login_url':
723 login_url,
724 'logout_url':
725 logout_url,
726 'logout_url_goto_home':
727 logout_url_goto_home,
728 'continue_issue_id':
729 mr.continue_issue_id,
730 'feedback_email':
731 settings.feedback_email,
732 'category_css':
733 None, # Used to specify a category of stylesheet
734 'category2_css':
735 None, # specify a 2nd category of stylesheet if needed.
736 'page_css':
737 None, # Used to add a stylesheet to a specific page.
738 'can':
739 mr.can,
740 'query':
741 mr.query,
742 'colspec':
743 None,
744 'sortspec':
745 mr.sort_spec,
746
747 # Options for issuelist display
748 'grid_x_attr':
749 grid_x_attr,
750 'grid_y_attr':
751 grid_y_attr,
752 'grid_cell_mode':
753 mr.cells,
754 'grid_mode':
755 None,
756 'list_mode':
757 None,
758 'chart_mode':
759 None,
760 'is_cross_project':
761 ezt.boolean(False),
762
763 # for project search (some also used in issue search)
764 'start':
765 mr.start,
766 'num':
767 mr.num,
768 'groupby':
769 mr.group_by_spec,
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200770 'q_field_size':
771 (
772 min(
773 framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
774 max(
775 framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
776 len(mr.query) + framework_constants.AUTOSIZE_STEP))),
Copybara854996b2021-09-07 19:36:02 +0000777 'mode':
778 None, # Display mode, e.g., grid mode.
779 'ajah':
780 mr.ajah,
781 'table_title':
782 mr.table_title,
783 'alerts':
784 alerts.AlertsView(mr), # For alert.ezt
785 'project_alert':
786 project_alert,
787 'title':
788 None, # First part of page title
789 'title_summary':
790 None, # Appended to title on artifact detail pages
791
792 # TODO(jrobbins): make sure that the templates use
793 # project_read_only for project-mutative actions and if any
794 # uses of read_only remain.
795 'project_read_only':
796 ezt.boolean(project_read_only),
797 'site_read_only':
798 ezt.boolean(settings.read_only),
799 'banner_time':
800 servlet_helpers.GetBannerTime(settings.banner_time),
801 'read_only':
802 ezt.boolean(settings.read_only or project_read_only),
803 'site_banner_message':
804 settings.banner_message,
805 'robots_no_index':
806 None,
807 'analytics_id':
808 settings.analytics_id,
809 'is_project_starred':
810 ezt.boolean(is_project_starred),
811 'version_base':
812 version_base,
813 'app_version':
814 app_version,
815 'gapi_client_id':
816 settings.gapi_client_id,
817 'viewing_user_page':
818 ezt.boolean(False),
819 'old_ui_url':
820 None,
821 'new_ui_url':
822 None,
823 'is_member':
824 ezt.boolean(False),
825 }
826
827 if mr.project:
828 base_data['project_home_url'] = '/p/%s' % mr.project_name
829
830 # Always add xhr-xsrf token because even anon users need some
831 # pRPC methods, e.g., autocomplete, flipper, and charts.
832 base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
833 base_data['xhr_token'] = xsrf.GenerateToken(
834 mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
835 # Always add other anti-xsrf tokens when the user is logged in.
836 if mr.auth.user_id:
837 form_token_path = self._FormHandlerURL(mr.request.path)
838 base_data['form_token'] = xsrf.GenerateToken(
839 mr.auth.user_id, form_token_path)
840 base_data['form_token_path'] = form_token_path
841
842 return base_data
843
844 def _FormHandlerURL(self, path):
845 """Return the form handler for the main form on a page."""
846 if path.endswith('/'):
847 return path + 'edit.do'
848 elif path.endswith('.do'):
849 return path # This happens as part of PleaseCorrect().
850 else:
851 return path + '.do'
852
853 def GatherPageData(self, mr):
854 """Return a dict of page-specific ezt data."""
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200855 raise servlet_helpers.MethodNotSupportedError()
Copybara854996b2021-09-07 19:36:02 +0000856
857 # pylint: disable=unused-argument
858 def GatherHelpData(self, mr, page_data):
859 """Return a dict of values to drive on-page user help.
860
861 Args:
862 mr: common information parsed from the HTTP request.
863 page_data: Dictionary of base and page template data.
864
865 Returns:
866 A dict of values to drive on-page user help, to be added to page_data.
867 """
868 help_data = {
869 'cue': None, # for cues.ezt
870 'account_cue': None, # for cues.ezt
871 }
872 dismissed = []
873 if mr.auth.user_pb:
874 with work_env.WorkEnv(mr, self.services) as we:
875 userprefs = we.GetUserPrefs(mr.auth.user_id)
876 dismissed = [
877 pv.name for pv in userprefs.prefs if pv.value == 'true']
878 if (mr.auth.user_pb.vacation_message and
879 'you_are_on_vacation' not in dismissed):
880 help_data['cue'] = 'you_are_on_vacation'
881 if (mr.auth.user_pb.email_bounce_timestamp and
882 'your_email_bounced' not in dismissed):
883 help_data['cue'] = 'your_email_bounced'
884 if mr.auth.user_pb.linked_parent_id:
885 # This one is not dismissable.
886 help_data['account_cue'] = 'switch_to_parent_account'
887 parent_email = self.services.user.LookupUserEmail(
888 mr.cnxn, mr.auth.user_pb.linked_parent_id)
889 help_data['parent_email'] = parent_email
890
891 return help_data
892
893 def GatherDebugData(self, mr, page_data):
894 """Return debugging info for display at the very bottom of the page."""
895 if mr.debug_enabled:
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200896 debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
Copybara854996b2021-09-07 19:36:02 +0000897 return {
898 'dbg': 'on',
899 'debug': debug,
900 'profiler': mr.profiler,
901 }
902 else:
903 if '?' in mr.current_page_url:
904 debug_url = mr.current_page_url + '&debug=1'
905 else:
906 debug_url = mr.current_page_url + '?debug=1'
907
908 return {
909 'debug_uri': debug_url,
910 'dbg': 'off',
911 'debug': [('none', 'recorded')],
912 }
913
914 def PleaseCorrect(self, mr, **echo_data):
915 """Show the same form again so that the user can correct their input."""
916 mr.PrepareForReentry(echo_data)
917 self.get()
918
919 def _RecordVisitTime(self, mr, now=None):
920 """Record the signed in user's last visit time, if possible."""
921 now = now or int(time.time())
922 if not settings.read_only and mr.auth.user_id:
923 user_pb = mr.auth.user_pb
924 if (user_pb.last_visit_timestamp <
925 now - framework_constants.VISIT_RESOLUTION):
926 user_pb.last_visit_timestamp = now
927 self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)