SpreadsheetWebApp.java

/*
 ** Module   : SpreadsheetWebApp.java
 ** Abstract : SPREADSHEET widget web application.
 **
 ** Copyright (c) 2024, Golden Code Development Corporation.
 **
 ** -#- -I- --Date-- ---------------------------------Description---------------------------------
 ** 001 HC  20240228 Created initial version.
 ** 002 HC  20240315 Implemented optional warmup of SPREADSHEET web application backend.
 ** 003 HC  20240513 Moved SPREADSHEET backend (Keikai web application and the related FWD
 **                  integration) from client to server.
 ** 004 HC  20240526 Added Keikai locale setup for both the Conversation and the HTTP request
 **                  threads.
 ** 005 HC  20241203 Improved event handling to better handle rapid key typing with high
 **                  latencies of Keikai backend. The changes simplify event handling by removing
 **                  the need for key events delivered to the backend through a custom web
 **                  socket, now all the events are delivered using the standard Keikai
 **                  mechanism.
 */
/*
 ** This program is free software: you can redistribute it and/or modify
 ** it under the terms of the GNU Affero General Public License as
 ** published by the Free Software Foundation, either version 3 of the
 ** License, or (at your option) any later version.
 **
 ** This program is distributed in the hope that it will be useful,
 ** but WITHOUT ANY WARRANTY; without even the implied warranty of
 ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 ** GNU Affero General Public License for more details.
 **
 ** You may find a copy of the GNU Affero GPL version 3 at the following
 ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
 **
 ** Additional terms under GNU Affero GPL version 3 section 7:
 **
 **   Under Section 7 of the GNU Affero GPL version 3, the following additional
 **   terms apply to the works covered under the License.  These additional terms
 **   are non-permissive additional terms allowed under Section 7 of the GNU
 **   Affero GPL version 3 and may not be removed by you.
 **
 **   0. Attribution Requirement.
 **
 **     You must preserve all legal notices or author attributions in the covered
 **     work or Appropriate Legal Notices displayed by works containing the covered
 **     work.  You may not remove from the covered work any author or developer
 **     credit already included within the covered work.
 **
 **   1. No License To Use Trademarks.
 **
 **     This license does not grant any license or rights to use the trademarks
 **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
 **     of Golden Code Development Corporation. You are not authorized to use the
 **     name Golden Code, FWD, or the names of any author or contributor, for
 **     publicity purposes without written authorization.
 **
 **   2. No Misrepresentation of Affiliation.
 **
 **     You may not represent yourself as Golden Code Development Corporation or FWD.
 **
 **     You may not represent yourself for publicity purposes as associated with
 **     Golden Code Development Corporation, FWD, or any author or contributor to
 **     the covered work, without written authorization.
 **
 **   3. No Misrepresentation of Source or Origin.
 **
 **     You may not represent the covered work as solely your work.  All modified
 **     versions of the covered work must be marked in a reasonable way to make it
 **     clear that the modified work is not originating from Golden Code Development
 **     Corporation or FWD.  All modified versions must contain the notices of
 **     attribution required in this license.
 */

package com.goldencode.p2j.main;

import com.goldencode.p2j.net.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.ui.SpreadsheetWidget;
import com.goldencode.p2j.ui.client.event.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import org.eclipse.jetty.annotations.*;
import org.eclipse.jetty.http.MetaData;
import org.eclipse.jetty.server.*;
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.handler.*;
import org.eclipse.jetty.webapp.*;

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.logging.*;
import java.util.regex.*;

/**
 * Represents a web application for managing and interacting with spreadsheets through a web interface.
 * This class initializes and manages the lifecycle of the web application including starting, stopping,
 * and handling of web contexts for spreadsheet widgets. It utilizes Jetty for web server functionality
 * and is designed to be accessed as a singleton instance.
 */
public class SpreadsheetWebApp
{
   /** Logger */
   private static final CentralLogger LOG = CentralLogger.get(SpreadsheetWebApp.class.getSimpleName());

   /** Singleton instance of the SpreadsheetWebApp class. */
   private static final SpreadsheetWebApp instance = new SpreadsheetWebApp();

