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