Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(264)

Side by Side Diff: appengine/monorail/framework/servlet.py

Issue 1868553004: Open Source Monorail (Closed) Base URL: https://chromium.googlesource.com/infra/infra.git@master
Patch Set: Rebase Created 4 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2016 The Chromium Authors. All rights reserved.
2 # Use of this source code is govered 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
8 This base class provides HTTP get() and post() methods that
9 conveniently drive the process of parsing the request, checking base
10 permissions, gathering common page information, gathering
11 page-specific information, and adding on-page debugging information
12 (when appropriate). Subclasses can simply implement the page-specific
13 logic.
14
15 Summary of page classes:
16 Servlet: abstract base class for all Monorail servlets.
17 _ContextDebugItem: displays page_data elements for on-page debugging.
18 """
19
20 import httplib
21 import json
22 import logging
23 import os
24 import time
25 import urllib
26
27 from third_party import ezt
28
29 from google.appengine.api import users
30
31 import webapp2
32
33 import settings
34 from features import savedqueries_helpers
35 from framework import actionlimit
36 from framework import alerts
37 from framework import captcha
38 from framework import framework_bizobj
39 from framework import framework_constants
40 from framework import framework_helpers
41 from framework import monorailrequest
42 from framework import permissions
43 from framework import profiler
44 from framework import ratelimiter
45 from framework import servlet_helpers
46 from framework import template_helpers
47 from framework import urls
48 from framework import xsrf
49 from proto import project_pb2
50 from search import query2ast
51 from services import issue_svc
52 from services import project_svc
53 from services import secrets_svc
54 from services import user_svc
55 from tracker import tracker_views
56
57 NONCE_LENGTH = 32
58
59 if not settings.unit_test_mode:
60 import MySQLdb
61
62
63 class MethodNotSupportedError(NotImplementedError):
64 """An exception class for indicating that the method is not supported.
65
66 Used by GatherPageData and ProcessFormData to indicate that GET and POST,
67 respectively, are not supported methods on the given Servlet.
68 """
69 pass
70
71
72 class Servlet(webapp2.RequestHandler):
73 """Base class for all Monorail servlets.
74
75 Defines a framework of methods that build up parts of the EZT page data.
76
77 Subclasses should override GatherPageData and/or ProcessFormData to
78 handle requests.
79 """
80
81 _MAIN_TAB_MODE = None # Normally overriden in subclasses to be one of these:
82
83 MAIN_TAB_NONE = 't0'
84 MAIN_TAB_DASHBOARD = 't1'
85 MAIN_TAB_ISSUES = 't2'
86 MAIN_TAB_PEOPLE = 't3'
87 MAIN_TAB_PROCESS = 't4'
88 MAIN_TAB_UPDATES = 't5'
89 MAIN_TAB_ADMIN = 't6'
90 PROCESS_TAB_SUMMARY = 'st1'
91 PROCESS_TAB_STATUSES = 'st3'
92 PROCESS_TAB_LABELS = 'st4'
93 PROCESS_TAB_RULES = 'st5'
94 PROCESS_TAB_TEMPLATES = 'st6'
95 PROCESS_TAB_COMPONENTS = 'st7'
96 PROCESS_TAB_VIEWS = 'st8'
97 ADMIN_TAB_META = 'st1'
98 ADMIN_TAB_ADVANCED = 'st9'
99
100 # Most forms require a security token, however if a form is really
101 # just redirecting to a search GET request without writing any data,
102 # subclass can override this to allow anonymous use.
103 CHECK_SECURITY_TOKEN = True
104
105 # Most forms just ignore fields that have value "". Subclasses can override
106 # if needed.
107 KEEP_BLANK_FORM_VALUES = False
108
109 # Most forms use regular forms, but subclasses that accept attached files can
110 # override this to be True.
111 MULTIPART_POST_BODY = False
112
113 # This value should not typically be overridden.
114 _TEMPLATE_PATH = framework_constants.TEMPLATE_PATH
115
116 _PAGE_TEMPLATE = None # Normally overriden in subclasses.
117 _ELIMINATE_BLANK_LINES = False
118
119 _CAPTCHA_ACTION_TYPES = [] # Override this in subclass to add captcha.
120
121 _MISSING_PERMISSIONS_TEMPLATE = 'sitewide/403-page.ezt'
122
123 def __init__(self, request, response, services=None,
124 content_type='text/html; charset=UTF-8'):
125 """Load and parse the template, saving it for later use."""
126 super(Servlet, self).__init__(request, response)
127 if self._PAGE_TEMPLATE: # specified in subclasses
128 template_path = self._TEMPLATE_PATH + self._PAGE_TEMPLATE
129 self.template = template_helpers.GetTemplate(
130 template_path, eliminate_blank_lines=self._ELIMINATE_BLANK_LINES)
131 else:
132 self.template = None
133
134 self._missing_permissions_template = template_helpers.MonorailTemplate(
135 self._TEMPLATE_PATH + self._MISSING_PERMISSIONS_TEMPLATE)
136 self.services = services or self.app.config.get('services')
137 self.content_type = content_type
138 self.profiler = profiler.Profiler()
139 self.mr = None
140 self.ratelimiter = ratelimiter.RateLimiter()
141
142 def dispatch(self):
143 """Do common stuff then dispatch the request to get() or put() methods."""
144 handler_start_time = time.time()
145
146 logging.info('\n\n\nRequest handler: %r', self)
147
148 self.mr = monorailrequest.MonorailRequest()
149
150 self.ratelimiter.CheckStart(self.request)
151 self.response.headers.add('Strict-Transport-Security',
152 'max-age=31536000; includeSubDomains')
153
154 if self.services.cache_manager:
155 # TODO(jrobbins): don't do this step if invalidation_timestep was
156 # passed via the request and matches our last timestep
157 try:
158 with self.profiler.Phase('distributed invalidation'):
159 self.services.cache_manager.DoDistributedInvalidation(self.mr.cnxn)
160
161 except MySQLdb.OperationalError as e:
162 logging.exception(e)
163 self.redirect('/database-maintenance', abort=True)
164
165 try:
166 with self.profiler.Phase('parsing request and doing lookups'):
167 self.mr.ParseRequest(self.request, self.services, self.profiler)
168
169 self.response.headers['X-Frame-Options'] = 'SAMEORIGIN'
170 webapp2.RequestHandler.dispatch(self)
171
172 except user_svc.NoSuchUserException as e:
173 logging.warning('Trapped NoSuchUserException %s', e)
174 self.abort(404, 'user not found')
175
176 except monorailrequest.InputException as e:
177 logging.info('Rejecting invalid input: %r', e)
178 self.response.status = httplib.BAD_REQUEST
179
180 except project_svc.NoSuchProjectException as e:
181 logging.info('Rejecting invalid request: %r', e)
182 self.response.status = httplib.BAD_REQUEST
183
184 except xsrf.TokenIncorrect as e:
185 logging.info('Bad XSRF token: %r', e.message)
186 self.response.status = httplib.BAD_REQUEST
187
188 except AlreadySentResponseException:
189 # If servlet already sent response, then do nothing more. E.g.,
190 # when serving attachment content, we do not use templates.
191 pass
192
193 except permissions.BannedUserException as e:
194 logging.warning('The user has been banned')
195 url = framework_helpers.FormatAbsoluteURL(
196 self.mr, urls.BANNED, include_project=False, copy_params=False)
197 self.redirect(url, abort=True)
198
199 except actionlimit.ExcessiveActivityException:
200 logging.info('Excessive Activity Exception %r', self.mr.auth.user_id)
201 url = framework_helpers.FormatAbsoluteURL(
202 self.mr, urls.EXCESSIVE_ACTIVITY,
203 include_project=False, copy_params=False)
204 self.redirect(url, abort=True)
205
206 except ratelimiter.RateLimitExceeded as e:
207 logging.info('RateLimitExceeded Exception %s', e)
208 self.response.status = httplib.BAD_REQUEST
209 self.response.body = 'Slow your roll.'
210
211 finally:
212 self.mr.CleanUp()
213 self.ratelimiter.CheckEnd(self.request, time.time(), handler_start_time)
214
215 total_processing_time = time.time() - handler_start_time
216 logging.warn('Processed request in %d ms',
217 int(total_processing_time * 1000))
218 if settings.enable_profiler_logging:
219 self.profiler.LogStats()
220
221 def _AddHelpDebugPageData(self, page_data):
222 with self.profiler.Phase('help and debug data'):
223 page_data.update(self.GatherHelpData(self.mr, page_data))
224 page_data.update(self.GatherDebugData(self.mr, page_data))
225
226 # pylint: disable=unused-argument
227 def get(self, **kwargs):
228 """Collect page-specific and generic info, then render the page.
229
230 Args:
231 Any path components parsed by webapp2 will be in kwargs, but we do
232 our own parsing later anyway, so igore them for now.
233 """
234 page_data = {}
235 nonce = framework_helpers.MakeRandomKey(length=NONCE_LENGTH)
236 try:
237 csp_header = 'Content-Security-Policy'
238 csp_scheme = 'https:'
239 if settings.dev_mode:
240 csp_header = 'Content-Security-Policy-Report-Only'
241 csp_scheme = 'http:'
242 user_agent = self.mr.request.headers.get('User-Agent', '')
243 csp_supports_nonce = (
244 ('Chrome' in user_agent or 'Firefox' in user_agent) and
245 ('Edge' not in user_agent))
246 self.response.headers.add(csp_header,
247 ("default-src %(scheme)s ; "
248 "script-src"
249 " 'unsafe-inline'" # Only counts in browsers that lack CSP2.
250 " 'unsafe-dynamic'" # Allows <script nonce> to load more.
251 " https://www.gstatic.com/recaptcha/api2/"
252 " %(csp_self)s 'nonce-%(nonce)s'; "
253 "child-src https://www.google.com/recaptcha/; "
254 "frame-src https://www.google.com/recaptcha/; "
255 "img-src %(scheme)s data: blob: ; "
256 "style-src %(scheme)s 'unsafe-inline'; "
257 "object-src 'none'; "
258 "report-uri /csp.do" % {
259 'nonce': nonce,
260 'scheme': csp_scheme,
261 'csp_self': '' if csp_supports_nonce else "'self'",
262 }))
263
264 page_data.update(self._GatherFlagData(self.mr))
265
266 # Page-specific work happens in this call.
267 page_data.update(self._DoPageProcessing(self.mr, nonce))
268
269 self._AddHelpDebugPageData(page_data)
270
271 with self.profiler.Phase('rendering template'):
272 self._RenderResponse(page_data)
273
274 except (MethodNotSupportedError, NotImplementedError) as e:
275 # Instead of these pages throwing 500s display the 404 message and log.
276 # The motivation of this is to minimize 500s on the site to keep alerts
277 # meaningful during fuzzing. For more context see
278 # https://bugs.chromium.org/p/monorail/issues/detail?id=659
279 logging.warning('Trapped NotImplementedError %s', e)
280 self.abort(404, 'invalid page')
281 except query2ast.InvalidQueryError as e:
282 logging.warning('Trapped InvalidQueryError: %s', e)
283 logging.exception(e)
284 msg = e.message if e.message else 'invalid query'
285 self.abort(400, msg)
286 except permissions.PermissionException as e:
287 logging.warning('Trapped PermissionException %s', e)
288 if not self.mr.auth.user_id:
289 # If not logged in, let them log in
290 url = _SafeCreateLoginURL(self.mr)
291 self.redirect(url, abort=True)
292 else:
293 # Display the missing permissions template.
294 self.response.status = httplib.FORBIDDEN
295 page_data = {'reason': e.message}
296 with self.profiler.Phase('gather base data'):
297 page_data.update(self.GatherBaseData(self.mr, nonce))
298 self._AddHelpDebugPageData(page_data)
299 self._missing_permissions_template.WriteResponse(
300 self.response, page_data, content_type=self.content_type)
301
302 def SetCacheHeaders(self, response):
303 """Set headers to allow the response to be cached."""
304 headers = framework_helpers.StaticCacheHeaders()
305 for name, value in headers:
306 response.headers[name] = value
307
308 def GetTemplate(self, _page_data):
309 """Get the template to use for writing the http response.
310
311 Defaults to self.template. This method can be overwritten in subclasses
312 to allow dynamic template selection based on page_data.
313
314 Args:
315 _page_data: A dict of data for ezt rendering, containing base ezt
316 data, captcha data, page data, and debug data.
317
318 Returns:
319 The template to be used for writing the http response.
320 """
321 return self.template
322
323 def _GatherFlagData(self, mr):
324 page_data = {
325 'recaptcha_public_key': secrets_svc.GetRecaptchaPublicKey(),
326 'project_stars_enabled': ezt.boolean(
327 settings.enable_project_stars),
328 'user_stars_enabled': ezt.boolean(settings.enable_user_stars),
329 'can_create_project': ezt.boolean(
330 permissions.CanCreateProject(mr.perms)),
331 'can_create_group': ezt.boolean(
332 permissions.CanCreateGroup(mr.perms)),
333 }
334
335 return page_data
336
337 def _RenderResponse(self, page_data):
338 logging.info('rendering response len(page_data) is %r', len(page_data))
339 self.GetTemplate(page_data).WriteResponse(
340 self.response, page_data, content_type=self.content_type)
341
342 def ProcessFormData(self, mr, post_data):
343 """Handle form data and redirect appropriately.
344
345 Args:
346 mr: commonly used info parsed from the request.
347 post_data: HTML form data from the request.
348
349 Returns:
350 String URL to redirect the user to, or None if response was already sent.
351 """
352 raise MethodNotSupportedError()
353
354 def post(self, **kwargs):
355 """Parse the request, check base perms, and call form-specific code."""
356 try:
357 # Page-specific work happens in this call.
358 self._DoFormProcessing(self.request, self.mr)
359
360 except permissions.PermissionException as e:
361 logging.warning('Trapped permission-related exception "%s".', e)
362 # TODO(jrobbins): can we do better than an error page? not much.
363 self.response.status = httplib.BAD_REQUEST
364
365 except issue_svc.MidAirCollisionException as e:
366 logging.info('Mid-air collision detected.')
367 collision_page_url = urls.ARTIFACT_COLLISION
368 url = framework_helpers.FormatAbsoluteURL(
369 self.mr, collision_page_url, copy_params=False,
370 name=e.name, continue_issue_id=e.continue_issue_id,
371 ts=int(time.time()))
372 self.redirect(url, abort=True)
373
374 def _DoCommonRequestProcessing(self, request, mr):
375 """Do common processing dependent on having the user and project pbs."""
376 with self.profiler.Phase('basic processing'):
377 self._CheckForMovedProject(mr, request)
378 self.AssertBasePermission(mr)
379
380 def _DoPageProcessing(self, mr, nonce):
381 """Do user lookups and gather page-specific ezt data."""
382 with self.profiler.Phase('common request data'):
383 self._DoCommonRequestProcessing(self.request, mr)
384 page_data = self.GatherBaseData(mr, nonce)
385 page_data.update(self.GatherCaptchaData(mr))
386
387 with self.profiler.Phase('page processing'):
388 page_data.update(self.GatherPageData(mr))
389 page_data.update(mr.form_overrides)
390 template_helpers.ExpandLabels(page_data)
391
392 return page_data
393
394 def _DoFormProcessing(self, request, mr):
395 """Do user lookups and handle form data."""
396 self._DoCommonRequestProcessing(request, mr)
397
398 if self.CHECK_SECURITY_TOKEN:
399 xsrf.ValidateToken(
400 request.POST.get('token'), mr.auth.user_id, request.path)
401
402 redirect_url = self.ProcessFormData(mr, request.POST)
403
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
407 # invalid form fields higlighted.
408 if redirect_url:
409 self.redirect(redirect_url, abort=True)
410 else:
411 assert self.response.body
412
413 def _CheckForMovedProject(self, mr, request):
414 """If the project moved, redirect there or to an informational page."""
415 if not mr.project:
416 return # We are on a site-wide or user page.
417 if not mr.project.moved_to:
418 return # This project has not moved.
419 admin_url = '/p/%s%s' % (mr.project_name, urls.ADMIN_META)
420 if request.path.startswith(admin_url):
421 return # It moved, but we are near the page that can un-move it.
422
423 logging.info('project %s has moved: %s', mr.project.project_name,
424 mr.project.moved_to)
425
426 moved_to = mr.project.moved_to
427 if framework_bizobj.RE_PROJECT_NAME.match(moved_to):
428 # Use the redir query parameter to avoid redirect loops.
429 if mr.redir is None:
430 url = framework_helpers.FormatMovedProjectURL(mr, moved_to)
431 if '?' in url:
432 url += '&redir=1'
433 else:
434 url += '?redir=1'
435 logging.info('trusted move to a new project on our site')
436 self.redirect(url, abort=True)
437
438 logging.info('not a trusted move, will display link to user to click')
439 # Attach the project name as a url param instead of generating a /p/
440 # link to the destination project.
441 url = framework_helpers.FormatAbsoluteURL(
442 mr, urls.PROJECT_MOVED,
443 include_project=False, copy_params=False, project=mr.project_name)
444 self.redirect(url, abort=True)
445
446 def CheckPerm(self, mr, perm, art=None, granted_perms=None):
447 """Return True if the user can use the requested permission."""
448 return servlet_helpers.CheckPerm(
449 mr, perm, art=art, granted_perms=granted_perms)
450
451 def MakePagePerms(self, mr, art, *perm_list, **kwargs):
452 """Make an EZTItem with a set of permissions needed in a given template.
453
454 Args:
455 mr: commonly used info parsed from the request.
456 art: a project artifact, such as an issue.
457 *perm_list: any number of permission names that are referenced
458 in the EZT template.
459 **kwargs: dictionary that may include 'granted_perms' list of permissions
460 granted to the current user specifically on the current page.
461
462 Returns:
463 An EZTItem with one attribute for each permission and the value
464 of each attribute being an ezt.boolean(). True if the user
465 is permitted to do that action on the given artifact, or
466 False if not.
467 """
468 granted_perms = kwargs.get('granted_perms')
469 page_perms = template_helpers.EZTItem()
470 for perm in perm_list:
471 setattr(
472 page_perms, perm,
473 ezt.boolean(self.CheckPerm(
474 mr, perm, art=art, granted_perms=granted_perms)))
475
476 return page_perms
477
478 def AssertBasePermission(self, mr):
479 """Make sure that the logged in user has permission to view this page.
480
481 Subclasses should call super, then check additional permissions
482 and raise a PermissionException if the user is not authorized to
483 do something.
484
485 Args:
486 mr: commonly used info parsed from the request.
487
488 Raises:
489 PermissionException: If the user does not have permisssion to view
490 the current page.
491 """
492 servlet_helpers.AssertBasePermission(mr)
493
494 def GatherBaseData(self, mr, nonce):
495 """Return a dict of info used on almost all pages."""
496 project = mr.project
497
498 project_summary = ''
499 project_alert = None
500 project_read_only = False
501 project_home_page = ''
502 project_thumbnail_url = ''
503 if project:
504 project_summary = project.summary
505 project_alert = _CalcProjectAlert(project)
506 project_read_only = project.read_only_reason
507 project_home_page = project.home_page
508 project_thumbnail_url = tracker_views.LogoView(project).thumbnail_url
509
510 # If we have both a project and a logged in user, we need to check if the
511 # user has starred that project.
512 with self.profiler.Phase('project star'):
513 is_project_starred = False
514 if mr.project and mr.auth.user_id:
515 is_project_starred = self.services.project_star.IsItemStarredBy(
516 mr.cnxn, mr.project_id, mr.auth.user_id)
517
518 project_view = None
519 if mr.project:
520 # TODO(jrobbins): should this be a ProjectView?
521 project_view = template_helpers.PBProxy(mr.project)
522
523 app_version = os.environ.get('CURRENT_VERSION_ID')
524
525 viewed_username = None
526 if mr.viewed_user_auth.user_view:
527 viewed_username = mr.viewed_user_auth.user_view.username
528
529 grid_x_attr = None
530 grid_y_attr = None
531 canned_query_views = []
532 issue_entry_url = 'entry'
533 if mr.project_id and self.services.config:
534 with self.profiler.Phase('getting config'):
535 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
536 canned_queries = self.services.features.GetCannedQueriesByProjectID(
537 mr.cnxn, mr.project_id)
538 grid_x_attr = (mr.x or config.default_x_attr).lower()
539 grid_y_attr = (mr.y or config.default_y_attr).lower()
540 canned_query_views = [
541 savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
542 for idx, sq in enumerate(canned_queries)]
543 issue_entry_url = _ComputeIssueEntryURL(mr, config)
544
545 if mr.auth.user_id and self.services.features:
546 with self.profiler.Phase('getting saved queries'):
547 saved_queries = self.services.features.GetSavedQueriesByUserID(
548 mr.cnxn, mr.me_user_id)
549 saved_query_views = [
550 savedqueries_helpers.SavedQueryView(sq, idx + 1, None, None)
551 for idx, sq in enumerate(saved_queries)
552 if (mr.project_id in sq.executes_in_project_ids or
553 not mr.project_id)]
554 else:
555 saved_query_views = []
556
557 viewing_self = mr.auth.user_id == mr.viewed_user_auth.user_id
558 offer_saved_queries_subtab = (
559 viewing_self or mr.auth.user_pb and mr.auth.user_pb.is_site_admin)
560
561 login_url = _SafeCreateLoginURL(mr)
562 logout_url = _SafeCreateLogoutURL(mr)
563 logout_url_goto_home = users.create_logout_url('/')
564
565 base_data = {
566 # EZT does not have constants for True and False, so we pass them in.
567 'True': ezt.boolean(True),
568 'False': ezt.boolean(False),
569
570 'site_name': settings.site_name,
571 'show_search_metadata': ezt.boolean(False),
572 'page_template': self._PAGE_TEMPLATE,
573 'main_tab_mode': self._MAIN_TAB_MODE,
574 'project_summary': project_summary,
575 'project_home_page': project_home_page,
576 'project_thumbnail_url': project_thumbnail_url,
577
578 'hostport': mr.request.host,
579 'absolute_base_url': '%s://%s' % (mr.request.scheme, mr.request.host),
580 'project_home_url': None,
581 'link_rel_canonical': None, # For specifying <link rel="canonical">
582 'projectname': mr.project_name,
583 'project': project_view,
584 'project_is_restricted': ezt.boolean(_ProjectIsRestricted(mr)),
585 'offer_contributor_list': ezt.boolean(
586 permissions.CanViewContributorList(mr)),
587 'logged_in_user': mr.auth.user_view,
588 'form_token': None, # Set to a value below iff the user is logged in.
589 'form_token_path': None,
590 'token_expires_sec': None,
591 'xhr_token': None, # Set to a value below iff the user is logged in.
592 'flag_spam_token': None,
593 'nonce': nonce,
594 'perms': mr.perms,
595 'warnings': mr.warnings,
596 'errors': mr.errors,
597
598 'viewed_username': viewed_username,
599 'viewed_user': mr.viewed_user_auth.user_view,
600 'viewed_user_pb': template_helpers.PBProxy(
601 mr.viewed_user_auth.user_pb),
602 'viewing_self': ezt.boolean(viewing_self),
603 'viewed_user_id': mr.viewed_user_auth.user_id,
604 'offer_saved_queries_subtab': ezt.boolean(offer_saved_queries_subtab),
605
606 'currentPageURL': mr.current_page_url,
607 'currentPageURLEncoded': mr.current_page_url_encoded,
608 'login_url': login_url,
609 'logout_url': logout_url,
610 'logout_url_goto_home': logout_url_goto_home,
611 'continue_issue_id': mr.continue_issue_id,
612 'feedback_email': settings.feedback_email,
613 'category_css': None, # Used to specify a category of stylesheet
614 'page_css': None, # Used to add a stylesheet to a specific page.
615
616 'can': mr.can,
617 'query': mr.query,
618 'colspec': None,
619 'sortspec': mr.sort_spec,
620
621 'grid_x_attr': grid_x_attr,
622 'grid_y_attr': grid_y_attr,
623 'grid_cell_mode': mr.cells,
624 'grid_mode': None,
625
626 'issue_entry_url': issue_entry_url,
627 'canned_queries': canned_query_views,
628 'saved_queries': saved_query_views,
629 'is_cross_project': ezt.boolean(False),
630
631 # for project search (some also used in issue search)
632 'start': mr.start,
633 'num': mr.num,
634 'groupby': mr.group_by_spec,
635 'q_field_size': (
636 min(framework_constants.MAX_ARTIFACT_SEARCH_FIELD_SIZE,
637 max(framework_constants.MIN_ARTIFACT_SEARCH_FIELD_SIZE,
638 len(mr.query) + framework_constants.AUTOSIZE_STEP))),
639 'mode': None, # Display mode, e.g., grid mode.
640 'ajah': mr.ajah,
641 'table_title': mr.table_title,
642
643 'alerts': alerts.AlertsView(mr), # For alert.ezt
644 'project_alert': project_alert,
645
646 'title': None, # First part of page title
647 'title_summary': None, # Appended to title on artifact detail pages
648
649 # TODO(jrobbins): make sure that the templates use
650 # project_read_only for project-mutative actions and if any
651 # uses of read_only remain.
652 'project_read_only': ezt.boolean(project_read_only),
653 'site_read_only': ezt.boolean(settings.read_only),
654 'banner_time': servlet_helpers.GetBannerTime(settings.banner_time),
655 'read_only': ezt.boolean(settings.read_only or project_read_only),
656 'site_banner_message': settings.banner_message,
657 'robots_no_index': None,
658 'analytics_id': settings.analytics_id,
659
660 'is_project_starred': ezt.boolean(is_project_starred),
661
662 'app_version': app_version,
663 'viewing_user_page': ezt.boolean(False),
664 }
665
666 if mr.project:
667 base_data['project_home_url'] = '/p/%s' % mr.project_name
668
669 # Always add an anti-xsrf token when the user is logged in.
670 if mr.auth.user_id:
671 form_token_path = self._FormHandlerURL(mr.request.path)
672 base_data['form_token'] = xsrf.GenerateToken(
673 mr.auth.user_id, form_token_path)
674 base_data['form_token_path'] = form_token_path
675 base_data['token_expires_sec'] = xsrf.TokenExpiresSec()
676 base_data['xhr_token'] = xsrf.GenerateToken(
677 mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
678 base_data['flag_spam_token'] = xsrf.GenerateToken(
679 mr.auth.user_id, '/p/%s%s.do' % (
680 mr.project_name, urls.ISSUE_FLAGSPAM_JSON))
681
682 return base_data
683
684 def _FormHandlerURL(self, path):
685 """Return the form handler for the main form on a page."""
686 if path.endswith('/'):
687 return path + 'edit.do'
688 elif path.endswith('.do'):
689 return path # This happens as part of PleaseCorrect().
690 else:
691 return path + '.do'
692
693 def GatherCaptchaData(self, mr):
694 """If this page needs a captcha, return captcha info for use in EZT."""
695 if (mr.project and
696 framework_bizobj.UserIsInProject(mr.project, mr.auth.effective_ids)):
697 # Don't show users CAPTCHAs within their own projects.
698 return {'show_captcha': ezt.boolean(False)}
699
700 show_captcha = any(actionlimit.NeedCaptcha(mr.auth.user_pb, action_type)
701 for action_type in self._CAPTCHA_ACTION_TYPES)
702 return {'show_captcha': ezt.boolean(show_captcha)}
703
704 def GatherPageData(self, mr):
705 """Return a dict of page-specific ezt data."""
706 raise MethodNotSupportedError()
707
708 # pylint: disable=unused-argument
709 def GatherHelpData(self, mr, page_data):
710 """Return a dict of values to drive on-page user help.
711
712 Args:
713 mr: common information parsed from the HTTP request.
714 page_data: Dictionary of base and page template data.
715
716 Returns:
717 A dict of values to drive on-page user help, to be added to page_data.
718 """
719 return {
720 'cue': None, # for cues.ezt
721 }
722
723 def GatherDebugData(self, mr, page_data):
724 """Return debugging info for display at the very bottom of the page."""
725 if mr.debug_enabled:
726 debug = [_ContextDebugCollection('Page data', page_data)]
727 return {
728 'dbg': 'on',
729 'debug': debug,
730 'profiler': self.profiler,
731 }
732 else:
733 if '?' in mr.current_page_url:
734 debug_url = mr.current_page_url + '&debug=1'
735 else:
736 debug_url = mr.current_page_url + '?debug=1'
737
738 return {
739 'debug_uri': debug_url,
740 'dbg': 'off',
741 'debug': [('none', 'recorded')],
742 }
743
744 def CheckCaptcha(self, mr, post_data):
745 """Check the provided CAPTCHA solution and add an error if it is wrong."""
746 if (mr.project and
747 framework_bizobj.UserIsInProject(mr.project, mr.auth.effective_ids)):
748 return # Don't check a user's actions within their own projects.
749
750 if not any(actionlimit.NeedCaptcha(mr.auth.user_pb, action_type)
751 for action_type in self._CAPTCHA_ACTION_TYPES):
752 return # no captcha was needed.
753
754 remote_ip = mr.request.remote_addr
755 captcha_response = post_data.get('g-recaptcha-response')
756 correct, _msg = captcha.Verify(remote_ip, captcha_response)
757 if not correct:
758 logging.info('BZzzz! Bad captcha solution.')
759 mr.errors.captcha = 'Captcha check failed.'
760
761 def CountRateLimitedActions(self, mr, action_counts):
762 """Count attempted actions against non-member's action limits.
763
764 Note that users can take any number of actions in their own projects.
765
766 Args:
767 mr: commonly used info parsed from the request.
768 action_counts: {action_type: delta, ... }
769 a dictionary mapping action type constants to the number of times
770 that action was performed during the current request (usually 1).
771 """
772 if (mr.project and
773 framework_bizobj.UserIsInProject(mr.project, mr.auth.effective_ids)):
774 # Don't count a user's actions within their own projects...
775 return
776
777 for action_type in action_counts:
778 actionlimit.CountAction(
779 mr.auth.user_pb, action_type, delta=action_counts[action_type])
780
781 self.services.user.UpdateUser(mr.cnxn, mr.auth.user_id, mr.auth.user_pb)
782
783 def PleaseCorrect(self, mr, **echo_data):
784 """Show the same form again so that the user can correct their input."""
785 mr.PrepareForReentry(echo_data)
786 self.get()
787
788
789 def _CalcProjectAlert(project):
790 """Return a string to be shown as red text explaning the project state."""
791
792 project_alert = None
793
794 if project.read_only_reason:
795 project_alert = 'READ-ONLY: %s.' % project.read_only_reason
796 if project.moved_to:
797 project_alert = 'This project has moved to: %s.' % project.moved_to
798 elif project.delete_time:
799 delay_seconds = project.delete_time - time.time()
800 delay_days = delay_seconds // framework_constants.SECS_PER_DAY
801 if delay_days <= 0:
802 project_alert = 'Scheduled for deletion today.'
803 else:
804 days_word = 'day' if delay_days == 1 else 'days'
805 project_alert = (
806 'Scheduled for deletion in %d %s.' % (delay_days, days_word))
807 elif project.state == project_pb2.ProjectState.ARCHIVED:
808 project_alert = 'Project is archived: read-only by members only.'
809
810 return project_alert
811
812
813 class _ContextDebugItem(object):
814 """Wrapper class to generate on-screen debugging output."""
815
816 def __init__(self, key, val):
817 """Store the key and generate a string for the value."""
818 self.key = key
819 if isinstance(val, list):
820 nested_debug_strs = [self.StringRep(v) for v in val]
821 self.val = '[%s]' % ', '.join(nested_debug_strs)
822 else:
823 self.val = self.StringRep(val)
824
825 def StringRep(self, val):
826 """Make a useful string representation of the given value."""
827 try:
828 return val.DebugString()
829 except Exception:
830 try:
831 return str(val.__dict__)
832 except Exception:
833 return repr(val)
834
835
836 class _ContextDebugCollection(object):
837 """Attach a title to a dictionary for exporting as a table of debug info."""
838
839 def __init__(self, title, collection):
840 self.title = title
841 self.collection = [_ContextDebugItem(key, collection[key])
842 for key in sorted(collection.iterkeys())]
843
844
845 def _ProjectIsRestricted(mr):
846 """Return True if the mr has a 'private' project."""
847 return (mr.project and
848 mr.project.access != project_pb2.ProjectAccess.ANYONE)
849
850
851 def _ComputeIssueEntryURL(mr, config):
852 """Compute the URL to use for the "New issue" subtab.
853
854 Args:
855 mr: commonly used info parsed from the request.
856 config: ProjectIssueConfig for the current project.
857
858 Returns:
859 A URL string to use. It will be simply "entry" in the non-customized
860 case. Otherewise it will be a fully qualified URL that includes some
861 query string parameters.
862 """
863 if not config.custom_issue_entry_url:
864 return 'entry'
865
866 base_url = config.custom_issue_entry_url
867 sep = '&' if '?' in base_url else '?'
868 token = xsrf.GenerateToken(
869 mr.auth.user_id, '/p/%s%s%s' % (mr.project_name, urls.ISSUE_ENTRY, '.do'))
870 role_name = framework_helpers.GetRoleName(mr.auth.effective_ids, mr.project)
871
872 continue_url = urllib.quote(framework_helpers.FormatAbsoluteURL(
873 mr, urls.ISSUE_ENTRY + '.do'))
874
875 return '%s%stoken=%s&role=%s&continue=%s' % (
876 base_url, sep, urllib.quote(token),
877 urllib.quote(role_name or ''), continue_url)
878
879
880 def _SafeCreateLoginURL(mr):
881 """Make a login URL w/ a detailed continue URL, otherwise use a short one."""
882 try:
883 return users.create_login_url(mr.current_page_url)
884 except users.RedirectTooLongError:
885 if mr.project_name:
886 return users.create_login_url('/p/%s' % mr.project_name)
887 else:
888 return users.create_login_url('/')
889
890
891 def _SafeCreateLogoutURL(mr):
892 """Make a logout URL w/ a detailed continue URL, otherwise use a short one."""
893 try:
894 return users.create_logout_url(mr.current_page_url)
895 except users.RedirectTooLongError:
896 if mr.project_name:
897 return users.create_logout_url('/p/%s' % mr.project_name)
898 else:
899 return users.create_logout_url('/')
900
901
902 class Error(Exception):
903 """Base class for errors from this module."""
904 pass
905
906
907 class AlreadySentResponseException(Error):
908 """The servlet already responded, no need to render a page template."""
909 pass
OLDNEW
« no previous file with comments | « appengine/monorail/framework/registerpages_helpers.py ('k') | appengine/monorail/framework/servlet_helpers.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698