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();
}
}
}
}
}