   /** The static part of context path */
   private static final String CONTEXT_PATH = "/_sheet";

   /** WebAppContext for managing the web application's servlet context. */
   private WebAppContext webApp;

   /** The set of active spreadsheet widget ids */
   private Set<Integer> activeWidgets = new HashSet<>();

   /** Thread-local storage for current request */
   private static ThreadLocal<String> currentRequestOrigURI = new ThreadLocal<>();

   /** Represents an atomic integer that is used to generate a unique context ID. */
   private static AtomicInteger lastContextId = new AtomicInteger();

   /**
    * A map that stores the Spreadsheet application contexts.
    * The map is thread-safe and allows concurrent access.
    */
   private static Map<Integer, Context> contexts = new ConcurrentHashMap<>();

   /**
    * Retrieves the singleton instance of the SpreadsheetWebApp.
    *
    * @return  The singleton instance of SpreadsheetWebApp.
    */
   public static SpreadsheetWebApp getInstance()
   {
      return instance;
   }

   /**
    * Returns the current request URI
    *
    * @return  See above.
    */
   public static String getCurrentRequestOrigURI()
   {
      return currentRequestOrigURI.get();
   }

   /**
    * Initializes the web application.
    */
   public Handler init()
   {
      try
      {
         // Override Jetty's web app context to allow handling multiple spreadsheet instances
         // from a single web app while allowing to request the resources with widget-specific
         // URI (/_sheet/<widgetId>). The widget-specific URI allows to bind to the specific driver
         // widget inside the web application.
         webApp = new WebAppContext()
         {
            private Pattern contextIdPattern;

            @Override
            public void setContextPath(String contextPath)
            {
               super.setContextPath(contextPath);
               contextIdPattern = Pattern.compile(contextPath + "/\\d+");
            }

            @Override
            public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
            throws IOException,
                   ServletException
            {
               ContextHandler.Context oldContext = null;
               String oldContextPath = null;
               String oldServletPath = null;
               String oldPathInfo = null;
               ClassLoader oldClassloader = null;
               Thread currentThread = null;
               String pathInfo = target;
               DispatcherType dispatch = baseRequest.getDispatcherType();
               oldContext = baseRequest.getContext();
               String contextPath = getContextPath();
               if (oldContext != this._scontext) {
                  if (DispatcherType.REQUEST.equals(dispatch) || DispatcherType.ASYNC.equals(dispatch)) {
                     if (contextIdPattern != null)
                     {
                        Matcher matcher = contextIdPattern.matcher(target);
                        if (matcher.find())
                        {
                           StringBuffer sb = new StringBuffer();
                           matcher.appendReplacement(sb, contextPath);
                           matcher.appendTail(sb);
                           target = sb.toString();
                        }
                     }

                     if (!this.checkContext(target, baseRequest, response)) {
                        return;
                     }

                     if (target.length() > contextPath.length()) {
                        if (contextPath.length() > 1) {
                           target = target.substring(contextPath.length());
                        }

                        pathInfo = target;
                     } else if (contextPath.length() == 1) {
                        target = "/";
                        pathInfo = "/";
                     } else {
                        target = "/";
                        pathInfo = null;
                     }
                  }

                  if (this.getClassLoader() != null) {
                     currentThread = Thread.currentThread();
                     oldClassloader = currentThread.getContextClassLoader();
                     currentThread.setContextClassLoader(this.getClassLoader());
                  }
               }

               ThreadLocal __context = getPrivateFieldValue(ContextHandler.class, this, "__context");

               try {
                  currentRequestOrigURI.set(baseRequest.getOriginalURI());

                  oldContextPath = baseRequest.getContextPath();
                  oldServletPath = baseRequest.getServletPath();
                  oldPathInfo = baseRequest.getPathInfo();
                  baseRequest.setContext(this._scontext);
                  __context.set(this._scontext);
                  if (!DispatcherType.INCLUDE.equals(dispatch) && target.startsWith("/")) {
                     if (contextPath.length() == 1) {
                        baseRequest.setContextPath("");
                     } else {
                        baseRequest.setContextPath(this.getContextPathEncoded());
                     }

                     baseRequest.setServletPath((String)null);
                     baseRequest.setPathInfo(pathInfo);
                  }

                  if (oldContext != this._scontext) {
                     this.enterScope(baseRequest, dispatch);
                  }

                  this.nextScope(target, baseRequest, request, response);
               } finally {
                  if (oldContext != this._scontext) {
                     this.exitScope(baseRequest);
                     if (this.getClassLoader() != null && currentThread != null) {
                        currentThread.setContextClassLoader(oldClassloader);
                     }

                     baseRequest.setContext(oldContext);
                     __context.set(oldContext);
                     baseRequest.setContextPath(oldContextPath);
                     baseRequest.setServletPath(oldServletPath);
                     baseRequest.setPathInfo(oldPathInfo);

                     currentRequestOrigURI.set(null);
                  }

               }

            }
         };

         // add AnnotationConfiguration to the list of default configurations to initialize jsp
         // (JasperInitializer is called)
         webApp.setConfigurations(new org.eclipse.jetty.webapp.Configuration[]
         {
            new WebInfConfiguration(),
            new WebXmlConfiguration(),
            new MetaInfConfiguration(),
            new FragmentConfiguration(),
            new JettyWebXmlConfiguration(),
            new AnnotationConfiguration()
         });

         webApp.setWar(getWARPath().getAbsolutePath());
         webApp.setContextPath(CONTEXT_PATH);

         return webApp;
      }
      catch (Throwable e)
      {
         LOG.log(Level.WARNING, "Spreadsheet web app init error.", e);
         return null;
      }
   }

