From 4aa4dfdcafb05fcb176fd5282334e954e8d766f8 Mon Sep 17 00:00:00 2001
From: Uku Taht <Uku.taht@gmail.com>
Date: Tue, 25 Aug 2020 10:56:36 +0300
Subject: [PATCH] Hash mode (#299)

* Build tracker with hash mode

* Extract hash fragment in hash mode

* Serve new hash-based tracker
---
 .../controllers/api/external_controller.ex    | 13 +++++++-
 .../controllers/tracker_controller.ex         | 11 +++++++
 lib/plausible_web/router.ex                   |  1 +
 priv/tracker/js/plausible.hash.js             |  1 +
 .../api/external_controller_test.exs          | 17 ++++++++++
 tracker/compile.js                            |  8 +++--
 tracker/package-lock.json                     | 32 +++++++++++++++++++
 tracker/package.json                          |  1 +
 tracker/src/plausible.js                      | 12 +++++--
 9 files changed, 90 insertions(+), 6 deletions(-)
 create mode 100644 priv/tracker/js/plausible.hash.js

diff --git a/lib/plausible_web/controllers/api/external_controller.ex b/lib/plausible_web/controllers/api/external_controller.ex
index d5d253fe..9c6b870d 100644
--- a/lib/plausible_web/controllers/api/external_controller.ex
+++ b/lib/plausible_web/controllers/api/external_controller.ex
@@ -61,6 +61,7 @@ defmodule PlausibleWeb.Api.ExternalController do
       "referrer" => params["r"] || params["referrer"],
       "domain" => params["d"] || params["domain"],
       "screen_width" => params["w"] || params["screen_width"],
+      "hash_mode" => params["h"] || params["hashMode"],
     }
 
     uri = params["url"] && URI.parse(URI.decode(params["url"]))
@@ -83,7 +84,7 @@ defmodule PlausibleWeb.Api.ExternalController do
         name: params["name"],
         hostname: strip_www(uri && uri.host),
         domain: strip_www(params["domain"]) || strip_www(uri && uri.host),
-        pathname: uri && (uri.path || "/"),
+        pathname: get_pathname(uri, params["hash_mode"]),
         user_id: generate_user_id(conn, params, salts[:current]),
         country_code: country_code,
         operating_system: ua && os_name(ua),
@@ -108,6 +109,16 @@ defmodule PlausibleWeb.Api.ExternalController do
     end
   end
 
+  defp get_pathname(nil, _), do: "/"
+  defp get_pathname(uri, hash_mode) do
+    pathname = uri.path || "/"
+    if hash_mode && uri.fragment do
+      pathname <> "#" <> uri.fragment
+    else
+      pathname
+    end
+  end
+
   defp visitor_country(conn) do
     result =
       PlausibleWeb.RemoteIp.get(conn)
diff --git a/lib/plausible_web/controllers/tracker_controller.ex b/lib/plausible_web/controllers/tracker_controller.ex
index 50e3349c..c6464cdb 100644
--- a/lib/plausible_web/controllers/tracker_controller.ex
+++ b/lib/plausible_web/controllers/tracker_controller.ex
@@ -9,6 +9,13 @@ defmodule PlausibleWeb.TrackerController do
     [:base_url]
   )
 
