Merge branch 'main' into avm99963-monorail

Merged commit cd4b3b336f1f14afa02990fdc2eec5d9467a827e

GitOrigin-RevId: e67bbf185d5538e1472bb42e0abb2a141f88bac1
diff --git a/framework/jsonfeed.py b/framework/jsonfeed.py
index 44e9cea..b7d85ac 100644
--- a/framework/jsonfeed.py
+++ b/framework/jsonfeed.py
@@ -12,7 +12,7 @@
 from __future__ import division
 from __future__ import absolute_import
 
-import httplib
+from six.moves import http_client
 import json
 import logging
 
@@ -21,6 +21,8 @@
 import settings
 
 from framework import framework_constants
+from framework import flaskservlet
+from framework import servlet_helpers
 from framework import permissions
 from framework import servlet
 from framework import xsrf
@@ -49,7 +51,7 @@
     Returns:
       A dictionary of json data.
     """
-    raise servlet.MethodNotSupportedError()
+    raise servlet_helpers.MethodNotSupportedError()
 
   def _DoRequestHandling(self, request, mr):
     """Do permission checking, page processing, and response formatting."""
@@ -72,7 +74,7 @@
       if self.CHECK_SAME_APP and not settings.local_mode:
         calling_app_id = request.headers.get('X-Appengine-Inbound-Appid')
         if calling_app_id != app_identity.get_application_id():
-          self.response.status = httplib.FORBIDDEN
+          self.response.status = http_client.FORBIDDEN
           return
 
       self._CheckForMovedProject(mr, request)
@@ -89,7 +91,7 @@
       self.abort(400, msg)
     except permissions.PermissionException as e:
       logging.info('Trapped PermissionException %s', e)
-      self.response.status = httplib.FORBIDDEN
+      self.response.status = http_client.FORBIDDEN
 
   # pylint: disable=unused-argument
   # pylint: disable=arguments-differ
@@ -132,3 +134,99 @@
   """Internal tasks are JSON feeds that can only be reached by our own code."""
 
   CHECK_SECURITY_TOKEN = False
+
+
+class FlaskJsonFeed(flaskservlet.FlaskServlet):
+  """A convenient base class for JSON feeds."""
+
+  # By default, JSON output is compact.  Subclasses can set this to
+  # an integer, like 4, for pretty-printed output.
+  JSON_INDENT = None
+
+  # Some JSON handlers can only be accessed from our own app.
+  CHECK_SAME_APP = False
+
+  def HandleRequest(self, _mr):
+    """Override this method to implement handling of the request.
+
+    Args:
+      mr: common information parsed from the HTTP request.
+
+    Returns:
+      A dictionary of json data.
+    """
+    raise servlet_helpers.MethodNotSupportedError()
+
+  def _DoRequestHandling(self, request, mr):
+    """Do permission checking, page processing, and response formatting."""
+    try:
+      if self.CHECK_SECURITY_TOKEN and mr.auth.user_id:
+        try:
+          logging.info('request in jsonfeed is %r', request)
+          xsrf.ValidateToken(mr.token, mr.auth.user_id, request.path)
+        except xsrf.TokenIncorrect:
+          logging.info('using token path "xhr"')
+          xsrf.ValidateToken(mr.token, mr.auth.user_id, xsrf.XHR_SERVLET_PATH)
+
+      if self.CHECK_SAME_APP and not settings.local_mode:
+        calling_app_id = request.headers.get('X-Appengine-Inbound-Appid')
+        if calling_app_id != app_identity.get_application_id():
+          self.response.status = http_client.FORBIDDEN
+          return
+
+      self._CheckForMovedProject(mr, request)
+      self.AssertBasePermission(mr)
+
+      json_data = self.HandleRequest(mr)
+
+      self._RenderJsonResponse(json_data)
+
+    except query2ast.InvalidQueryError as e:
+      logging.warning('Trapped InvalidQueryError: %s', e)
+      logging.exception(e)
+      msg = e.message if e.message else 'invalid query'
+      self.abort(400, msg)
+    except permissions.PermissionException as e:
+      logging.info('Trapped PermissionException %s', e)
+      self.response.status = http_client.FORBIDDEN
+
+  # pylint: disable=unused-argument
+  # pylint: disable=arguments-differ
+  # Note: unused arguments necessary because they are specified in
+  # registerpages.py as an extra URL validation step even though we
+  # do our own URL parsing in monorailrequest.py
+  def get(self, **kwargs):
+    """Collect page-specific and generic info, then render the page.
+
+    Args:
+      project_name: string project name parsed from the URL by webapp2,
+        but we also parse it out in our code.
+      viewed_username: string user email parsed from the URL by webapp2,
+        but we also parse it out in our code.
+      hotlist_id: string hotlist id parsed from the URL by webapp2,
+        but we also parse it out in our code.
+    """
+    self._DoRequestHandling(self.mr.request, self.mr)
+
+  # pylint: disable=unused-argument
+  # pylint: disable=arguments-differ
+  def post(self, **kwargs):
+    """Parse the request, check base perms, and call form-specific code."""
+    self._DoRequestHandling(self.mr.request, self.mr)
+
+  def _RenderJsonResponse(self, json_data):
+    """Serialize the data as JSON so that it can be sent to the browser."""
+    json_str = json.dumps(json_data, indent=self.JSON_INDENT)
+    logging.debug(
+        'Sending JSON response: %r length: %r',
+        json_str[:framework_constants.LOGGING_MAX_LENGTH], len(json_str))
+    self.response.content_type = framework_constants.CONTENT_TYPE_JSON
+    self.response.headers['X-Content-Type-Options'] = (
+        framework_constants.CONTENT_TYPE_JSON_OPTIONS)
+    self.response.set_data(XSSI_PREFIX + json_str)
+
+
+class FlaskInternalTask(FlaskJsonFeed):
+  """Internal tasks are JSON feeds that can only be reached by our own code."""
+
+  CHECK_SECURITY_TOKEN = False