   /**
    * Starts the web application for a specific spreadsheet widget identified by its widget ID. This method
    * sets the context path and starts the web application context. If the application fails to start,
    * it logs the error and returns null.
    *
    * @param   widgetId
    *          The ID of the widget for which the web application is to be started.
    *
    * @return  The path to the started web context or null if the application failed to start.
    */
   public String start(int widgetId)
   {
      if (webApp == null || webApp.isFailed() || !webApp.isAvailable())
      {
         Throwable ex = webApp.getUnavailableException();
         LOG.log(Level.WARNING, "Failed to start Spreadsheet web application!", ex);

         webApp = null;
         return null;
      }

      activeWidgets.add(widgetId);
      return CONTEXT_PATH + "/" + widgetId;
   }

   /**
    * Checks if the web application is started for a specific widget ID.
    *
    * @param   widgetId
    *          The ID of the widget to check.
    *
    * @return  true if the web application is started, false otherwise.
    */
   public boolean isStarted(int widgetId)
   {
      return activeWidgets.contains(widgetId);
   }

   /**
    * Stops the web application for a specific widget ID. If the application is not running,
    * this method does nothing.
    *
    * @param   widgetId
    *          The ID of the widget for which the web application is to be stopped.
    */
   public void stop(int widgetId)
   {
      activeWidgets.remove(widgetId);
   }

   /**
    * This method is called when a SpreadsheetWidget is created. It is responsible for creating a context,
    * assigning necessary values to it, initializing the dispatcher, setting the context ID in the
    * SpreadsheetConfig, and adding the context to the map of contexts.
    *
    * @param   widget
    *          The SpreadsheetWidget that was created.
    * @param   eventHandler
    *          The SheetEventHandler that will handle events for the SpreadsheetWidget.
    *
    * @return  The ID of the created context.
    */
   public static int onSpreadsheetWidgetCreated(SpreadsheetWidget widget, SheetEventHandler eventHandler)
   {
      Context ctxt = new Context();
      ctxt.widget = widget;
      ctxt.eventHandler = eventHandler;
      ctxt.client = LogicalTerminal.getClient();
      ctxt.id = lastContextId.incrementAndGet();

      // TODO: get the locale from the legacy session
      String locString = Utils.getDirectoryNodeString(null, "currentLanguage", "?");
      if (locString != null)
      {
         // convert to BCP 47
         locString = locString.replace("_", "-");
         ctxt.locale = Locale.forLanguageTag(locString);
      }
      ctxt.dispatcher = new SecurityContextDispatcher();
      SpreadsheetConfig c = widget.config();
      c.contextId = ctxt.id;
      ClientParameters cp = StandardServer.getClientParameters();
      c.welcomeUrl = cp.serverBaseUrl + CONTEXT_PATH + "/" + c.contextId;
      contexts.put(ctxt.id, ctxt);
      ctxt.dispatcher.start();
      return ctxt.id;
   }