+  EEx.function_from_file(
+    :defp,
+    :render_plausible_hash,
+    Application.app_dir(:plausible, "priv/tracker/js/plausible.hash.js"),
+    [:base_url]
+  )
+
   EEx.function_from_file(
     :defp,
     :render_p,
@@ -23,6 +30,10 @@ defmodule PlausibleWeb.TrackerController do
     send_js(conn, render_plausible(base_url()))
   end
 
+  def plausible_hash(conn, _params) do
+    send_js(conn, render_plausible_hash(base_url()))
+  end
+
   def analytics(conn, _params) do
     send_js(conn, render_plausible(base_url()))
   end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index e3c52125..3901cc98 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -37,6 +37,7 @@ defmodule PlausibleWeb.Router do
   end
 
   get "/js/plausible.js", PlausibleWeb.TrackerController, :plausible
+  get "/js/plausible.hash.js", PlausibleWeb.TrackerController, :plausible_hash
   get "/js/analytics.js", PlausibleWeb.TrackerController, :plausible
   get "/js/p.js", PlausibleWeb.TrackerController, :p
 
diff --git a/priv/tracker/js/plausible.hash.js b/priv/tracker/js/plausible.hash.js
new file mode 100644
index 00000000..34e57c83
--- /dev/null
+++ b/priv/tracker/js/plausible.hash.js
@@ -0,0 +1 @@
+!function(r,i){"use strict";var o=r.location,s=r.document,e=s.querySelector('[src*="'+i+'"]'),l={domain:e&&e.getAttribute("data-domain")||o.hostname};function c(e){console.warn("[Plausible] Ignore event: "+e)}function t(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return c("running locally");if("prerender"===s.visibilityState)return c("prerendering");var n={};n.n=e,n.u=o.href,n.d=l.domain,n.r=s.referrer||null,n.w=r.innerWidth,n.h=1;var a=new XMLHttpRequest;a.open("POST",i+"/api/event",!0),a.setRequestHeader("Content-Type","text/plain"),a.send(JSON.stringify(n)),a.onreadystatechange=function(){4==a.readyState&&t&&t.callback&&t.callback()}}function n(){t("pageview")}try{var a,u=r.history;u.pushState&&(a=u.pushState,u.pushState=function(){a.apply(this,arguments),n()},r.addEventListener("popstate",n)),r.addEventListener("hashchange",n);var p=r.plausible&&r.plausible.q||[];r.plausible=t;for(var d=0;d<p.length;d++)t.apply(this,p[d]);n()}catch(e){(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"<%= base_url %>");
\ No newline at end of file
diff --git a/test/plausible_web/controllers/api/external_controller_test.exs b/test/plausible_web/controllers/api/external_controller_test.exs
index 0e438399..78614332 100644
--- a/test/plausible_web/controllers/api/external_controller_test.exs
+++ b/test/plausible_web/controllers/api/external_controller_test.exs
@@ -434,6 +434,23 @@ defmodule PlausibleWeb.Api.ExternalControllerTest do
     assert pageview["screen_size"] == "Mobile"
   end
 
+  test "records hash when in hash mode", %{conn: conn} do
+    params = %{
+      n: "pageview",
+      u: "http://www.example.com/#page-a",
+      d: "external-controller-test-23.com",
+      h: 1
+    }
+
+    conn
+    |> put_req_header("content-type", "text/plain")
+    |> post("/api/event", Jason.encode!(params))
+
+    pageview = get_event("external-controller-test-23.com")
+
+    assert pageview["pathname"] == "/#page-a"
+  end
+
   test "responds 400 when required fields are missing", %{conn: conn} do
     params = %{}
 
diff --git a/tracker/compile.js b/tracker/compile.js
index 9ec48562..dbb7b4fe 100644
--- a/tracker/compile.js
+++ b/tracker/compile.js
@@ -1,17 +1,21 @@
 const uglify = require("uglify-js");
 const fs = require('fs')
 const path = require('path')
+const Handlebars = require("handlebars");
 
 function relPath(segment) {
   return path.join(__dirname, segment)
 }
 
-function compilefile(input, output) {
+function compilefile(input, output, templateVars = {}) {
   const code = fs.readFileSync(input).toString()
-  const result = uglify.minify(code)
+  const template = Handlebars.compile(code)
+  const rendered = template(templateVars)
+  const result = uglify.minify(rendered)
   fs.writeFileSync(output, result.code)
 }
 
 compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.js'))
+compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.hash.js'), {hashMode: true})
 compilefile(relPath('src/p.js'), relPath('../priv/tracker/js/p.js'))
 fs.copyFileSync(relPath('../priv/tracker/js/plausible.js'), relPath('../priv/tracker/js/analytics.js'))
diff --git a/tracker/package-lock.json b/tracker/package-lock.json
index ff852c51..d4d1b5ae 100644
--- a/tracker/package-lock.json
+++ b/tracker/package-lock.json
@@ -7,6 +7,33 @@
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
     },
+    "handlebars": {
+      "version": "4.7.6",
+      "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz",
+      "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==",
+      "requires": {
+        "minimist": "^1.2.5",
+        "neo-async": "^2.6.0",
+        "source-map": "^0.6.1",
+        "uglify-js": "^3.1.4",
+        "wordwrap": "^1.0.0"
+      }
+    },
+    "minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "neo-async": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+    },
     "uglify-js": {
       "version": "3.9.4",
       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.9.4.tgz",
@@ -14,6 +41,11 @@
       "requires": {
         "commander": "~2.20.3"
       }
+    },
+    "wordwrap": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+      "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus="
     }
   }
 }
diff --git a/tracker/package.json b/tracker/package.json
index 93e1c621..b616320e 100644
--- a/tracker/package.json
+++ b/tracker/package.json
@@ -4,6 +4,7 @@
   },
   "license": "MIT",
   "dependencies": {
+    "handlebars": "^4.7.6",
     "uglify-js": "^3.9.4"
   }
 }
diff --git a/tracker/src/plausible.js b/tracker/src/plausible.js
index b6def331..dcbbb46f 100644
--- a/tracker/src/plausible.js
+++ b/tracker/src/plausible.js
@@ -5,8 +5,7 @@
   var document = window.document
 
   var scriptEl = document.querySelector('[src*="' + plausibleHost +'"]')
-  var domainAttr = scriptEl && scriptEl.getAttribute('data-domain')
-  var CONFIG = {domain: domainAttr || location.hostname}
+  var domain = scriptEl && scriptEl.getAttribute('data-domain')
 
   function ignore(reason) {
     console.warn('[Plausible] Ignore event: ' + reason);
@@ -19,9 +18,12 @@
     var payload = {}
     payload.n = eventName
     payload.u = location.href
-    payload.d = CONFIG['domain']
+    payload.d = domain
     payload.r = document.referrer || null
     payload.w = window.innerWidth
+    {{#if hashMode}}
+    payload.h = 1
+    {{/if}}
 
     var request = new XMLHttpRequest();
     request.open('POST', plausibleHost + '/api/event', true);
@@ -51,6 +53,10 @@
       window.addEventListener('popstate', page)
     }
 
+    {{#if hashMode}}
+    window.addEventListener('hashchange', page)
+    {{/if}}
+
     var queue = (window.plausible && window.plausible.q) || []
     window.plausible = trigger
     for (var i = 0; i < queue.length; i++) {
-- 
GitLab