# ReportProtocol.java

    /*
    ** Module   : ReportProtocol.java
    ** Abstract : Provides a protocol for communications between the report server and a web client.
    **
    ** Copyright (c) 2017-2024, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ---------------------------------Description----------------------------------
    ** 001 ECF 20170428 First version.
    ** 002 ECF 20181117 Reduce memory consumption by sending JSON response in chunks.
    ** 003 TJD 20220504 Java 11 compatibility minor changes
    ** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
    ** 005 GBB 20240430 Catching all throwables from instantiating api classes.
    ** 006 TJD 20240508 Fixed compiler warnings related with dependencies updates
    */

    /*
    ** 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.report.server;

    import java.io.*;
    import java.lang.reflect.*;
    import java.security.*;
    import java.security.spec.*;
    import java.util.*;
    import java.util.concurrent.atomic.*;
    import java.util.logging.*;

    import com.goldencode.p2j.convert.*;
    import org.eclipse.jetty.websocket.api.*;
    import org.eclipse.jetty.websocket.api.annotations.*;
    import com.fasterxml.jackson.core.*;
    import com.fasterxml.jackson.databind.*;

    /**
     * A protocol for communications between the report server and a web client over a web socket.
     * Parameters and return data are marshalled and unmarshalled using Jackson. API calls are
     * dispatched using reflection, and parameters and returned objects are marshalled/unmarshalled
     * according to the method signatures of the API methods invoked. If an exception is thrown
     * during the processing of an API request, that exception is marshalled and returned as the
     * payload of a response message.
     */
    @WebSocket
    public class ReportProtocol
    {
       /** Base value for message types */
       public static final int MSG_BASE = 0;

       /** Logger. */
       private static final ConversionStatus LOG = ConversionStatus.get(ReportProtocol.class);

       /** Maximum JSON chunk length sent as a response */
       private static final int JSON_MAX_CHUNK = 8 * 1024 * 1024;

       /** Map of API types to API handler methods */
       private static final Map<Integer, Method> requestApi = new HashMap<>();

       /** Set of classes which contain API handler methods */
       private static final Set<Class<?>> requestApiClasses = new HashSet<>();

       /** Next available session ID */
       private static AtomicLong nextSessionId = new AtomicLong(0L);

       static
       {
          registerRequestApi(Services.class, false);
       }

       /** Map of API handler classes to instances of these classes which perform API work */
       private final Map<Class<?>, Object> requestWorkers = new HashMap<>();

       /** Active web socket session */
       private Session session = null;

       /** Current session ID */
       private long sessionId = -1L;

       /** Logged on user ({@code null} if no user is currently logged in) */
       private User user = null;

       /**
        * Default constructor.
        */
       public ReportProtocol()
       {
       }

       /**
        * Register an API handler class. All methods marked with the {@link WebApi} annotation will
        * be registered as request API handler methods.
        *
        * @param   apiClass
        *          A class which contains one or more request API handler methods.
        */
       public static void registerRequestApi(Class<?> apiClass)
       {
          registerRequestApi(apiClass, true);
       }

       /**
        * Register an API handler class. All methods marked with the {@link WebApi} annotation will
        * be registered as request API handler methods.
        *
        * @param   apiClass
        *          A class which contains one or more request API handler methods.
        * @param   autoInstantiate
        *          {@code true} to automatically instantiate the API class using reflection when the
        *          web socket connects; {@code false} if reflection should not be used (only relevant
        *          for the {@code Services} inner class).
        */
       private static void registerRequestApi(Class<?> apiClass, boolean autoInstantiate)
       {
          if (autoInstantiate)
          {
             requestApiClasses.add(apiClass);
          }

          for (Method method : apiClass.getDeclaredMethods())
          {
             if (!method.isAnnotationPresent(WebApi.class))
             {
                continue;
             }

             int type = method.getAnnotation(WebApi.class).type();
             if (requestApi.containsKey(type))
             {
                // TODO: proper exception
                throw new RuntimeException(
                   "Cannot register request handler '" +
                   method +
                   "'; API type " +
                   type +
                   " already registered to '" +
                   requestApi.get(type) +
                   "'");
             }

             requestApi.put(type, method);
          }
       }

       /**
        * This method is called when the remote peer open a web socket connection.
        *
        * @param   session
        *          An active link of communications with a Remote WebSocket Endpoint.
        *
        */
       @OnWebSocketConnect
       public void onConnect(Session session)
       {
          this.session = session;
          sessionId = nextSessionId.incrementAndGet();

          try
          {
             // special handling for inner services class
             requestWorkers.put(Services.class, new Services());

             for (Class<?> apiClass : requestApiClasses)
             {
                Object worker = apiClass.getDeclaredConstructor().newInstance();
                requestWorkers.put(apiClass, worker);
             }
          }
          catch (Throwable t)
          {
             LOG.log(Level.SEVERE, "Can't instantiate api class.", t);
             // TODO: proper exception
             throw new RuntimeException(t);
          }
       }

       /**
        * This method is called when the connection is closed.
        *
        * @param   statusCode
        *          The status integer code.
        * @param   reason
        *          A string representing the reason.
        */
       @OnWebSocketClose
       public void onClose(int statusCode, String reason)
       {
          cleanup();
       }

       /**
        * This method is called when a text message is received.
        *
        * @param   message
        *          The text message.
        */
       @OnWebSocketMessage
       public void onMessage(String message)
       {
          if (session == null || !session.isOpen())
          {
             // TODO: log
             return;
          }

          processIncoming(message);
       }

       /**
        * This method is called when a binary message is received.
        *
        * @param   message
        *          Message content as an byte array.
        * @param   offset
        *          Message offset.
        * @param   length
        *          Message size.
        */
       @OnWebSocketMessage
       public void onMessage(final byte[] message, final int offset, final int length)
       {
          // not used
       }

       /**
        * This method is called on communication errors.
        *
        * @param   session
        *          An active link of communications with a Remote WebSocket Endpoint.
        * @param   error
        *          A {@code Throwable} instance representing the error condition.
        */
       @OnWebSocketError
       public void onError(Session session, Throwable error)
       {
          session = null;
       }

       /**
        * Process an incoming JSON message string for a request API. This involves:
        * <ol>
        * <li>Deserializing the JSON message, which consists of a message header and optional payload.
        *     If present, the payload will consist of an array of serialized objects and/or
        *     primitives.</li>
        * <li>Invoking the appropriate API handler method.</li>
        * <li>Serializing the value (if any) returned by the API worker method into a response JSON
        *     string message.</li>
        * <li>Sending the response message to the remote side of the web socket.</li>
        * </ol>
        *
        * @param   message
        *          Incoming JSON message.
        */
       private void processIncoming(String message)
       {
          // TODO: add security check

          JsonParser parser = null;
          JsonGenerator gen = null;
          String jsonResponse = null;

          int msgId = -1;
          int msgType = -1;
          Throwable error = null;

          ObjectMapper mapper = new ObjectMapper();
          JsonFactory factory = mapper.getFactory();

          // create a buffered writer to stream the JSON response to the remote endpoint in chunks,
          // to avoid memory spikes for very large responses
          Writer writer = new BufferedWriter(new ResponseWriter(session.getRemote()), JSON_MAX_CHUNK);

          try
          {
             // parse incoming message
             parser = factory.createParser(message);

             // parse message header
             parseHeader:
             while (parser.nextToken() != JsonToken.END_OBJECT)
             {
                String field = parser.currentName();

                if (field == null)
                {
                   continue;
                }

                switch (field)
                {
                   case "id":
                      parser.nextToken();
                      msgId = parser.getIntValue();
                      break;

                   case "type":
                      parser.nextToken();
                      msgType = parser.getIntValue();
                      break;

                   case "pay":
                      break parseHeader;

                   default:
                      break;
                }
             }

             // sanity check
             if (msgId < 0 || msgType < MSG_BASE)
             {
                // TODO: log and throw proper exception
                throw new RuntimeException("malformed JSON for incoming message:\n" + message);
             }

             if (msgType != Services.MSG_AUTH && user == null)
             {
                throw new SecurityException("not logged on");
             }

             // use the message type to look up the worker method which handles this request
             Method handler = requestApi.get(msgType);
             if (handler == null)
             {
                throw new RuntimeException("Unknown message type: " + msgType);
             }

             // use the handler method's signature to determine the expected parameters in the
             // payload array
             Object[] args = null;
             if (parser.nextToken() == JsonToken.START_ARRAY)
             {
                Parameter[] parms = handler.getParameters();
                int len = parms.length;
                args = new Object[len];

                for (int i = 0; i < len && parser.nextToken() != JsonToken.END_ARRAY; i++)
                {
                   Class<?> type = parms[i].getType();
                   args[i] = parser.readValueAs(type);
                }
             }

             parser.close();
             parser = null;

             // look up the object instance associated with the handler method and invoke the method
             // on it
             Object worker = requestWorkers.get(handler.getDeclaringClass());
             Object result = null;
             if (args != null)
             {
                result = handler.invoke(worker, args);
             }
             else
             {
                result = handler.invoke(worker);
             }

             // generate JSON message for return to client
             gen = factory.createGenerator(writer);
             generateJsonResponse(gen, msgId, msgType, true, result);
             gen = null;
          }
          catch (IOException | IllegalAccessException | IllegalArgumentException exc)
          {
             LOG.log(Level.SEVERE, "", exc);

             error = exc;
          }
          catch (InvocationTargetException exc)
          {
             LOG.log(Level.SEVERE, "", exc);

             error = exc.getCause();
          }
          finally
          {
             if (parser != null)
             {
                try
                {
                   parser.close();
                }
                catch (IOException exc)
                {
                   error = exc.getCause();

                   LOG.log(Level.SEVERE, "", exc);
                }
             }

             if (gen != null)
             {
                try
                {
                   gen.close();
                }
                catch (IOException exc)
                {
                   error = exc.getCause();

                   LOG.log(Level.SEVERE, "", exc);
                }
             }

             if (error != null)
             {
                try
                {
                   gen = factory.createGenerator(writer);
                   generateJsonResponse(gen, msgId, msgType, false, error);
                   gen = null;
                }
                catch (IOException exc)
                {
                   LOG.log(Level.SEVERE, "", exc);
                }
             }

             try
             {
                writer.close();
             }
             catch (IOException exc)
             {
                LOG.log(Level.SEVERE, "", exc);
             }

             // close websocket if authentication failed; this can't happen in Services.authenticate
             // because we need to return failure information to the client before closing the
             // session/socket
             if (user == null && session != null && session.isOpen())
             {
                try
                {
                   if (msgType == Services.MSG_AUTH)
                   {
                      session.close(1000, "Unable to authenticate session");
                   }
                   else
                   {
                      session.close();
                   }
                }
                finally
                {
                   cleanup();
                }
             }
          }
       }

       /**
        * Generate a JSON message response which includes a message header and payload.
        *
        * @param   gen
        *          Jackson JSON generator.
        * @param   msgId
        *          Message identifier sent with message request.
        * @param   msgType
        *          Type of message request to which this is the response.
        * @param   success
        *          {@code True} if the API was successfully executed; {@code false} if there was an
        *          error.
        * @param   payload
        *          Response payload, which will be the normal payload if the API was successful, or
        *          a {@code Throwable} if an exception occurred.
        *
        * @throws  IOException
        *          if there is an error serializing the response to JSON.
        */
       private void generateJsonResponse(JsonGenerator gen,
                                         int msgId,
                                         int msgType,
                                         boolean success,
                                         Object payload)
       throws IOException
       {
          gen.writeStartObject();

          // write response header (same as the incoming header, but with success indicator)
          gen.writeFieldName("id");
          gen.writeNumber(msgId);
          gen.writeFieldName("type");
          gen.writeNumber(msgType);
          gen.writeFieldName("success");
          gen.writeBoolean(success);

          // write response payload, if any
          gen.writeFieldName("pay");

          if (payload == null)
          {
             gen.writeNull();
          }
          else
          {
             gen.writeObject(payload);
          }

          gen.writeEndObject();

          gen.close();
       }

       /**
        * Clean up after a session ends.
        */
       private void cleanup()
       {
          session = null;
          user = null;
       }

       /**
        * Writer to which a JSON generator streams JSON output, and which writes it to the remote
        * endpoint as it is generated. This writer should be attached to a {@code BufferedWriter}
        * to stream the JSON to the enpoint in chunks. This prevents huge JSON strings from being
        * created that would consume too much memory.
        */
       private class ResponseWriter
       extends Writer
       {
          /** Remote endpoint to which JSON is streamed as it is generated */
          private final RemoteEndpoint endpoint;

          /** Next string or partial string of JSON output to be written to the remote endpoint */
          private String pending = null;

          /**
           * Constructor.
           *
           * @param   endpoint
           *          Remote endpoint to which JSON is streamed as it is generated.
           */
          ResponseWriter(RemoteEndpoint endpoint)
          {
             super();

             this.endpoint = endpoint;
          }

          /**
           * Write method invoked when the next string or partial string of JSON output has been
           * generated. The string is saved in the {@code pending} field, to be sent on the next
           * call to this method or when the writer is closed.
           * <p>
           * We don't send the current buffer's contents immediately, because we don't yet know
           * whether the current chunk is the last one to be sent. The jetty API to send partial
           * strings to the remote endpoint needs to be told whether the string being sent is the
           * last one in a series. We only know this when this method is called with a 0-length
           * string or upon the writer being closed.
           *
           * @throws  IOException
           *          if there is an error writing the pending response data to the endpoint.
           */
          public void write(char[] buf, int off, int len)
          throws IOException
          {
             sendPendingResponse(len == 0);

             if (len > 0)
             {
                pending = new String(buf, off, len);
             }
          }

          /**
           * No-op.
           *
           * @throws  IOException
           *          never.
           */
          public void flush()
          throws IOException
          {
          }

          /**
           * Send any pending response as the last in the series.
           *
           * @throws  IOException
           *          if there is an error writing the pending response data to the endpoint.
           */
          public void close()
          throws IOException
          {
             sendPendingResponse(true);
          }

          /**
           * Send the pending response, if any, to the remote endpoint.
           *
           * @param   isLast
           *          {@code true} if the response to be sent is the last in the series;
           *          {@code false} if additional reponses are expected after this one.
           *
           * @throws  IOException
           *          if there is an error writing the pending response data to the endpoint.
           */
          private void sendPendingResponse(boolean isLast)
          throws IOException
          {
             if (pending != null)
             {
                endpoint.sendPartialString(pending, isLast);
                pending = null;
             }
          }
       }

       /**
        * API services that are specific to the protocol and user session.
        */
       private class Services
       {
          /** Authentication message type */
          private static final int MSG_AUTH = MSG_BASE + 1;

          /** Session close message type */
          private static final int MSG_CLOSE = MSG_BASE + 2;

          /** Request to change password */
          private static final int MSG_CHANGE_PASS = MSG_BASE + 3;

          /**
           * Default constructor.
           */
          public Services()
          {
          }

          /**
           * Authenticate a user's credentials and log into a new session.
           *
           * @param   name
           *          User name
           * @param   pass
           *          User password
           *
           * @return  A boolean array with two elements:
           *          <ul>
           *          <li>0: {@code True} if user has admin rights, else {@code false}.</li>
           *          <li>1: {@code True} if user's password has expired, else {@code false}.</li>
           *          </ul>
           *
           * @throws  RuntimeException
           *          if there was an error authenticating.
           */
          @WebApi(type = MSG_AUTH)
          public boolean[] authenticate(String name, String pass)
          {
             Authentication auth = null;

             try
             {
                ReportApi worker = (ReportApi) requestWorkers.get(ReportApi.class);
                auth = worker.logIn(name, pass, sessionId);
                if (auth == null)
                {
                   throw new RuntimeException("Invalid user name or password.");
                }
                user = auth.getUser();
             }
             catch (NoSuchAlgorithmException | InvalidKeySpecException exc)
             {
                throw new RuntimeException(exc);
             }

             boolean pwExpired = auth.getPasswordChanged() == null;

             boolean[] response = new boolean[] { user.isAdmin(), pwExpired };

             return response;
          }

          /**
           * Change the given user's password, first authenticating with the old password.
           *
           * @param   name
           *          User name.
           * @param   oldPass
           *          Existing password with which to authenticate request.
           * @param   newPass1
           *          New password.
           * @param   newPass2
           *          New password (for verification; should be the same as {@code newPass1}.
           *
           * @throws  RuntimeException
           *          if the old or new passwords were not valid or if there was an encryption or
           *          database problem.
           */
          @WebApi(type = MSG_CHANGE_PASS)
          public void changePassword(String name, String oldPass, String newPass1, String newPass2)
          {
             try
             {
                ReportApi worker = (ReportApi) requestWorkers.get(ReportApi.class);
                worker.changePassword(name, oldPass, newPass1, newPass2);
             }
             catch (NoSuchAlgorithmException | InvalidKeySpecException exc)
             {
                throw new RuntimeException("Error encrypting new password", exc);
             }
          }

          /**
           * Close the session normally, performing any session-specific protocol cleanup needed.
           */
          @WebApi(type = MSG_CLOSE)
          public void closeNormally()
          {
             // TODO: any cleanup needed upon closing the socket? Dispatch this to individual API
             // handler classes?
          }
       }
    }