   /**
    * Removes the context associated with a deleted SpreadsheetWidget.
    *
    * @param   widget
    *          The SpreadsheetWidget that was deleted.
    */
   public static void onSpreadsheetWidgetDeleted(SpreadsheetWidget widget)
   {
      Context ctxt = contexts.remove(widget.config().contextId);
      if (ctxt != null)
      {
         ctxt.dispatcher.stop();
      }
   }

   /**
    * Returns the spreadsheet widget ID belonging to the current HTTP request (i.e. loading the
    * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
    * web component, etc.).
    *
    * @return  See above.
    */
   public static int getWidgetId()
   {
      Context ctxt = getContext();
      return ctxt != null ? ctxt.widget.getId() : WidgetId.INVALID_WIDGET_ID;
   }

   /**
    * Returns the SheetEventHandler instance belonging to the current HTTP request (i.e. loading the
    * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
    * web component, etc.).
    *
    * @return  See above.
    */
   public static SheetEventHandler getEventHandler()
   {
      Context ctxt = getContext();
      return ctxt != null ? ctxt.eventHandler : null;
   }

   /**
    * Returns the spreadsheet web app context belonging to the current HTTP request (i.e. loading the
    * spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
    * web component, etc.).
    *
    * @return  See above.
    */
   public static Context getContext()
   {
      return contexts.get(getContextId());
   }

   /**
    * Returns the ID if the spreadsheet web app context belonging to the current HTTP request (i.e. loading
    * the spreadsheet web component from the JS client, handling an event triggered in the spreadsheet
    * web component, etc.).
    *
    * @return  See above.
    */
   public static int getContextId()
   {
      return getContextId(getCurrentRequestOrigURI());
   }

   /**
    * Returns the spreadsheet web app context associated with the specified contextId.
    *
    * @param   contextId
    *          The identifier of the context object to retrieve.
    *
    * @return  The Context object associated with the specified contextId.
    */
   public static Context getContext(int contextId)
   {
      return contexts.get(contextId);
   }

   /**
    * Retrieves the ID of the spreadsheet web app context from the supplied path. The path is expected to
    * have the following form: /sheet/[context ID]/whatever/subpath/components.
    *
    * @param   path
    *          The path of the current HTTP request.
    *
    * @return  The ID of the spreadsheet web app context, or -1 if the ID cannot be determined.
    */
   public static int getContextId(String path)
   {
      if (path == null)
      {
         return -1;
      }

      String[] components = path.split("/");
      if (components.length < 3)
      {
         return -1;
      }

      try
      {
         return Integer.parseInt(components[2]);
      }
      catch (NumberFormatException ex)
      {
         return -1;
      }
   }

   /**
    * The method invokes {@link ClientExports#postServerEvent(ServerEvent)} asynchronously
    * in a thread associated with the security context related to the supplied spreadsheet web
    * app context.
    *
    * @param   contextId
    *          ID representing a valid spreadsheet web app context.
    * @param   run
    *          The code to be executed when the server event is handled on the server.
    *
    * @return  {@code true} if the server event is successfully posted, {@code false} otherwise.
    */
   public static boolean postServerEvent(int contextId, Runnable run)
   {
      Context ctxt = contexts.get(contextId);
      if (ctxt == null)
      {
         LOG.warning("postServerEvent called but no context found for context id " + contextId);
         return false;
      }

      postServerEvent(ctxt, run);
      return true;
   }

