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