First implementation, after fixing tons of handy-http bugs

This commit is contained in:
Andrew Lalis 2023-10-03 15:51:30 -04:00
parent 386b1283bf
commit 1175f7f891
8 changed files with 311 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -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

13
dub.json Normal file
View File

@ -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"
}

10
dub.selections.json Normal file
View File

@ -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"
}
}

91
sitestat.js Normal file
View File

@ -0,0 +1,91 @@
/*
sitestat.js is meant to be included in the <head> like so:
<script src="path/to/sitestat.js" async></script>
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);
}

52
source/app.d Normal file
View File

@ -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;
}

88
source/live_tracker.d Normal file
View File

@ -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;
}

18
test-site/about.html Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>About this Site</title>
<script src="../sitestat.js?remote-url=localhost:8081" async></script>
</head>
<body>
<h1>About this Site</h1>
<a href="index.html">Back to index.html</a>
<p>
This is a page with information about this testing website.
</p>
</body>
</html>

23
test-site/index.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Sample Page</title>
<script src="../sitestat.js?remote-url=localhost:8081" async></script>
</head>
<body>
<h1>Testing Site</h1>
<nav>
<ul>
<li><a href="about.html">About</a></li>
</ul>
</nav>
<p>
This is a testing site used for testing the various features of <em>sitestat</em>.
</p>
<button id="test-button">Click me!</button>
</body>
</html>