CookieJar.java

/*
** Module   : CookieJar.java
** Abstract : Implementation of the builtin class.
**
** Copyright (c) 2019-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- -------------------------------Description--------------------------------
** 001 IAS 20190923 First version.
** 002 ME  20200410 Implement ILogger interface fully. 
** 003 MP  20200611 Complete class with stubs taken by converting the skeleton using FWD.
** 004 ME  20201028 Implement all methods as per OE12.2.
**     CA  20210221 Fixed parameters at the LegacySignature annotation.
** 005 CA  20210221 Fixed 'qualified', 'extent' and 'returns' annotations at the legacy
**                  signature.
**     CA  20210609 Updated INPUT/INPUT-OUTPUT parameters to the new approach, where they are explicitly 
**                  initialized at the method's execution, and not at the caller's arguments.
**     ME  20211012 Increment/decrement object references on add/remove. 
** 006 CA  20231113 The 'execute' method must be annotated with LegacySignature Type.Execute, and also can be
**                  dropped if is a no-op.
*/

/*
** 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.oo.net.http;

import com.goldencode.p2j.oo.core.*;
import com.goldencode.p2j.oo.json.objectmodel.JsonArray;
import com.goldencode.p2j.oo.json.objectmodel.JsonObject;
import com.goldencode.p2j.oo.json.objectmodel.ObjectModelParser;
import com.goldencode.p2j.oo.lang.*;
import com.goldencode.p2j.oo.logging.*;
import com.goldencode.p2j.security.ContextLocal;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.BlockManager.Action;
import com.goldencode.p2j.util.BlockManager.Condition;
import com.goldencode.p2j.util.InternalEntry.*;

import static com.goldencode.p2j.report.ReportConstants.*;
import static com.goldencode.p2j.util.BlockManager.*;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;

/**
 * 
 * Stores cookies temporarily and persistently.
 *
 */
