ExternalDataManager.java

  1. /*
  2.  * The coLAB project
  3.  * Copyright (C) 2022-2023 AlbaSim, MEI, HEIG-VD, HES-SO
  4.  *
  5.  * Licensed under the MIT License
  6.  */
  7. package ch.colabproject.colab.api.controller.document;

  8. import ch.colabproject.colab.api.controller.RequestManager;
  9. import ch.colabproject.colab.api.rest.document.bean.UrlMetadata;
  10. import java.io.ByteArrayOutputStream;
  11. import java.io.IOException;
  12. import java.net.URI;
  13. import java.net.URLDecoder;
  14. import java.nio.charset.StandardCharsets;
  15. import java.time.OffsetDateTime;
  16. import java.util.HashMap;
  17. import java.util.Iterator;
  18. import javax.cache.Cache;
  19. import javax.ejb.LocalBean;
  20. import javax.ejb.Stateless;
  21. import javax.inject.Inject;
  22. import org.apache.commons.lang3.StringUtils;
  23. import org.apache.hc.client5.http.classic.methods.HttpGet;
  24. import org.apache.hc.client5.http.impl.classic.HttpClients;
  25. import org.apache.hc.core5.http.Header;
  26. import org.apache.hc.core5.http.HttpEntity;
  27. import org.apache.hc.core5.net.URIBuilder;
  28. import org.jsoup.Jsoup;
  29. import org.jsoup.nodes.Document;
  30. import org.jsoup.select.Elements;
  31. import org.slf4j.Logger;
  32. import org.slf4j.LoggerFactory;

  33. /**
  34.  * To deal with external data
  35.  *
  36.  * @author maxence
  37.  */
  38. @Stateless
  39. @LocalBean
  40. public class ExternalDataManager {

  41.     /** duration an entry may stay in cache before being drop or refreshed */
  42.     private static final int CACHE_TTL_HOUR = 24;

  43.     /** Logger */
  44.     private static final Logger logger = LoggerFactory.getLogger(UrlMetadata.class);

  45.     /** Open graph title property */
  46.     private static final String OG_TITLE = "og:title";

  47.     /** Open graph title */
  48.     private static final String OG_URL = "og:url";

  49.     /** Open graph image */
  50.     private static final String OG_IMAGE = "og:image";

  51.     /**
  52.      * cache metadata to avoid spamming external services.
  53.      */
  54.     @Inject
  55.     private Cache<String, UrlMetadata> metadataCache;

  56.     /** get the baseUrl of the application */
  57.     @Inject
  58.     private RequestManager requestManager;

  59.     /**
  60.      * Read response entity as stream
  61.      *
  62.      * @param entity http entity to read
  63.      *
  64.      * @return the string
  65.      *
  66.      * @throws IOException if something went wrong
  67.      */
  68.     private static String getEntityAsString(HttpEntity entity) throws IOException {
  69.         if (entity != null) {
  70.             ByteArrayOutputStream baos = new ByteArrayOutputStream();
  71.             entity.writeTo(baos);
  72.             return baos.toString("UTF-8");
  73.         } else {
  74.             return "";
  75.         }
  76.     }

  77.     /**
  78.      * Is the given data outdated?
  79.      *
  80.      * @param data metadata to check
  81.      *
  82.      * @return true if data is outdated
  83.      */
  84.     private boolean isOutdated(UrlMetadata data) {
  85.         OffsetDateTime date = data.getDate();
  86.         if (date != null) {
  87.             OffsetDateTime endOfLife = date.plusHours(CACHE_TTL_HOUR);
  88.             if (endOfLife.isAfter(OffsetDateTime.now())) {
  89.                 return false;
  90.             }
  91.         }

  92.         return true;
  93.     }

  94.     /**
  95.      * Get cached Url metadata. if exists of build fresh
  96.      *
  97.      * @param url url to fetch metadata for
  98.      *
  99.      * @return url metadata
  100.      */
  101.     public UrlMetadata getUrlMetadata(String url) {
  102.         try {
  103.             UrlMetadata cached = metadataCache.get(url);
  104.             if (cached != null && !isOutdated(cached)) {
  105.                 logger.trace("Get {} from cache", url);
  106.                 return cached;
  107.             }
  108.         } catch (Throwable t) {
  109.             logger.trace("Failed to fetch {} from cache {}", url, t);
  110.             metadataCache.remove(url);
  111.         }
  112.         return this.refreshAndGetUrlMetadata(url);
  113.     }

  114.     /**
  115.      * Make sure url starts with a protocol
  116.      *
  117.      * @param url             to sanitize
  118.      * @param defaultProtocol default protocol to use. http is the default defaultProtocol
  119.      *
  120.      * @return url with protocol
  121.      */
  122.     private String sanitizeUrl(String rawUrl, String defaultProtocol) {
  123.         if (!rawUrl.matches("[a-z-A-Z0-9]*://.*")) {
  124.             // There is no protocol, add default one
  125.             if (StringUtils.isEmpty(defaultProtocol)) {
  126.                 return "http://" + rawUrl;
  127.             } else {
  128.                 return defaultProtocol + "://" + rawUrl;
  129.             }
  130.         }
  131.         return rawUrl;
  132.     }

  133.     /**
  134.      * Update cache with fresh metadata
  135.      *
  136.      * @param url url to fetch metadata for
  137.      *
  138.      * @return url metadata
  139.      */
  140.     public UrlMetadata refreshAndGetUrlMetadata(String url) {

  141.         UrlMetadata urlMetadata = new UrlMetadata();
  142.         urlMetadata.setBroken(true);
  143.         HashMap<String, String> metadata = new HashMap<>();
  144.         urlMetadata.setMetadata(metadata);

  145.         String decoded = URLDecoder.decode(url, StandardCharsets.UTF_8);

  146.         // hack: intercept loobpack link
  147.         String baseUrl = requestManager.getBaseUrl();
  148.         if (decoded.startsWith(baseUrl)) {
  149.             logger.trace("Loopback url intercepted");
  150.             urlMetadata.setBroken(false);
  151.             metadata.put(OG_IMAGE, baseUrl + "/favicon_128.png");
  152.             metadata.put(OG_URL, decoded);
  153.         } else {

  154.             logger.trace("Raw URL {}", url);
  155.             try (var client = HttpClients.createDefault()) {
  156.                 String sanitizedUrl = sanitizeUrl(url, null);

  157.                 URIBuilder uriBuilder = new URIBuilder(sanitizedUrl, StandardCharsets.UTF_8);

  158.                 URI uri = uriBuilder.normalizeSyntax().build();
  159.                 metadata.put(OG_URL, url);

  160.                 String[] segs = uri.getPath().split("/");
  161.                 if (segs != null && segs.length > 0) {
  162.                     // default og:name to last path segment
  163.                     String filename = segs[segs.length - 1];
  164.                     metadata.put(OG_TITLE, filename);
  165.                 } else {
  166.                     // otherwise, default to hostname
  167.                     metadata.put(OG_TITLE, uri.getHost());
  168.                 }

  169.                 var get = new HttpGet(uri);
  170.                 try (var response = client.execute(get)) {

  171.                     HttpEntity entity = response.getEntity();
  172.                     int statusCode = response.getCode();

  173.                     if (statusCode < 400) {
  174.                         // success
  175.                         urlMetadata.setBroken(false);

  176.                         Header firstHeader = response.getFirstHeader("content-type");
  177.                         String contentType = firstHeader.getValue();
  178.                         int separator = contentType.indexOf(';');

  179.                         if (separator > 0) {
  180.                             contentType = contentType.substring(0, separator);
  181.                         }

  182.                         if (contentType != null) {
  183.                             urlMetadata.setContentType(contentType);
  184.                             if (contentType.equals("text/html")) {
  185.                                 // try to fetch metadata in head meta tags
  186.                                 String html = getEntityAsString(entity);
  187.                                 Document htmlDocument = Jsoup.parse(html, url);
  188.                                 Elements metas = htmlDocument.head().select("meta");
  189.                                 metas.forEach(meta -> {
  190.                                     String prop = meta.attr("property");
  191.                                     String name = meta.attr("name");
  192.                                     if (prop != null && prop.indexOf(':') >= 0
  193.                                         || name != null && name.indexOf(':') >= 0) {
  194.                                         metadata.put(prop, meta.attr("content"));
  195.                                     }
  196.                                 });
  197.                             }
  198.                         }
  199.                     }

  200.                 }
  201.             } catch (Exception e) {
  202.                 logger.debug("Major Failure", e);
  203.                 urlMetadata.setBroken(true);
  204.             }
  205.         }
  206.         urlMetadata.setDate(OffsetDateTime.now());
  207.         // cache metadata
  208.         metadataCache.put(url, urlMetadata);
  209.         return urlMetadata;
  210.     }

  211.     /**
  212.      * Drop outdated entries from cache
  213.      */
  214.     public void clearOutdated() {
  215.         Iterator<Cache.Entry<String, UrlMetadata>> iterator = metadataCache.iterator();
  216.         while (iterator.hasNext()) {
  217.             Cache.Entry<String, UrlMetadata> entry = iterator.next();
  218.             UrlMetadata data = entry.getValue();
  219.             if (isOutdated(data)) {
  220.                 iterator.remove();
  221.             }
  222.         }
  223.     }

  224. }