   /**
    * The method invokes {@link ClientExports#postServerEvent(ServerEvent)} asynchronously
    * in a thread associated with the security context related to the supplied spreadsheet web
    * app context.
    *
    * @param   context
    *          A valid spreadsheet web app context.
    * @param   run
    *          The code to be executed when the server event is handled on the server.
    */
   public static void postServerEvent(Context context, Runnable run)
   {
      context.dispatcher.execute(() ->
         context.client.postServerEvent(new ServerEvent(context.widget.getId(), "", run)));
   }

   /**
    * Returns the value of a private field in the given object without throwing checked exceptions.
    *
    * @param   clazz
    *          the class whose private field is to be read
    * @param   targetObject
    *          the object whose private field is to be read
    * @param   fieldName
    *          the name of the private field to be read
    */
   private static <T> T getPrivateFieldValue(Class clazz, Object targetObject, String fieldName)
   {
      try
      {
         // Get the private field, using getDeclaredField to access private fields
         Field field = clazz.getDeclaredField(fieldName);

         // Set the field accessible to bypass access checks
         field.setAccessible(true);

         // Set the value of the field in the target object
         return (T) field.get(targetObject);
      }
      catch (NoSuchFieldException | IllegalAccessException e)
      {
         throw new RuntimeException("Error setting private field value: " + e.getMessage(), e);
      }
   }

   /**
    * Returns the path of the spreadsheet backed war file.
    *
    * @return  See above.
    */
   private File getWARPath()
   {
      // TODO: currently the method searches for the p2j.jar parent dir and uses it to resolve the war file.
      //       This should eventually change once war files can be defined in the directory.
      String cp = System.getProperty("java.class.path");
      String[] paths = cp.split(File.pathSeparator);

      String p2jPath = null;
      for (String path : paths)
      {
         if (path.endsWith("p2j.jar"))
         {
            p2jPath = path;
            break;
         }
      }

      File f = null;
      if (p2jPath != null)
      {
         f = new File(p2jPath);
         f = f.getParentFile();
      }

      return new File(f, "fwd_sheet.war");
   }

   /**
    * The Context class represents the context within which a spreadsheet widget operates.
    * It holds information such as the widget ID, event handlers, client exports, and security context dispatcher.
    */
   public static class Context
   {
      /** The ID of the context. */
      public int id;

      /** The SpreadsheetWidget associated with the context. */
      public SpreadsheetWidget widget;

      /** The Spreadsheet UI event handler. */
      public SheetEventHandler eventHandler;

      /** The client exports associated with this context. */
      public ClientExports client;

      /** Security context dispatcher associated with this context. */
      public SecurityContextDispatcher dispatcher;

      /** The effective locale at time of SPREADSHEET widget creation */
      public Locale locale;
   }

   /**
    * The class provides a mechanism to execute runnables asynchronously in a separate thread associated
    * with the related security context.
    *
    * Executing on the associated thread is needed in order to access the resources bound to the related
    * security context and call remote objects like ThinClient.
    */
   public static class SecurityContextDispatcher
   {
      /** The associated thread that allows execution of runnables asynchronously. */
      private AssociatedThread thread;

      /** Stores runnable instances to be executed asynchronously. */
      private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();

      /**
       * Starts the thread associated with the current security context.
       */
      public void start()
      {
         thread = new AssociatedThread(this::run);
         thread.setDaemon(true);
         SessionManager.get().registerAsyncThread(thread);
         thread.start();
      }

      /**
       * Stops the associated thread.
       */
      public void stop()
      {
         thread.interrupt();

         try
         {
            thread.join();
         }
         catch (InterruptedException e)
         {
            Thread.currentThread().interrupt();
         }

         SessionManager.get().deregisterAsyncThread(thread);
      }

      /**
       * Executes the supplied runnable asynchronously in a thread associated with the security context.
       *
       * @param   run
       *          The runnable object representing the execution to be added to the queue.
       */
      public void execute(Runnable run)
      {
         queue.add(run);
      }

      /**
       * Executes the runnables in the queue. The method will continue executing until the thread is
       * interrupted.
       */
      private void run()
      {
         while (!thread.interrupted())
         {
            try
            {
               Runnable r = queue.take();
               r.run();
            }
            catch (InterruptedException e)
            {
               thread.interrupt();
            }
         }
      }
   }
}