JAL-4117 JAL-1551 source licensing
[jalview.git] / src / jalview / analytics / Plausible.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.analytics;
22
23 import java.io.BufferedReader;
24 import java.io.IOException;
25 import java.io.InputStreamReader;
26 import java.io.OutputStream;
27 import java.io.UnsupportedEncodingException;
28 import java.lang.invoke.MethodHandles;
29 import java.net.HttpURLConnection;
30 import java.net.MalformedURLException;
31 import java.net.URL;
32 import java.net.URLConnection;
33 import java.net.URLEncoder;
34 import java.nio.charset.StandardCharsets;
35 import java.util.AbstractMap;
36 import java.util.ArrayList;
37 import java.util.Collections;
38 import java.util.HashMap;
39 import java.util.Iterator;
40 import java.util.List;
41 import java.util.Map;
42 import java.util.Random;
43
44 import jalview.bin.Cache;
45 import jalview.bin.Console;
46 import jalview.util.ChannelProperties;
47 import jalview.util.HttpUtils;
48
49 public class Plausible
50 {
51   private static final String USER_AGENT;
52
53   private static final String JALVIEW_ID = "Jalview Desktop";
54
55   private static final String DOMAIN = "jalview.org";
56
57   private static final String CONFIG_API_BASE_URL = "https://www.jalview.org/services/config/analytics/url";
58
59   private static final String DEFAULT_API_BASE_URL = "https://analytics.jalview.org/api/event";
60
61   private static final String API_BASE_URL;
62
63   private static final String clientId;
64
65   public static final String APPLICATION_BASE_URL = "desktop://localhost";
66
67   private List<Map.Entry<String, String>> queryStringValues;
68
69   private List<Map.Entry<String, Object>> jsonObject;
70
71   private List<Map.Entry<String, String>> cookieValues;
72
73   private static boolean ENABLED = false;
74
75   private static boolean DEBUG = true;
76
77   private static Plausible instance = null;
78
79   private static final Map<String, String> defaultProps;
80
81   static
82   {
83     defaultProps = new HashMap<>();
84     defaultProps.put("app_name",
85             ChannelProperties.getProperty("app_name") + " Desktop");
86     defaultProps.put("version", Cache.getProperty("VERSION"));
87     defaultProps.put("build_date",
88             Cache.getDefault("BUILD_DATE", "unknown"));
89     defaultProps.put("java_version", System.getProperty("java.version"));
90     String val = System.getProperty("sys.install4jVersion");
91     if (val != null)
92     {
93       defaultProps.put("install4j_version", val);
94     }
95     val = System.getProperty("installer_template_version");
96     if (val != null)
97     {
98       defaultProps.put("install4j_template_version", val);
99     }
100     val = System.getProperty("launcher_version");
101     if (val != null)
102     {
103       defaultProps.put("launcher_version", val);
104     }
105     defaultProps.put("java_arch",
106             System.getProperty("os.arch") + " "
107                     + System.getProperty("os.name") + " "
108                     + System.getProperty("os.version"));
109     defaultProps.put("os", System.getProperty("os.name"));
110     defaultProps.put("os_version", System.getProperty("os.version"));
111     defaultProps.put("os_arch", System.getProperty("os.arch"));
112     String installation = Cache.applicationProperties
113             .getProperty("INSTALLATION");
114     if (installation != null)
115     {
116       defaultProps.put("installation", installation);
117     }
118
119     // ascertain the API_BASE_URL
120     API_BASE_URL = getAPIBaseURL();
121
122     // random clientId to make User-Agent unique (to register analytic)
123     clientId = String.format("%08x", new Random().nextInt());
124
125     USER_AGENT = HttpUtils.getUserAgent(
126             MethodHandles.lookup().lookupClass().getCanonicalName() + " "
127                     + clientId);
128   }
129
130   private Plausible()
131   {
132     this.resetLists();
133   }
134
135   public static void setEnabled(boolean b)
136   {
137     ENABLED = b;
138   }
139
140   public void sendEvent(String eventName, String urlString,
141           String... propsStrings)
142   {
143     sendEvent(eventName, urlString, false, propsStrings);
144   }
145
146   /**
147    * The simplest way to send an analytic event.
148    * 
149    * @param eventName
150    *          The event name. To emulate a webpage view use "pageview" and set a
151    *          "url" key/value. See https://plausible.io/docs/events-api
152    * @param sendDefaultProps
153    *          Flag whether to add the default props about the application.
154    * @param propsStrings
155    *          Optional multiple Strings in key, value pairs (there should be an
156    *          even number of propsStrings) to be set as property of the event.
157    *          To emulate a webpage view set "url" as the URL in a "pageview"
158    *          event.
159    */
160   public void sendEvent(String eventName, String urlString,
161           boolean sendDefaultProps, String... propsStrings)
162   {
163     // clear out old lists
164     this.resetLists();
165
166     if (!ENABLED)
167     {
168       Console.debug("Plausible not enabled.");
169       return;
170     }
171     Map<String, String> props = new HashMap<>();
172
173     // add these to all events from this application instance
174     if (sendDefaultProps)
175     {
176       props.putAll(defaultProps);
177     }
178
179     // add (and overwrite with) the passed in props
180     if (propsStrings != null && propsStrings.length > 0)
181     {
182       if (propsStrings.length % 2 != 0)
183       {
184         Console.warn(
185                 "Cannot addEvent with odd number of propsStrings.  Ignoring the last one.");
186       }
187       for (int i = 0; i < propsStrings.length - 1; i += 2)
188       {
189         String key = propsStrings[i];
190         String value = propsStrings[i + 1];
191         props.put(key, value);
192       }
193     }
194
195     addJsonValue("domain", DOMAIN);
196     addJsonValue("name", eventName);
197     StringBuilder eventUrlSb = new StringBuilder(APPLICATION_BASE_URL);
198     if (!APPLICATION_BASE_URL.endsWith("/") && !urlString.startsWith("/"))
199     {
200       eventUrlSb.append("/");
201     }
202     eventUrlSb.append(urlString);
203     addJsonValue("url", eventUrlSb.toString());
204     addJsonObject("props", props);
205     StringBuilder urlSb = new StringBuilder();
206     urlSb.append(API_BASE_URL);
207     String qs = buildQueryString();
208     if (qs != null && qs.length() > 0)
209     {
210       urlSb.append('?');
211       urlSb.append(qs);
212     }
213     try
214     {
215       URL url = new URL(urlSb.toString());
216       URLConnection urlConnection = url.openConnection();
217       HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection;
218       httpURLConnection.setRequestMethod("POST");
219       httpURLConnection.setDoOutput(true);
220
221       String jsonString = buildJson();
222
223       Console.debug(
224               "Plausible: HTTP Request is: '" + urlSb.toString() + "'");
225       if (DEBUG)
226       {
227         Console.debug("Plausible: User-Agent is: '" + USER_AGENT + "'");
228       }
229       Console.debug("Plausible: POSTed JSON is:\n" + jsonString);
230
231       byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
232       int jsonLength = jsonBytes.length;
233
234       httpURLConnection.setFixedLengthStreamingMode(jsonLength);
235       httpURLConnection.setRequestProperty("Content-Type",
236               "application/json");
237       httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
238       httpURLConnection.connect();
239       try (OutputStream os = httpURLConnection.getOutputStream())
240       {
241         os.write(jsonBytes);
242       }
243       int responseCode = httpURLConnection.getResponseCode();
244       String responseMessage = httpURLConnection.getResponseMessage();
245
246       if (responseCode < 200 || responseCode > 299)
247       {
248         Console.warn("Plausible connection failed: '" + responseCode + " "
249                 + responseMessage + "'");
250       }
251       else
252       {
253         Console.debug("Plausible connection succeeded: '" + responseCode
254                 + " " + responseMessage + "'");
255       }
256
257       if (DEBUG)
258       {
259         BufferedReader br = new BufferedReader(new InputStreamReader(
260                 (httpURLConnection.getInputStream())));
261         StringBuilder sb = new StringBuilder();
262         String response;
263         while ((response = br.readLine()) != null)
264         {
265           sb.append(response);
266         }
267         String body = sb.toString();
268         Console.debug("Plausible response content:\n" + body);
269       }
270     } catch (MalformedURLException e)
271     {
272       Console.debug(
273               "Somehow the Plausible BASE_URL and queryString is malformed: '"
274                       + urlSb.toString() + "'",
275               e);
276       return;
277     } catch (IOException e)
278     {
279       Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
280               + "' failed.", e);
281     } catch (ClassCastException e)
282     {
283       Console.debug(
284               "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
285               e);
286     }
287   }
288
289   private void addJsonObject(String key, Map<String, String> map)
290   {
291     List<Map.Entry<String, ? extends Object>> list = new ArrayList<>();
292     for (String k : map.keySet())
293     {
294       list.add(stringEntry(k, map.get(k)));
295     }
296     addJsonObject(key, list);
297
298   }
299
300   private void addJsonObject(String key,
301           List<Map.Entry<String, ? extends Object>> object)
302   {
303     jsonObject.add(objectEntry(key, object));
304   }
305
306   private void addJsonValues(String key, List<Object> values)
307   {
308     jsonObject.add(objectEntry(key, values));
309   }
310
311   private void addJsonValue(String key, String value)
312   {
313     jsonObject.add(objectEntry(key, value));
314   }
315
316   private void addJsonValue(String key, int value)
317   {
318     jsonObject.add(objectEntry(key, Integer.valueOf(value)));
319   }
320
321   private void addJsonValue(String key, boolean value)
322   {
323     jsonObject.add(objectEntry(key, Boolean.valueOf(value)));
324   }
325
326   private void addQueryStringValue(String key, String value)
327   {
328     queryStringValues.add(stringEntry(key, value));
329   }
330
331   private void addCookieValue(String key, String value)
332   {
333     cookieValues.add(stringEntry(key, value));
334   }
335
336   private void resetLists()
337   {
338     jsonObject = new ArrayList<>();
339     queryStringValues = new ArrayList<>();
340     cookieValues = new ArrayList<>();
341   }
342
343   public static Plausible getInstance()
344   {
345     if (instance == null)
346     {
347       instance = new Plausible();
348     }
349     return instance;
350   }
351
352   public static void reset()
353   {
354     getInstance().resetLists();
355   }
356
357   private String buildQueryString()
358   {
359     StringBuilder sb = new StringBuilder();
360     for (Map.Entry<String, String> entry : queryStringValues)
361     {
362       if (sb.length() > 0)
363       {
364         sb.append('&');
365       }
366       try
367       {
368         sb.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
369       } catch (UnsupportedEncodingException e)
370       {
371         sb.append(entry.getKey());
372       }
373       sb.append('=');
374       try
375       {
376         sb.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
377       } catch (UnsupportedEncodingException e)
378       {
379         sb.append(entry.getValue());
380       }
381     }
382     return sb.toString();
383   }
384
385   private void buildCookieHeaders()
386   {
387     // TODO not needed yet
388   }
389
390   private String buildJson()
391   {
392     StringBuilder sb = new StringBuilder();
393     addJsonObject(sb, 0, jsonObject);
394     return sb.toString();
395   }
396
397   private void addJsonObject(StringBuilder sb, int indent,
398           List<Map.Entry<String, Object>> entries)
399   {
400     indent(sb, indent);
401     sb.append('{');
402     newline(sb);
403     Iterator<Map.Entry<String, Object>> entriesI = entries.iterator();
404     while (entriesI.hasNext())
405     {
406       Map.Entry<String, Object> entry = entriesI.next();
407       String key = entry.getKey();
408       // TODO sensibly escape " characters in key
409       Object value = entry.getValue();
410       indent(sb, indent + 1);
411       sb.append('"').append(quoteEscape(key)).append('"').append(':');
412       space(sb);
413       if (value != null && value instanceof List)
414       {
415         newline(sb);
416       }
417       addJsonValue(sb, indent + 2, value);
418       if (entriesI.hasNext())
419       {
420         sb.append(',');
421       }
422       newline(sb);
423     }
424     indent(sb, indent);
425     sb.append('}');
426   }
427
428   private void addJsonValue(StringBuilder sb, int indent, Object value)
429   {
430     if (value == null)
431     {
432       return;
433     }
434     try
435     {
436       if (value instanceof Map.Entry)
437       {
438         Map.Entry<String, Object> entry = (Map.Entry<String, Object>) value;
439         List<Map.Entry<String, Object>> object = new ArrayList<>();
440         object.add(entry);
441         addJsonObject(sb, indent, object);
442       }
443       else if (value instanceof List)
444       {
445         // list of Map.Entries or list of values?
446         List<Object> valueList = (List<Object>) value;
447         if (valueList.size() > 0 && valueList.get(0) instanceof Map.Entry)
448         {
449           // entries
450           // indent(sb, indent);
451           List<Map.Entry<String, Object>> entryList = (List<Map.Entry<String, Object>>) value;
452           addJsonObject(sb, indent, entryList);
453         }
454         else
455         {
456           // values
457           indent(sb, indent);
458           sb.append('[');
459           newline(sb);
460           Iterator<Object> valueListI = valueList.iterator();
461           while (valueListI.hasNext())
462           {
463             Object v = valueListI.next();
464             addJsonValue(sb, indent + 1, v);
465             if (valueListI.hasNext())
466             {
467               sb.append(',');
468             }
469             newline(sb);
470           }
471           indent(sb, indent);
472           sb.append("]");
473         }
474       }
475       else if (value instanceof String)
476       {
477         sb.append('"').append(quoteEscape((String) value)).append('"');
478       }
479       else if (value instanceof Integer)
480       {
481         sb.append(((Integer) value).toString());
482       }
483       else if (value instanceof Boolean)
484       {
485         sb.append('"').append(((Boolean) value).toString()).append('"');
486       }
487     } catch (ClassCastException e)
488     {
489       Console.debug(
490               "Could not deal with type of json Object " + value.toString(),
491               e);
492     }
493   }
494
495   private static String quoteEscape(String s)
496   {
497     if (s == null)
498     {
499       return null;
500     }
501     // this escapes quotation marks (") that aren't already escaped (in the
502     // string) ready to go into a quoted JSON string value
503     return s.replaceAll("((?<!\\\\)(?:\\\\{2})*)\"", "$1\\\\\"");
504   }
505
506   private static void prettyWhitespace(StringBuilder sb, String whitespace,
507           int repeat)
508   {
509     // only add whitespace if we're in DEBUG mode
510     if (!DEBUG)
511     {
512       return;
513     }
514     if (repeat >= 0 && whitespace != null)
515     {
516       // sb.append(whitespace.repeat(repeat));
517       sb.append(String.join("", Collections.nCopies(repeat, whitespace)));
518
519     }
520     else
521     {
522       sb.append(whitespace);
523     }
524   }
525
526   private static void indent(StringBuilder sb, int indent)
527   {
528     prettyWhitespace(sb, "  ", indent);
529   }
530
531   private static void newline(StringBuilder sb)
532   {
533     prettyWhitespace(sb, "\n", -1);
534   }
535
536   private static void space(StringBuilder sb)
537   {
538     prettyWhitespace(sb, " ", -1);
539   }
540
541   protected static Map.Entry<String, Object> objectEntry(String s, Object o)
542   {
543     return new AbstractMap.SimpleEntry<String, Object>(s, o);
544   }
545
546   protected static Map.Entry<String, String> stringEntry(String s, String v)
547   {
548     return new AbstractMap.SimpleEntry<String, String>(s, v);
549   }
550
551   private static String getAPIBaseURL()
552   {
553     try
554     {
555       URL url = new URL(CONFIG_API_BASE_URL);
556       URLConnection urlConnection = url.openConnection();
557       HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection;
558       httpURLConnection.setRequestMethod("GET");
559       httpURLConnection.setRequestProperty("User-Agent", USER_AGENT);
560       httpURLConnection.setConnectTimeout(5000);
561       httpURLConnection.setReadTimeout(3000);
562       httpURLConnection.connect();
563       int responseCode = httpURLConnection.getResponseCode();
564       String responseMessage = httpURLConnection.getResponseMessage();
565
566       if (responseCode < 200 || responseCode > 299)
567       {
568         Console.warn("Config URL connection to '" + CONFIG_API_BASE_URL
569                 + "' failed: '" + responseCode + " " + responseMessage
570                 + "'");
571       }
572
573       BufferedReader br = new BufferedReader(
574               new InputStreamReader((httpURLConnection.getInputStream())));
575       StringBuilder sb = new StringBuilder();
576       String response;
577       while ((response = br.readLine()) != null)
578       {
579         sb.append(response);
580       }
581       if (sb.length() > 7 && sb.substring(0, 5).equals("https"))
582       {
583         return sb.toString();
584       }
585
586     } catch (MalformedURLException e)
587     {
588       Console.debug("Somehow the config URL is malformed: '"
589               + CONFIG_API_BASE_URL + "'", e);
590     } catch (IOException e)
591     {
592       Console.debug("Connection to Plausible BASE_URL '" + API_BASE_URL
593               + "' failed.", e);
594     } catch (ClassCastException e)
595     {
596       Console.debug(
597               "Couldn't cast URLConnection to HttpURLConnection in Plausible.",
598               e);
599     }
600     return DEFAULT_API_BASE_URL;
601   }
602 }