blob: 15530a90d2fbf331a88df6a1b86b0a8541015efe [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2022 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4"""Base classes for Monorail Flask servlets.
Copybara854996b2021-09-07 19:36:02 +00005
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01006This is derived from servlet.py
7This base class provides handler methods that conveniently drive
8the process of parsing the request, checking base permisssion,
9gathering common page information, gathering page-specific information,
10and adding on-page debugging information (when appropriate).
11Subclasses can simply implement the page-specific logic.
Copybara854996b2021-09-07 19:36:02 +000012
13Summary of page classes:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010014 Servlet: abstract base class for all Monorail flask servlets.
Copybara854996b2021-09-07 19:36:02 +000015"""
Copybara854996b2021-09-07 19:36:02 +000016
17import gc
Copybara854996b2021-09-07 19:36:02 +000018import os
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010019import logging
20from six.moves import http_client
Copybara854996b2021-09-07 19:36:02 +000021import time
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010022from businesslogic import work_env
Copybara854996b2021-09-07 19:36:02 +000023
24import ezt
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010025from features import features_bizobj, hotlist_views
26import flask
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +020027import httpagentparser
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010028from project import project_constants
29from mrproto import project_pb2
30from search import query2ast
31
32import settings
33from framework import alerts, exceptions, framework_helpers, urls
34from framework import framework_views, servlet_helpers
35from framework import framework_constants
36from framework import monorailrequest
37from framework import permissions
38from framework import ratelimiter
39from framework import template_helpers
40from framework import xsrf
Copybara854996b2021-09-07 19:36:02 +000041
42from google.appengine.api import app_identity
43from google.appengine.api import modules
44from google.appengine.api import users
Copybara854996b2021-09-07 19:36:02 +000045from tracker import tracker_views
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010046from werkzeug import datastructures
Copybara854996b2021-09-07 19:36:02 +000047
48NONCE_LENGTH = 32
49
50if not settings.unit_test_mode:
51 import MySQLdb
52
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010053class Servlet(object):
54 """Base class for all Monorail flask servlets.
Copybara854996b2021-09-07 19:36:02 +000055
56 Defines a framework of methods that build up parts of the EZT page data.
57
58 Subclasses should override GatherPageData and/or ProcessFormData to
59 handle requests.
60 """
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010061 _MAIN_TAB_MODE = None # Normally overridden in subclasses to be one of these:
Copybara854996b2021-09-07 19:36:02 +000062
63 MAIN_TAB_NONE = 't0'
64 MAIN_TAB_DASHBOARD = 't1'
65 MAIN_TAB_ISSUES = 't2'
66 MAIN_TAB_PEOPLE = 't3'
67 MAIN_TAB_PROCESS = 't4'
68 MAIN_TAB_UPDATES = 't5'
69 MAIN_TAB_ADMIN = 't6'
70 MAIN_TAB_DETAILS = 't7'
71 PROCESS_TAB_SUMMARY = 'st1'
72 PROCESS_TAB_STATUSES = 'st3'
73 PROCESS_TAB_LABELS = 'st4'
74 PROCESS_TAB_RULES = 'st5'
75 PROCESS_TAB_TEMPLATES = 'st6'
76 PROCESS_TAB_COMPONENTS = 'st7'
77 PROCESS_TAB_VIEWS = 'st8'
78 ADMIN_TAB_META = 'st1'
79 ADMIN_TAB_ADVANCED = 'st9'
80 HOTLIST_TAB_ISSUES = 'ht2'
81 HOTLIST_TAB_PEOPLE = 'ht3'
82 HOTLIST_TAB_DETAILS = 'ht4'
83
84 # Most forms require a security token, however if a form is really
85 # just redirecting to a search GET request without writing any data,
86 # subclass can override this to allow anonymous use.
87 CHECK_SECURITY_TOKEN = True
88
89 # Some pages might be posted to by clients outside of Monorail.
90 # ie: The issue entry page, by the issue filing wizard. In these cases,
91 # we can allow an xhr-scoped XSRF token to be used to post to the page.
92 ALLOW_XHR = False
93
Copybara854996b2021-09-07 19:36:02 +000094 # This value should not typically be overridden.
95 _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
96
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010097 _PAGE_TEMPLATE = None # Normally overridden in subclasses.
Copybara854996b2021-09-07 19:36:02 +000098 _ELIMINATE_BLANK_LINES = False
99
100 _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
101
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100102 def __init__(self, services=None, content_type='text/html; charset=UTF-8'):
Copybara854996b2021-09-07 19:36:02 +0000103 """Load and parse the template, saving it for later use."""
Copybara854996b2021-09-07 19:36:02 +0000104 if self._PAGE_TEMPLATE: # specified in subclasses
105 template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
106 self.template = template_helpers.GetTemplate(
107 template_path, eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
108 else:
109 self.template = None
110
111 self._missing_permissions_template = template_helpers.MonorailTemplate(
112 self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100113 self.services = services or flask.current_app.config['services']
Copybara854996b2021-09-07 19:36:02 +0000114 self.content_type = content_type
115 self.mr = None
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100116 # TODO: convert it to use self.request.path when we merge all flask together
117 self.request = flask.request
118 self.request_path = None
119 self.response = None
Copybara854996b2021-09-07 19:36:02 +0000120 self.ratelimiter = ratelimiter.RateLimiter()
121
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100122 # pylint: disable=unused-argument
123 def handler(self, **kwargs):
Copybara854996b2021-09-07 19:36:02 +0000124 """Do common stuff then dispatch the request to get() or put() methods."""
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100125 self.response = flask.make_response()
Copybara854996b2021-09-07 19:36:02 +0000126 handler_start_time = time.time()
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100127 logging.info('\n\n\n Flask Request handler: %r', self)
Copybara854996b2021-09-07 19:36:02 +0000128
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100129 #TODO: add the ts_mon.NonCumulativeDistributionMetric
130 # count0, count1, count2 = gc.get_count()
131 # logging.info('gc counts: %d %d %d', count0, count1, count2)
132 # GC_COUNT.add(count0, {'generation': 0})
133 # GC_COUNT.add(count1, {'generation': 1})
134 # GC_COUNT.add(count2, {'generation': 2})
Copybara854996b2021-09-07 19:36:02 +0000135
136 self.mr = monorailrequest.MonorailRequest(self.services)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100137 # TODO: convert it to use self.request.path when we merge all flask together
138 self.request_path = self.request.base_url[len(self.request.host_url) - 1:]
139 self.response.headers.add(
140 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
Copybara854996b2021-09-07 19:36:02 +0000141
142 if 'X-Cloud-Trace-Context' in self.request.headers:
143 self.mr.profiler.trace_context = (
144 self.request.headers.get('X-Cloud-Trace-Context'))
Copybara854996b2021-09-07 19:36:02 +0000145
146 if self.services.cache_manager:
Copybara854996b2021-09-07 19:36:02 +0000147 try:
148 with self.mr.profiler.Phase('distributed invalidation'):
149 self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
150
151 except MySQLdb.OperationalError as e:
152 logging.exception(e)
153 page_data = {
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200154 'http_response_code': http_client.SERVICE_UNAVAILABLE,
155 'requested_url': self.request.url,
Copybara854996b2021-09-07 19:36:02 +0000156 }
157 self.template = template_helpers.GetTemplate(
158 'templates/framework/database-maintenance.ezt',
159 eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
160 self.template.WriteResponse(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100161 self.response, page_data, content_type='text/html')
162 return self.response
Copybara854996b2021-09-07 19:36:02 +0000163
164 try:
165 self.ratelimiter.CheckStart(self.request)
166
167 with self.mr.profiler.Phase('parsing request and doing lookups'):
168 self.mr.ParseRequest(self.request, self.services)
169
170 self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
Copybara854996b2021-09-07 19:36:02 +0000171
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100172 if self.request.method == 'POST':
173 self.post()
174 elif self.request.method == 'GET':
175 self.get()
176
177 except exceptions.RedirectException as e:
178 return self.redirect(str(e))
Copybara854996b2021-09-07 19:36:02 +0000179 except exceptions.NoSuchUserException as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100180 logging.info('Trapped NoSuchUserException %s', e)
181 flask.abort(404, 'user not found')
Copybara854996b2021-09-07 19:36:02 +0000182
183 except exceptions.NoSuchGroupException as e:
184 logging.warning('Trapped NoSuchGroupException %s', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100185 flask.abort(404, 'user group not found')
Copybara854996b2021-09-07 19:36:02 +0000186
187 except exceptions.InputException as e:
188 logging.info('Rejecting invalid input: %r', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100189 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000190
191 except exceptions.NoSuchProjectException as e:
192 logging.info('Rejecting invalid request: %r', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100193 self.response.status_code = http_client.NOT_FOUND
Copybara854996b2021-09-07 19:36:02 +0000194
195 except xsrf.TokenIncorrect as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100196 logging.info('Bad XSRF token: %r', str(e))
197 self.response.status_code = http_client.BAD_REQUEST
Copybara854996b2021-09-07 19:36:02 +0000198
199 except permissions.BannedUserException as e:
200 logging.warning('The user has been banned')
201 url = framework_helpers.FormatAbsoluteURL(
202 self.mr, urls.BANNED, include_project=False, copy_params=False)
203 self.redirect(url, abort=True)
204
205 except ratelimiter.RateLimitExceeded as e:
206 logging.info('RateLimitExceeded Exception %s', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100207 self.response.status_code = http_client.BAD_REQUEST
208 self.response.set_data('Slow your roll.')
Copybara854996b2021-09-07 19:36:02 +0000209
210 finally:
211 self.mr.CleanUp()
212 self.ratelimiter.CheckEnd(self.request, time.time(), handler_start_time)
213
214 total_processing_time = time.time() - handler_start_time
215 logging.info(
216 'Processed request in %d ms', int(total_processing_time * 1000))
217
218 end_count0, end_count1, end_count2 = gc.get_count()
219 logging.info('gc counts: %d %d %d', end_count0, end_count1, end_count2)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100220 # TODO: get the GC event back
221 # if (end_count0 < count0) or (end_count1 < count1) or(end_count2 < count2):
222 # GC_EVENT_REQUEST.increment()
Copybara854996b2021-09-07 19:36:02 +0000223
224 if settings.enable_profiler_logging:
225 self.mr.profiler.LogStats()
226
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100227 return self.response
Copybara854996b2021-09-07 19:36:02 +0000228
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100229 def get(self):
230 """Collect page-specific and generic info, then render the page."""
Copybara854996b2021-09-07 19:36:02 +0000231 page_data = {}
232 nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
233 try:
234 csp_header = 'Content-Security-Policy'
235 csp_scheme = 'https:'
236 if settings.local_mode:
237 csp_header = 'Content-Security-Policy-Report-Only'
238 csp_scheme = 'http:'
239 user_agent_str = self.mr.request.headers.get('User-Agent', '')
240 ua = httpagentparser.detect(user_agent_str)
241 browser, browser_major_version = 'Unknown browser', 0
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100242 if 'browser' in ua:
Copybara854996b2021-09-07 19:36:02 +0000243 browser = ua['browser']['name']
244 try:
245 browser_major_version = int(ua['browser']['version'].split('.')[0])
246 except ValueError:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100247 logging.warning(
248 'Could not parse version: %r', ua['browser']['version'])
Adrià Vilanova Martínez9f9ade52022-10-10 23:20:11 +0200249 except KeyError:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100250 logging.warning('No browser version defined in user agent.')
Copybara854996b2021-09-07 19:36:02 +0000251 csp_supports_report_sample = (
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100252 (browser == 'Chrome' and browser_major_version >= 59) or
253 (browser == 'Opera' and browser_major_version >= 46))
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200254 version_base = servlet_helpers.VersionBaseURL(self.mr.request)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100255 self.response.headers.add(
256 csp_header,
257 (
258 "default-src %(scheme)s ; "
259 "script-src"
260 " %(rep_samp)s" # Report 40 chars of any inline violation.
261 " 'unsafe-inline'" # Only counts in browsers that lack CSP2.
262 " 'strict-dynamic'" # Allows <script nonce> to load more.
263 " %(version_base)s/static/dist/"
264 " 'self' 'nonce-%(nonce)s'; "
265 "child-src 'none'; "
266 "frame-src accounts.google.com" # All used by gapi.js auth.
267 " content-issuetracker.corp.googleapis.com"
268 " login.corp.google.com up.corp.googleapis.com"
269 # Used by Google Feedback
270 " feedback.googleusercontent.com"
271 " www.google.com; "
272 "img-src %(scheme)s data: blob: ; "
273 "style-src %(scheme)s 'unsafe-inline'; "
274 "object-src 'none'; "
275 "base-uri 'self'; " # Used by Google Feedback
276 "report-uri /csp.do" % {
277 'nonce':
278 nonce,
279 'scheme':
280 csp_scheme,
281 'rep_samp':
282 "'report-sample'" if csp_supports_report_sample else '',
283 'version_base':
284 version_base,
285 }))
Copybara854996b2021-09-07 19:36:02 +0000286
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100287 # add the function to get data and render page
Copybara854996b2021-09-07 19:36:02 +0000288 page_data.update(self._GatherFlagData(self.mr))
289
290 # Page-specific work happens in this call.
291 page_data.update(self._DoPageProcessing(self.mr, nonce))
292
293 self._AddHelpDebugPageData(page_data)
294
295 with self.mr.profiler.Phase('rendering template'):
296 self._RenderResponse(page_data)
297
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200298 except (servlet_helpers.MethodNotSupportedError, NotImplementedError) as e:
Copybara854996b2021-09-07 19:36:02 +0000299 # Instead of these pages throwing 500s display the 404 message and log.
300 # The motivation of this is to minimize 500s on the site to keep alerts
301 # meaningful during fuzzing. For more context see
302 # https://bugs.chromium.org/p/monorail/issues/detail?id=659
303 logging.warning('Trapped NotImplementedError %s', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100304 flask.abort(404, 'invalid page')
Copybara854996b2021-09-07 19:36:02 +0000305 except query2ast.InvalidQueryError as e:
306 logging.warning('Trapped InvalidQueryError: %s', e)
307 logging.exception(e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100308 msg = str(e) if str(e) else 'invalid query'
309 flask.abort(400, msg)
Copybara854996b2021-09-07 19:36:02 +0000310 except permissions.PermissionException as e:
311 logging.warning('Trapped PermissionException %s', e)
312 logging.warning('mr.auth.user_id is %s', self.mr.auth.user_id)
313 logging.warning('mr.auth.effective_ids is %s', self.mr.auth.effective_ids)
314 logging.warning('mr.perms is %s', self.mr.perms)
315 if not self.mr.auth.user_id:
316 # If not logged in, let them log in
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100317 login_url = servlet_helpers.SafeCreateLoginURL(self.mr)
318 raise exceptions.RedirectException(login_url)
Copybara854996b2021-09-07 19:36:02 +0000319 else:
320 # Display the missing permissions template.
321 page_data = {
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100322 'reason': str(e),
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200323 'http_response_code': http_client.FORBIDDEN,
324 }
Copybara854996b2021-09-07 19:36:02 +0000325 with self.mr.profiler.Phase('gather base data'):
326 page_data.update(self.GatherBaseData(self.mr, nonce))
327 self._AddHelpDebugPageData(page_data)
328 self._missing_permissions_template.WriteResponse(
329 self.response, page_data, content_type=self.content_type)
330
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100331 def post(self):
332 logging.info('process post request')
Copybara854996b2021-09-07 19:36:02 +0000333 try:
334 # Page-specific work happens in this call.
335 self._DoFormProcessing(self.request, self.mr)
336
337 except permissions.PermissionException as e:
338 logging.warning('Trapped permission-related exception "%s".', e)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100339 self.response.status_code = http_client.BAD_REQUEST
340
341 def _RenderResponse(self, page_data):
342 logging.info('rendering response len(page_data) is %r', len(page_data))
343 self.template.WriteResponse(
344 self.response, page_data, content_type=self.content_type)
345
346 def _GatherFlagData(self, mr):
347 page_data = {
348 'project_stars_enabled':
349 ezt.boolean(settings.enable_project_stars),
350 'user_stars_enabled':
351 ezt.boolean(settings.enable_user_stars),
352 'can_create_project':
353 ezt.boolean(permissions.CanCreateProject(mr.perms)),
354 'can_create_group':
355 ezt.boolean(permissions.CanCreateGroup(mr.perms)),
356 }
357
358 return page_data
Copybara854996b2021-09-07 19:36:02 +0000359
360 def _DoCommonRequestProcessing(self, request, mr):
361 """Do common processing dependent on having the user and project pbs."""
362 with mr.profiler.Phase('basic processing'):
363 self._CheckForMovedProject(mr, request)
364 self.AssertBasePermission(mr)
365
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100366 # pylint: disable=unused-argument
Copybara854996b2021-09-07 19:36:02 +0000367 def _DoPageProcessing(self, mr, nonce):
368 """Do user lookups and gather page-specific ezt data."""
369 with mr.profiler.Phase('common request data'):
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100370
Copybara854996b2021-09-07 19:36:02 +0000371 self._DoCommonRequestProcessing(self.request, mr)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100372
Copybara854996b2021-09-07 19:36:02 +0000373 self._MaybeRedirectToBrandedDomain(self.request, mr.project_name)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100374
Copybara854996b2021-09-07 19:36:02 +0000375 page_data = self.GatherBaseData(mr, nonce)
376
377 with mr.profiler.Phase('page processing'):
378 page_data.update(self.GatherPageData(mr))
379 page_data.update(mr.form_overrides)
380 template_helpers.ExpandLabels(page_data)
381 self._RecordVisitTime(mr)
382
383 return page_data
384
385 def _DoFormProcessing(self, request, mr):
386 """Do user lookups and handle form data."""
387 self._DoCommonRequestProcessing(request, mr)
388
389 if self.CHECK_SECURITY_TOKEN:
390 try:
391 xsrf.ValidateToken(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100392 request.values.get('token'), mr.auth.user_id, self.request_path)
Copybara854996b2021-09-07 19:36:02 +0000393 except xsrf.TokenIncorrect as err:
394 if self.ALLOW_XHR:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100395 xsrf.ValidateToken(
396 request.values.get('token'), mr.auth.user_id, 'xhr')
Copybara854996b2021-09-07 19:36:02 +0000397 else:
398 raise err
399
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100400 form_values = datastructures.MultiDict(request.values)
401 form_values.update(request.files)
402 redirect_url = self.ProcessFormData(mr, form_values)
Copybara854996b2021-09-07 19:36:02 +0000403
404 # Most forms redirect the user to a new URL on success. If no
405 # redirect_url was returned, the form handler must have already
406 # sent a response. E.g., bounced the user back to the form with
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100407 # invalid form fields highlighted.
Copybara854996b2021-09-07 19:36:02 +0000408 if redirect_url:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100409 raise exceptions.RedirectException(redirect_url)
Copybara854996b2021-09-07 19:36:02 +0000410 else:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100411 assert self.response.response
Copybara854996b2021-09-07 19:36:02 +0000412
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100413 def ProcessFormData(self, mr, post_data):
414 """Handle form data and redirect appropriately.
Copybara854996b2021-09-07 19:36:02 +0000415
416 Args:
417 mr: commonly used info parsed from the request.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100418 post_data: HTML form data from the request.
Copybara854996b2021-09-07 19:36:02 +0000419
420 Returns:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100421 String URL to redirect the user to, or None if response was already sent.
Copybara854996b2021-09-07 19:36:02 +0000422 """
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100423 raise servlet_helpers.MethodNotSupportedError()
Copybara854996b2021-09-07 19:36:02 +0000424
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100425 def _FormHandlerURL(self, path):
426 """Return the form handler for the main form on a page."""
427 if path.endswith('/'):
428 return path + 'edit.do'
429 elif path.endswith('.do'):
430 return path # This happens as part of PleaseCorrect().
431 else:
432 return path + '.do'
Copybara854996b2021-09-07 19:36:02 +0000433
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100434 # pylint: disable=unused-argument
435 def GatherPageData(self, mr):
436 """Return a dict of page-specific ezt data."""
437 raise servlet_helpers.MethodNotSupportedError()
Copybara854996b2021-09-07 19:36:02 +0000438
439 def GatherBaseData(self, mr, nonce):
440 """Return a dict of info used on almost all pages."""
441 project = mr.project
442
443 project_summary = ''
444 project_alert = None
445 project_read_only = False
446 project_home_page = ''
447 project_thumbnail_url = ''
448 if project:
449 project_summary = project.summary
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200450 project_alert = servlet_helpers.CalcProjectAlert(project)
Copybara854996b2021-09-07 19:36:02 +0000451 project_read_only = project.read_only_reason
452 project_home_page = project.home_page
453 project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
454
455 with work_env.WorkEnv(mr, self.services) as we:
456 is_project_starred = False
457 project_view = None
458 if mr.project:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100459 if permissions.UserCanViewProject(mr.auth.user_pb,
460 mr.auth.effective_ids, mr.project):
Copybara854996b2021-09-07 19:36:02 +0000461 is_project_starred = we.IsProjectStarred(mr.project_id)
Copybara854996b2021-09-07 19:36:02 +0000462 project_view = template_helpers.PBProxy(mr.project)
463
464 grid_x_attr = None
465 grid_y_attr = None
466 hotlist_view = None
467 if mr.hotlist:
468 users_by_id = framework_views.MakeAllUserViews(
469 mr.cnxn, self.services.user,
470 features_bizobj.UsersInvolvedInHotlists([mr.hotlist]))
471 hotlist_view = hotlist_views.HotlistView(
472 mr.hotlist, mr.perms, mr.auth, mr.viewed_user_auth.user_id,
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100473 users_by_id,
474 self.services.hotlist_star.IsItemStarredBy(
475 mr.cnxn, mr.hotlist.hotlist_id, mr.auth.user_id))
Copybara854996b2021-09-07 19:36:02 +0000476 grid_x_attr = mr.x.lower()
477 grid_y_attr = mr.y.lower()
478
479 app_version = os.environ.get('CURRENT_VERSION_ID')
480
481 viewed_username = None
482 if mr.viewed_user_auth.user_view:
483 viewed_username = mr.viewed_user_auth.user_view.username
484
485 config = None
486 if mr.project_id and self.services.config:
487 with mr.profiler.Phase('getting config'):
488 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
489 grid_x_attr = (mr.x or config.default_x_attr).lower()
490 grid_y_attr = (mr.y or config.default_y_attr).lower()
491
492 viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
493 offer_saved_queries_subtab = (
494 viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
495
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200496 login_url = servlet_helpers.SafeCreateLoginURL(mr)
497 logout_url = servlet_helpers.SafeCreateLogoutURL(mr)
Copybara854996b2021-09-07 19:36:02 +0000498 logout_url_goto_home = users.create_logout_url('/')
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200499 version_base = servlet_helpers.VersionBaseURL(mr.request)
Copybara854996b2021-09-07 19:36:02 +0000500
501 base_data = {
502 # EZT does not have constants for True and False, so we pass them in.
503 'True':
504 ezt.boolean(True),
505 'False':
506 ezt.boolean(False),
507 'local_mode':
508 ezt.boolean(settings.local_mode),
509 'site_name':
510 settings.site_name,
511 'show_search_metadata':
512 ezt.boolean(False),
513 'page_template':
514 self._PAGE_TEMPLATE,
515 'main_tab_mode':
516 self._MAIN_TAB_MODE,
517 'project_summary':
518 project_summary,
519 'project_home_page':
520 project_home_page,
521 'project_thumbnail_url':
522 project_thumbnail_url,
523 'hotlist_id':
524 mr.hotlist_id,
525 'hotlist':
526 hotlist_view,
527 'hostport':
528 mr.request.host,
529 'absolute_base_url':
530 '%s://%s' % (mr.request.scheme, mr.request.host),
531 'project_home_url':
532 None,
533 'link_rel_canonical':
534 None, # For specifying <link rel="canonical">
535 'projectname':
536 mr.project_name,
537 'project':
538 project_view,
539 'project_is_restricted':
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200540 ezt.boolean(servlet_helpers.ProjectIsRestricted(mr)),
Copybara854996b2021-09-07 19:36:02 +0000541 'offer_contributor_list':
542 ezt.boolean(permissions.CanViewContributorList(mr, mr.project)),
543 'logged_in_user':
544 mr.auth.user_view,
545 'form_token':
546 None, # Set to a value below iff the user is logged in.
547 'form_token_path':
548 None,
549 'token_expires_sec':
550 None,
551 'xhr_token':
552 None, # Set to a value below iff the user is logged in.
553 'flag_spam_token':
554 None,
555 'nonce':
556 nonce,
557 'perms':
558 mr.perms,
559 'warnings':
560 mr.warnings,
561 'errors':
562 mr.errors,
563 'viewed_username':
564 viewed_username,
565 'viewed_user':
566 mr.viewed_user_auth.user_view,
567 'viewed_user_pb':
568 template_helpers.PBProxy(mr.viewed_user_auth.user_pb),
569 'viewing_self':
570 ezt.boolean(viewing_self),
571 'viewed_user_id':
572 mr.viewed_user_auth.user_id,
573 'offer_saved_queries_subtab':
574 ezt.boolean(offer_saved_queries_subtab),
575 'currentPageURL':
576 mr.current_page_url,
577 'currentPageURLEncoded':
578 mr.current_page_url_encoded,
579 'login_url':
580 login_url,
581 'logout_url':
582 logout_url,
583 'logout_url_goto_home':
584 logout_url_goto_home,
585 'continue_issue_id':
586 mr.continue_issue_id,
587 'feedback_email':
588 settings.feedback_email,
589 'category_css':
590 None, # Used to specify a category of stylesheet
591 'category2_css':
592 None, # specify a 2nd category of stylesheet if needed.
593 'page_css':
594 None, # Used to add a stylesheet to a specific page.
595 'can':
596 mr.can,
597 'query':
598 mr.query,
599 'colspec':
600 None,
601 'sortspec':
602 mr.sort_spec,
603
604 # Options for issuelist display
605 'grid_x_attr':
606 grid_x_attr,
607 'grid_y_attr':
608 grid_y_attr,
609 'grid_cell_mode':
610 mr.cells,
611 'grid_mode':
612 None,
613 'list_mode':
614 None,
615 'chart_mode':
616 None,
617 'is_cross_project':
618 ezt.boolean(False),
619
620 # for project search (some also used in issue search)
621 'start':
622 mr.start,
623 'num':
624 mr.num,
625 'groupby':
626 mr.group_by_spec,
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200627 'q_field_size':
628 (
629 min(
630 framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
631 max(
632 framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
633 len(mr.query) + framework_constants.AUTOSIZE_STEP))),
Copybara854996b2021-09-07 19:36:02 +0000634 'mode':
635 None, # Display mode, e.g., grid mode.
636 'ajah':
637 mr.ajah,
638 'table_title':
639 mr.table_title,
640 'alerts':
641 alerts.AlertsView(mr), # For alert.ezt
642 'project_alert':
643 project_alert,
644 'title':
645 None, # First part of page title
646 'title_summary':
647 None, # Appended to title on artifact detail pages
Copybara854996b2021-09-07 19:36:02 +0000648 'project_read_only':
649 ezt.boolean(project_read_only),
650 'site_read_only':
651 ezt.boolean(settings.read_only),
652 'banner_time':
653 servlet_helpers.GetBannerTime(settings.banner_time),
654 'read_only':
655 ezt.boolean(settings.read_only or project_read_only),
656 'site_banner_message':
657 settings.banner_message,
658 'robots_no_index':
659 None,
660 'analytics_id':
661 settings.analytics_id,
662 'is_project_starred':
663 ezt.boolean(is_project_starred),
664 'version_base':
665 version_base,
666 'app_version':
667 app_version,
668 'gapi_client_id':
669 settings.gapi_client_id,
670 'viewing_user_page':
671 ezt.boolean(False),
672 'old_ui_url':
673 None,
674 'new_ui_url':
675 None,
676 'is_member':
677 ezt.boolean(False),
678 }
679
680 if mr.project:
681 base_data['project_home_url'] = '/p/%s' % mr.project_name
682
683 # Always add xhr-xsrf token because even anon users need some
684 # pRPC methods, e.g., autocomplete, flipper, and charts.
685 base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
686 base_data['xhr_token'] = xsrf.GenerateToken(
687 mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
688 # Always add other anti-xsrf tokens when the user is logged in.
689 if mr.auth.user_id:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100690 form_token_path = self._FormHandlerURL(mr.request_path)
Copybara854996b2021-09-07 19:36:02 +0000691 base_data['form_token'] = xsrf.GenerateToken(
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100692 mr.auth.user_id, form_token_path)
Copybara854996b2021-09-07 19:36:02 +0000693 base_data['form_token_path'] = form_token_path
694
695 return base_data
696
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100697 def _AddHelpDebugPageData(self, page_data):
698 with self.mr.profiler.Phase('help and debug data'):
699 page_data.update(self.GatherHelpData(self.mr, page_data))
700 page_data.update(self.GatherDebugData(self.mr, page_data))
Copybara854996b2021-09-07 19:36:02 +0000701
702 # pylint: disable=unused-argument
703 def GatherHelpData(self, mr, page_data):
704 """Return a dict of values to drive on-page user help.
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100705 Subclasses can override this function
Copybara854996b2021-09-07 19:36:02 +0000706 Args:
707 mr: common information parsed from the HTTP request.
708 page_data: Dictionary of base and page template data.
709
710 Returns:
711 A dict of values to drive on-page user help, to be added to page_data.
712 """
713 help_data = {
714 'cue': None, # for cues.ezt
715 'account_cue': None, # for cues.ezt
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100716 }
Copybara854996b2021-09-07 19:36:02 +0000717 dismissed = []
718 if mr.auth.user_pb:
719 with work_env.WorkEnv(mr, self.services) as we:
720 userprefs = we.GetUserPrefs(mr.auth.user_id)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100721 dismissed = [pv.name for pv in userprefs.prefs if pv.value == 'true']
Copybara854996b2021-09-07 19:36:02 +0000722 if (mr.auth.user_pb.vacation_message and
723 'you_are_on_vacation' not in dismissed):
724 help_data['cue'] = 'you_are_on_vacation'
725 if (mr.auth.user_pb.email_bounce_timestamp and
726 'your_email_bounced' not in dismissed):
727 help_data['cue'] = 'your_email_bounced'
728 if mr.auth.user_pb.linked_parent_id:
729 # This one is not dismissable.
730 help_data['account_cue'] = 'switch_to_parent_account'
731 parent_email = self.services.user.LookupUserEmail(
732 mr.cnxn, mr.auth.user_pb.linked_parent_id)
733 help_data['parent_email'] = parent_email
734
735 return help_data
736
737 def GatherDebugData(self, mr, page_data):
738 """Return debugging info for display at the very bottom of the page."""
739 if mr.debug_enabled:
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200740 debug = [servlet_helpers.ContextDebugCollection('Page data', page_data)]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100741 debug = [('none', 'recorded')]
Copybara854996b2021-09-07 19:36:02 +0000742 return {
743 'dbg': 'on',
744 'debug': debug,
745 'profiler': mr.profiler,
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100746 }
Copybara854996b2021-09-07 19:36:02 +0000747 else:
748 if '?' in mr.current_page_url:
749 debug_url = mr.current_page_url + '&debug=1'
750 else:
751 debug_url = mr.current_page_url + '?debug=1'
752
753 return {
754 'debug_uri': debug_url,
755 'dbg': 'off',
756 'debug': [('none', 'recorded')],
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100757 }
758
759 def _CheckForMovedProject(self, mr, request):
760 """If the project moved, redirect there or to an informational page."""
761 if not mr.project:
762 return # We are on a site-wide or user page.
763 if not mr.project.moved_to:
764 return # This project has not moved.
765 admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
766 if self.request_path.startswith(admin_url):
767 return # It moved, but we are near the page that can un-move it.
768
769 logging.info(
770 'project %s has moved: %s', mr.project.project_name,
771 mr.project.moved_to)
772
773 moved_to = mr.project.moved_to
774 if project_constants.RE_PROJECT_NAME.match(moved_to):
775 # Use the redir query parameter to avoid redirect loops.
776 if mr.redir is None:
777 url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
778 if '?' in url:
779 url += '&redir=1'
780 else:
781 url += '?redir=1'
782 logging.info('trusted move to a new project on our site')
783 self.redirect(url, abort=True)
784
785 logging.info('not a trusted move, will display link to user to click')
786 # Attach the project name as a url param instead of generating a /p/
787 # link to the destination project.
788 url = framework_helpers.FormatAbsoluteURL(
789 mr,
790 urls.PROJECT_MOVED,
791 include_project=False,
792 copy_params=False,
793 project=mr.project_name)
794 self.redirect(url, abort=True)
795
796 def _MaybeRedirectToBrandedDomain(self, request, project_name):
797 """If we are live and the project should be branded, check request host."""
798 if request.values.get('redir'):
799 return # Avoid any chance of a redirect loop.
800 if not project_name:
801 return
802 needed_domain = framework_helpers.GetNeededDomain(
803 project_name, request.host)
804 if not needed_domain:
805 return
806
807 url = 'https://%s%s' % (needed_domain, request.full_path)
808 if '?' in url:
809 url += '&redir=1'
810 else:
811 url += '?redir=1'
812 logging.info('branding redirect to url %r', url)
813 self.redirect(url, abort=True)
814
815 def AssertBasePermission(self, mr):
816 """Make sure that the logged in user has permission to view this page.
817
818 Subclasses should call super, then check additional permissions
819 and raise a PermissionException if the user is not authorized to
820 do something.
821
822 Args:
823 mr: commonly used info parsed from the request.
824
825 Raises:
826 PermissionException: If the user does not have permisssion to view
827 the current page.
828 """
829 servlet_helpers.AssertBasePermission(mr)
830
831 def CheckPerm(self, mr, perm, art=None, granted_perms=None):
832 """Return True if the user can use the requested permission."""
833 return servlet_helpers.CheckPerm(
834 mr, perm, art=art, granted_perms=granted_perms)
835
836 def MakePagePerms(self, mr, art, *perm_list, **kwargs):
837 """Make an EZTItem with a set of permissions needed in a given template.
838
839 Args:
840 mr: commonly used info parsed from the request.
841 art: a project artifact, such as an issue.
842 *perm_list: any number of permission names that are referenced
843 in the EZT template.
844 **kwargs: dictionary that may include 'granted_perms' list of permissions
845 granted to the current user specifically on the current page.
846
847 Returns:
848 An EZTItem with one attribute for each permission and the value
849 of each attribute being an ezt.boolean(). True if the user
850 is permitted to do that action on the given artifact, or
851 False if not.
852 """
853 granted_perms = kwargs.get('granted_perms')
854 page_perms = template_helpers.EZTItem()
855 for perm in perm_list:
856 setattr(
857 page_perms, perm,
858 ezt.boolean(
859 self.CheckPerm(mr, perm, art=art, granted_perms=granted_perms)))
860
861 return page_perms
862
863 def redirect(self, url, abort=False):
864 if abort:
865 return flask.redirect(url, code=302)
866 else:
867 return flask.redirect(url)
Copybara854996b2021-09-07 19:36:02 +0000868
869 def PleaseCorrect(self, mr, **echo_data):
870 """Show the same form again so that the user can correct their input."""
871 mr.PrepareForReentry(echo_data)
872 self.get()
873
874 def _RecordVisitTime(self, mr, now=None):
875 """Record the signed in user's last visit time, if possible."""
876 now = now or int(time.time())
877 if not settings.read_only and mr.auth.user_id:
878 user_pb = mr.auth.user_pb
879 if (user_pb.last_visit_timestamp <
880 now - framework_constants.VISIT_RESOLUTION):
881 user_pb.last_visit_timestamp = now
882 self.services.user.UpdateUser(mr.cnxn, user_pb.user_id, user_pb)
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100883
884 def abort(self, code, context=""):
885 return flask.abort(code, context)