diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05f969d --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.dub +docs.json +__dummy.html +docs/ +/sitestat +sitestat.so +sitestat.dylib +sitestat.dll +sitestat.a +sitestat.lib +sitestat-test-* +*.exe +*.pdb +*.o +*.obj +*.lst diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..1edbf0b --- /dev/null +++ b/dub.json @@ -0,0 +1,13 @@ +{ + "authors": [ + "Andrew Lalis" + ], + "copyright": "Copyright © 2023, Andrew Lalis", + "dependencies": { + "d-properties": "~>1.0.5", + "handy-httpd": "~>7.10.5" + }, + "description": "A simple webserver and script for tracking basic, non-intrusive site statistics.", + "license": "MIT", + "name": "sitestat" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..cac735b --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,10 @@ +{ + "fileVersion": 1, + "versions": { + "d-properties": "1.0.5", + "handy-httpd": "7.10.5", + "httparsed": "1.2.1", + "slf4d": "2.4.3", + "streams": "3.5.0" + } +} diff --git a/sitestat.js b/sitestat.js new file mode 100644 index 0000000..fe49536 --- /dev/null +++ b/sitestat.js @@ -0,0 +1,91 @@ +/* +sitestat.js is meant to be included in the like so: + + + +See test-site/index.html for an example. +*/ + +/** + * The global websocket singleton. It's initialized at the bottom of this script. + * @type {WebSocket | null} + */ +let WS = null; + +/** + * Sends some JSON object to sitestat. + * @param {object} data The data to send. + */ +function sendStat(data) { + if (WS.readyState === WebSocket.OPEN) { + WS.send(JSON.stringify(data)); + } else { + console.warn("Couldn't send data because websocket is not open: ", data); + } +} + +/** + * Handles any event encountered, and sends a small message to sitestat about it. + * @param {Event} event The event that occurred. + */ +function handleEvent(event) { + sendStat({ + type: "event", + event: event.type + }); +} + +/** + * Gets the remote URL that sitestat is running at, from the query params of + * the script's `src` attribute. Throws an error if such a URL could not be + * found. + * @returns {string} The remote URL to connect to. + */ +function getRemoteUrl() { + const scriptUrl = document.currentScript.src; + const paramsIdx = scriptUrl.indexOf("?"); + if (paramsIdx !== -1) { + const paramsStr = scriptUrl.substring(paramsIdx); + const params = new URLSearchParams(paramsStr); + const remoteUrl = params.get("remote-url"); + if (remoteUrl !== null) { + return remoteUrl; + } + } + throw new Error("Missing `remote-url=...` query parameter on script src attribute.") +} + + + +// The main script starts below: +if (window.navigator.webdriver) { + throw new Error("sitestat disabled for automated user agents."); +} +const remoteUrl = getRemoteUrl(); +WS = new WebSocket(`ws://${remoteUrl}/ws`); +WS.onopen = () => { + // As soon as the connection is established, send some basic information + // about the current browsing session. + console.info( + "📈 Established a connection to %csitestat%c for %cnon-intrusive, non-identifiable%csite analytics. Learn more here: https://github.com/andrewlalis/sitestat", + "font-weight: bold; font-style: italic; font-size: large; color: #32a852; background-color: #2e2e2e; padding: 5px;", + "", + "font-style: italic;" + ); + sendStat({ + type: "ident", + href: window.location.href, + userAgent: window.navigator.userAgent, + viewport: { + width: window.innerWidth, + height: window.innerHeight + } + }); +} +WS.onerror = console.error; + +// Register various event listeners. +const events = ["click", "keyup", "keydown", "scroll", "copy"]; +for (let i = 0; i < events.length; i++) { + document.addEventListener(events[i], handleEvent); +} diff --git a/source/app.d b/source/app.d new file mode 100644 index 0000000..e0a2c78 --- /dev/null +++ b/source/app.d @@ -0,0 +1,52 @@ +import handy_httpd; +import handy_httpd.handlers.path_delegating_handler; + +void main() { + import slf4d; + import slf4d.default_provider; + auto provider = new shared DefaultProvider(true, Levels.INFO); + // provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG); + configureLoggingProvider(provider); + + new HttpServer(prepareHandler(), prepareConfig()).start(); +} + +/** + * Prepares the main request handler for the server. + * Returns: The request handler. + */ +private HttpRequestHandler prepareHandler() { + import live_tracker; + PathDelegatingHandler pathHandler = new PathDelegatingHandler(); + + pathHandler.addMapping(Method.GET, "/ws", new WebSocketHandler(new LiveTracker())); + + return pathHandler; +} + +/** + * Prepares the server's configuration using sensible default values that can + * be overridded by a "sitestat.properties" file in the program's working dir. + * Returns: The config to use. + */ +private ServerConfig prepareConfig() { + import std.file; + import d_properties; + ServerConfig config = ServerConfig.defaultValues(); + config.workerPoolSize = 3; + config.port = 8081; + config.enableWebSockets = true; + if (exists("sitestat.properties")) { + Properties props = Properties("sitestat.properties"); + if (props.has("server.host")) { + config.hostname = props.get("host"); + } + if (props.has("server.port")) { + config.port = props.get!ushort("server.port"); + } + if (props.has("server.workers")) { + config.workerPoolSize = props.get!size_t("server.workers"); + } + } + return config; +} diff --git a/source/live_tracker.d b/source/live_tracker.d new file mode 100644 index 0000000..fe6bcf7 --- /dev/null +++ b/source/live_tracker.d @@ -0,0 +1,88 @@ +module live_tracker; + +import handy_httpd.components.websocket; +import slf4d; +import std.uuid; +import std.datetime; +import std.json; +import std.string; + +/** + * A websocket message handler that keeps track of each connected session, and + * records information about that session's activities. + */ +class LiveTracker : WebSocketMessageHandler { + private StatSession[UUID] sessions; + private immutable uint minSessionDurationMillis = 1000; + + override void onConnectionEstablished(WebSocketConnection conn) { + debugF!"Connection established: %s"(conn.getId()); + sessions[conn.getId()] = StatSession( + conn.getId(), + Clock.currTime(UTC()) + ); + } + + override void onTextMessage(WebSocketTextMessage msg) { + debugF!"Got message from %s: %s"(msg.conn.getId(), msg.payload); + StatSession* session = msg.conn.getId() in sessions; + if (session is null) { + warnF!"Got a websocket text message from a client without a session: %s"(msg.conn.getId()); + return; + } + JSONValue obj = parseJSON(msg.payload); + immutable string msgType = obj.object["type"].str; + if (msgType == MessageTypes.IDENT) { + string fullUrl = obj.object["href"].str; + session.href = fullUrl; + ptrdiff_t paramsIdx = std.string.indexOf(fullUrl, '?'); + if (paramsIdx == -1) { + session.url = fullUrl; + } else { + session.url = fullUrl[0 .. paramsIdx]; + } + session.userAgent = obj.object["userAgent"].str; + } else if (msgType == MessageTypes.EVENT) { + session.events ~= EventRecord( + Clock.currTime(UTC()), + obj.object["event"].str + ); + } + } + + override void onConnectionClosed(WebSocketConnection conn) { + debugF!"Connection closed: %s"(conn.getId()); + StatSession* session = conn.getId() in sessions; + if (session !is null && session.isValid) { + Duration dur = Clock.currTime(UTC()) - session.connectedAt; + if (dur.total!"msecs" >= minSessionDurationMillis) { + infoF!"Session lasted %d seconds, %d events."(dur.total!"seconds", session.events.length); + infoF!"%s, %s"(session.href, session.userAgent); + } + } + sessions.remove(conn.getId()); + } +} + +private enum MessageTypes : string { + IDENT = "ident", + EVENT = "event" +} + +struct StatSession { + UUID id; + SysTime connectedAt; + string url = null; + string href = null; + string userAgent = null; + EventRecord[] events; + + bool isValid() const { + return url !is null && href !is null && userAgent !is null; + } +} + +struct EventRecord { + SysTime timestamp; + string eventType; +} diff --git a/test-site/about.html b/test-site/about.html new file mode 100644 index 0000000..4813bab --- /dev/null +++ b/test-site/about.html @@ -0,0 +1,18 @@ + + + + + About this Site + + + + + +

About this Site

+ Back to index.html +

+ This is a page with information about this testing website. +

+ + + \ No newline at end of file diff --git a/test-site/index.html b/test-site/index.html new file mode 100644 index 0000000..3202f26 --- /dev/null +++ b/test-site/index.html @@ -0,0 +1,23 @@ + + + + + Sample Page + + + + + +

Testing Site

+ +

+ This is a testing site used for testing the various features of sitestat. +

+ + + + \ No newline at end of file