@LegacyResource(resource = "OpenEdge.Net.HTTP.CookieJar")
@LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
public class CookieJar extends BaseObject
         implements ICookieJar, ISupportInitialize, ISupportLogging
{

   private class CookieJarEntry
   {
      final object<? extends Cookie> cookie;
      final String domain;
      final String path;
      final String name;

      final datetimetz created = TypeFactory.datetimetz();
      final datetimetz lastAccess = TypeFactory.datetimetz();

      CookieJarEntry(object<? extends Cookie> cookie, String domain, String path)
      {
         this(cookie, domain, path, datetimetz.now(), TypeFactory.datetimetz());
      }
      
      CookieJarEntry(object<? extends Cookie> cookie, String domain, String path, datetimetz created, datetimetz lastAccess)
      {
         this.cookie = cookie;
         this.name = cookie.ref().getName().getValue();
         this.domain = domain;
         this.path = TextOps.isEmpty(path) ? "/" : path;

         this.created.assign(created);
         this.lastAccess.assign(lastAccess);
      }
   }

   private final static String TAG_COOKIES = "cookies";
   private final static String TAG_COOKIE_NAME = "CookieName";
   private final static String TAG_COOKIE_DOMAIN = "Domain";
   private final static String TAG_COOKIE_PATH = "Path";
   private final static String TAG_COOKIE_DATA = "Cookie";
   private final static String TAG_COOKIE_CREATED = "CreatedAt";
   private final static String TAG_COOKIE_ACCESS = "LastAccessedAt";
   
   @LegacySignature(type = Type.PROPERTY, name = "Logger")
   private final object<? extends IlogWriter> logger = TypeFactory.object(IlogWriter.class);

   @LegacySignature(type = Type.PROPERTY, name = "CookieJarPath")
   private character cookieJarPath = TypeFactory.character();

   // persistent are stored as static for client session
   private static ContextLocal<List<CookieJarEntry>> persistentCookies = new ContextLocal<List<CookieJarEntry>>()
   {
      protected List<CookieJarEntry> initialValue()
      {
         return new ArrayList<CookieJarEntry>();
      }
   };

   // session cookies are stored only for the jar lifetime
   private List<CookieJarEntry> sessionCookies = new ArrayList<CookieJarEntry>();

   @LegacySignature(type = Type.EXECUTE)
   public void __net_http_CookieJar_execute__()
   {
      onBlockLevel(Condition.ERROR, Action.THROW);
   }

   /**
    * Get logger.
    * 
    * @return logger.
    */
   @LegacySignature(returns = "OBJECT", qualified = "OpenEdge.Logging.ILogWriter", type = Type.GETTER, name = "Logger")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   @Override
   public object<? extends IlogWriter> getLogger()
   {
      return function(this, "Logger", object.class, new Block((Body) () -> {
         returnNormal(logger);
      }));
   }

   /**
    * Destroy 
    */
   @LegacySignature(type = Type.METHOD, name = "Destroy")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   @Override
   public void destroy()
   {
      internalProcedure(CookieJar.class, this, "Destroy", new Block((Body) () -> {
         _persistCookies();
      }));
   }

   /**
    * Initialize
    */
   @LegacySignature(type = Type.METHOD, name = "Initialize")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   @Override
   public void initialize()
   {
      internalProcedure(CookieJar.class, this, "Initialize", new Block((Body) () -> {
         // load persisted cookies, no-op if file not found
         character jarStore = FileSystemOps.searchPath(cookieJarPath);
         
         if (jarStore.isUnknown())
            return;
         
         object<ObjectModelParser> parser = ObjectOps.newInstance(ObjectModelParser.class);
         object<JsonObject> data = ObjectOps.cast(parser.ref().parseFile(jarStore), JsonObject.class);
         
         if (data.ref().has(new character(TAG_COOKIES)).booleanValue()) {
            object<? extends JsonArray> cookies = data.ref().getJsonArray(new character(TAG_COOKIES));
            
            for (int i = 0; i < cookies.ref().getLength().intValue(); i++)
            {
               object<? extends JsonObject> cookie = cookies.ref().getJsonObject(new integer(i + 1));
               CookieJarEntry entry = new CookieJarEntry(Cookie.parse(cookie.ref().getCharacter(new character(TAG_COOKIE_DATA))), 
                        cookie.ref().getCharacter(new character(TAG_COOKIE_DOMAIN)).getValue(), 
                        cookie.ref().getCharacter(new character(TAG_COOKIE_PATH)).getValue(),
                        cookie.ref().getDatetimeTz(new character(TAG_COOKIE_CREATED)),
                        cookie.ref().getDatetimeTz(new character(TAG_COOKIE_ACCESS)));
               
               _addCookie(entry, persistentCookies.get());
            }
         }
         
      }));
   }

   /**
    * Set logger.
    * 
    * @param _logger The logger instance.
    */
   @LegacySignature(type = Type.SETTER, name = "Logger", parameters = 
   {
      @LegacyParameter(name = "logger", type = "OBJECT", mode = "INPUT", qualified = "OpenEdge.Logging.ILogWriter")
   })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   @Override
   public void setLogger(object<? extends IlogWriter> _logger)
   {
      object<? extends IlogWriter> logger = TypeFactory.initInput(_logger);

      internalProcedure(this, "Logger", new Block((Body) () -> {
         this.logger.assign(logger);
      }));

   }

   @LegacySignature(returns = "CHARACTER", type = Type.GETTER, name = "CookieJarPath")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public character getCookieJarPath()
   {
      return function(CookieJar.class, this, "CookieJarPath", character.class,
               new Block((Body) () -> {
                  returnNormal(cookieJarPath);
               }));
   }

   @LegacySignature(type = Type.SETTER, name = "CookieJarPath", parameters = {
            @LegacyParameter(name = "var", type = "CHARACTER", mode = "INPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void setCookieJarPath(final character _var)
   {
      character var = TypeFactory.initInput(_var);

      internalProcedure(CookieJar.class, this, "CookieJarPath", new Block((Body) () -> {
         cookieJarPath.assign(var);
      }));
   }

   @LegacySignature(type = Type.CONSTRUCTOR)
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void __net_http_CookieJar_constructor__()
   {
      internalProcedure(CookieJar.class, this, "__net_http_CookieJar_constructor__",
               new Block((Body) () -> {
                  __lang_BaseObject_constructor__();
                  cookieJarPath.assign(TextOps.substitute("&1cookies.json",
                           EnvironmentOps.getTempDirectory()));
                  logger.assign(ObjectOps.newInstance(VoidLogger.class));
               }));
   }

   @LegacySignature(type = Type.DESTRUCTOR)
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void __net_http_CookieJar_destructor__()
   {
      internalProcedure(CookieJar.class, this, "__net_http_CookieJar_destructor__",
               new Block((Body) () -> {
                  this.destroy();
               }));
   }

   @LegacySignature(type = Type.METHOD, name = "AddCookie", parameters = {
            @LegacyParameter(name = "pcDomain", type = "CHARACTER", mode = "INPUT"),
            @LegacyParameter(name = "pcPath", type = "CHARACTER", mode = "INPUT"),
            @LegacyParameter(name = "poCookie", type = "OBJECT", qualified = "openedge.net.http.cookie", mode = "INPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void addCookie(final character _pcDomain, final character _pcPath,
            final object<? extends com.goldencode.p2j.oo.net.http.Cookie> _poCookie)
   {
      character pcDomain = TypeFactory.initInput(_pcDomain);
      character pcPath = TypeFactory.initInput(_pcPath);
      object<? extends com.goldencode.p2j.oo.net.http.Cookie> poCookie = TypeFactory
               .initInput(_poCookie);

      internalProcedure(CookieJar.class, this, "AddCookie", new Block((Body) () -> {
         Assert.notNull(poCookie, new character("Cookie"));

         // no error thrown if domain doesn't match
         if (pcDomain.isUnknown()
                  || pcDomain.getValue().endsWith(poCookie.ref().getDomain().getValue()))
         {
            Assert.notNullOrEmpty(pcDomain, new character("Cookie Domain"));
            Assert.notNull(pcPath, new character("Cookie Path"));

            CookieJarEntry entry = new CookieJarEntry(poCookie, pcDomain.getValue(),
                     pcPath.getValue());

            // no expiration set, session cookie
            if (poCookie.ref().getExpiresAt().isUnknown())
            {
               _addCookie(entry, sessionCookies);
            }
            else
            {
               // don't save it if expired already
               if (!_isExpired(poCookie.ref()))
                  _addCookie(entry, persistentCookies.get());
            }
         } 
         else if (logger._isValid())
         {
            logger.ref().debug(TextOps.substitute("Bad domain &1 for &2", pcDomain, poCookie));
         }
      }));
   }

   @LegacySignature(type = Type.METHOD, name = "AddCookie", parameters = {
            @LegacyParameter(name = "poCookie", type = "OBJECT", qualified = "openedge.net.http.cookie", mode = "INPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void addCookie(final object<? extends com.goldencode.p2j.oo.net.http.Cookie> _poCookie)
   {
      object<? extends com.goldencode.p2j.oo.net.http.Cookie> poCookie = TypeFactory
               .initInput(_poCookie);

      internalProcedure(CookieJar.class, this, "AddCookie", new Block((Body) () -> {
         Assert.notNull(poCookie, new character("Cookie"));
         addCookie(poCookie.ref().getDomain(), poCookie.ref().getPath(), poCookie);
      }));
   }

   @LegacySignature(type = Type.METHOD, name = "AddCookies", parameters = {
            @LegacyParameter(name = "poCookies", type = "OBJECT", extent = -1, qualified = "openedge.net.http.cookie", mode = "INPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void addCookies(
            final object<? extends com.goldencode.p2j.oo.net.http.Cookie>[] _poCookies)
   {
      object<? extends com.goldencode.p2j.oo.net.http.Cookie>[] poCookies = TypeFactory
               .initInput(_poCookies);

      internalProcedure(CookieJar.class, this, "AddCookies", new Block((Body) () -> {
         Assert.notNull(poCookies, new character("Cookies"));

         for (int i = 0; i < poCookies.length; i++)
         {
            addCookie(poCookies[i]);
         }
      }));
   }

   @LegacySignature(type = Type.METHOD, name = "ClearPersistentCookies")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void clearPersistentCookies()
   {
      internalProcedure(CookieJar.class, this, "ClearPersistentCookies", new Block((Body) () -> {
         persistentCookies.get().clear();
         _persistCookies();
         
         if (logger._isValid())
            logger.ref().debug(TextOps.substitute("Cleared persistent cookies: &1", datetimetz.now()));
      }));
   }

   @LegacySignature(type = Type.METHOD, name = "ClearSessionCookies")
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public void clearSessionCookies()
   {
      internalProcedure(CookieJar.class, this, "ClearSessionCookies", new Block((Body) () -> {
         sessionCookies.clear();
         if (logger._isValid())
            logger.ref().debug(TextOps.substitute("Cleared session cookies: &1", datetimetz.now()));
      }));
   }

   @LegacySignature(type = Type.CONSTRUCTOR)
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public static void __net_http_CookieJar_constructor__static__()
   {
      externalProcedure(CookieJar.class, new Block((Body) () -> {
         onBlockLevel(Condition.ERROR, Action.THROW);
      }));
   }

   @LegacySignature(returns = "INTEGER", type = Type.METHOD, name = "GetCookies", parameters = {
            @LegacyParameter(name = "poUri", type = "OBJECT", qualified = "openedge.net.uri", mode = "INPUT"),
            @LegacyParameter(name = "poCookies", type = "OBJECT", extent = -1, qualified = "openedge.net.http.cookie", mode = "OUTPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public integer getCookies(final object<? extends com.goldencode.p2j.oo.net.Uri> _poUri,
            final OutputExtentParameter<object<? extends com.goldencode.p2j.oo.net.http.Cookie>> _extpoCookies)
   {
      object<? extends com.goldencode.p2j.oo.net.Uri> poUri = TypeFactory.initInput(_poUri);
      object[][] extpoCookies = { TypeFactory.initOutput(_extpoCookies) };

      return function(CookieJar.class, this, "GetCookies", integer.class, new Block((Body) () -> {
         String domain = poUri.ref().getHost().getValue().toLowerCase();
         String path = poUri.ref().getPath().getValue().toLowerCase();
         String scheme = poUri.ref().getScheme().getValue();

         boolean secure = "https".equalsIgnoreCase(scheme);
         boolean http = secure || "http".equalsIgnoreCase(scheme);

         ArrayList<object<? extends Cookie>> all = new ArrayList<object<? extends Cookie>>();

         // persistent first
         all.addAll(_getCookies(persistentCookies.get(), domain, path, http, secure));
         all.addAll(_getCookies(sessionCookies, domain, path, http, secure));
         
         if (all.size() > 0) {
            extpoCookies[0] = ArrayAssigner.resize(extpoCookies[0], all.size());
            
            for (int i = 0; i < all.size(); i++)
            {
               extpoCookies[0][i].assign(all.get(i));
            }
         }
         returnNormal(new integer(extpoCookies[0].length));
      }));
   }

   @LegacySignature(returns = "LOGICAL", type = Type.METHOD, name = "RemoveCookie", parameters = {
            @LegacyParameter(name = "poCookie", type = "OBJECT", qualified = "openedge.net.http.cookie", mode = "INPUT") })
   @LegacyResourceSupport(supportLvl = CVT_LVL_FULL | RT_LVL_FULL)
   public logical removeCookie(
            final object<? extends com.goldencode.p2j.oo.net.http.Cookie> _poCookie)
   {
      object<? extends com.goldencode.p2j.oo.net.http.Cookie> poCookie = TypeFactory
               .initInput(_poCookie);

      return function(CookieJar.class, this, "RemoveCookie", logical.class,
               new Block((Body) () -> {
                  Assert.notNull(poCookie, new character("Cookie"));

                  returnNormal(new logical(_removeCookie(poCookie.ref(), sessionCookies)
                           || _removeCookie(poCookie.ref(), persistentCookies.get())));
               }));
   }

   private boolean _removeCookie(Cookie cookie,
            List<CookieJarEntry> store)
   {

      Optional<CookieJarEntry> first = store.stream()
               .filter(e -> e.domain.equalsIgnoreCase(cookie.getDomain().getValue())
                        && e.path.equalsIgnoreCase(cookie.getPath().getValue())
                        && e.name.equalsIgnoreCase(cookie.getName().getValue()))
               .findFirst();

      if (first.isPresent()) {
         CookieJarEntry entry = first.get();
         if (store.remove(entry)) {
            if (entry.cookie._isValid())
               ObjectOps.decrement(entry.cookie.ref());
            return true;
         }
      }
      
      return false;
   }

   private void _addCookie(CookieJarEntry cookie, List<CookieJarEntry> store)
   {
      Optional<CookieJarEntry> find = store.stream()
               .filter(e -> e.domain.equalsIgnoreCase(cookie.domain)
                        && e.path.equalsIgnoreCase(cookie.path)
                        && e.name.equalsIgnoreCase(cookie.name))
               .findFirst();

      if (find.isPresent())
      {
         // already have a cookie with same name for domain/path pair
         CookieJarEntry entry = find.get();
         if (entry.cookie._isValid())
            ObjectOps.decrement(entry.cookie.ref());
         
         entry.cookie.assign(cookie.cookie);
      }
      else
      {
         store.add(cookie);
      }
      
      ObjectOps.increment(cookie.cookie.ref());
   }

   private boolean _isExpired(Cookie cookie)
   {

      return !cookie.getExpiresAt().isUnknown()
               && DateOps.compare(cookie.getExpiresAt(), datetimetz.now()) < 0;
   }

   private object<JsonObject> _serializeCookie(CookieJarEntry poCookie)
   {
      object<JsonObject> jsonObj = ObjectOps.newInstance(JsonObject.class);

      Cookie cookie = poCookie.cookie.ref();

      jsonObj.ref().add(new character(TAG_COOKIE_NAME), new character(poCookie.name));
      jsonObj.ref().add(new character(TAG_COOKIE_DOMAIN), new character(poCookie.domain));
      jsonObj.ref().add(new character(TAG_COOKIE_PATH), new character(poCookie.path));
      jsonObj.ref().add(new character(TAG_COOKIE_DATA), cookie.toLegacyString());
      jsonObj.ref().add(new character(TAG_COOKIE_CREATED), poCookie.created);
      jsonObj.ref().add(new character(TAG_COOKIE_ACCESS), poCookie.lastAccess);

      return jsonObj;
   }

   private Collection<object<? extends Cookie>> _getCookies(List<CookieJarEntry> store,
            String domain, String path, boolean http, boolean secure)
   {

      ArrayList<object<? extends Cookie>> cookies = new ArrayList<object<? extends Cookie>>();

      for (Iterator iterator = store.iterator(); iterator.hasNext();)
      {
         CookieJarEntry cookieJarEntry = (CookieJarEntry) iterator.next();

         // match domain and path
         if (path.startsWith(cookieJarEntry.path.toLowerCase())
                  && (cookieJarEntry.domain.equalsIgnoreCase(domain)
                           || (cookieJarEntry.domain.indexOf('.') == 0
                                    && domain.endsWith(cookieJarEntry.domain.toLowerCase()))))
         {
            Cookie cookie = cookieJarEntry.cookie.ref();
            // check expiration
            if (_isExpired(cookie))
            {
               // already expired, no point in keeping it around
               iterator.remove();
            }
            // check if http only/secure is set on cookie
            else if ((!cookie.getHttpOnly().booleanValue() || http)
                     && (!cookie.getSecure().booleanValue() || secure))
            {
               cookies.add(cookieJarEntry.cookie);
            }
         }
      }

      return cookies;
   }
   
   private void _persistCookies () {
      final object<JsonObject> jsonObj = ObjectOps.newInstance(JsonObject.class);
      final object<JsonArray> jsonArr = ObjectOps.newInstance(JsonArray.class);

      persistentCookies.get().stream()
               .filter(c -> c.cookie._isValid() && !_isExpired(c.cookie.ref()))
               .forEach(c -> jsonArr.ref().add_2(_serializeCookie(c)));

      jsonObj.ref().add(new character(TAG_COOKIES), jsonArr);

      jsonObj.ref().writeFile(cookieJarPath);
   }
}