updated readme, improved implementation.

This commit is contained in:
Andrew Lalis 2023-10-04 13:30:00 -04:00
parent 1175f7f891
commit aabec581b2
11 changed files with 386 additions and 111 deletions

2
.gitignore vendored
View File

@ -14,3 +14,5 @@ sitestat-test-*
*.o *.o
*.obj *.obj
*.lst *.lst
*.sqlite

View File

@ -1,2 +1,15 @@
# sitestat # sitestat
A simple webserver and script for tracking basic, non-intrusive site statistics. A simple webserver and script for tracking basic, non-intrusive site statistics using a websocket.
Simply run sitestat on your server, and add `<script src="path/to/sitestat.js" async></script>` to the `<head>` of any page you'd like to track statistics on.
It will record some basic information about each user's interaction session on each page, and save those sessions into an SQLite3 database for later analysis.
## What Information is Collected?
Right now, the following information is collected from each user's session on a page monitored by sitestat:
- The time they opened the page.
- The time they closed the page (telling us how long they viewed the page).
- Their user agent (browser name and associated details).
- The exact URL they visited.
- Certain anonymized actions done by the user on the page, like mouse clicks, button presses, copy-to-clipboard, etc. From any action, we simply save the name of the action, and no other information.

View File

@ -5,9 +5,13 @@
"copyright": "Copyright © 2023, Andrew Lalis", "copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": { "dependencies": {
"d-properties": "~>1.0.5", "d-properties": "~>1.0.5",
"handy-httpd": "~>7.10.5" "handy-httpd": "~>7.11.0",
"d2sqlite3": "~>1.0.0"
}, },
"description": "A simple webserver and script for tracking basic, non-intrusive site statistics.", "description": "A simple webserver and script for tracking basic, non-intrusive site statistics.",
"license": "MIT", "license": "MIT",
"name": "sitestat" "name": "sitestat",
"subConfigurations": {
"d2sqlite3": "all-included"
}
} }

View File

@ -2,7 +2,8 @@
"fileVersion": 1, "fileVersion": 1,
"versions": { "versions": {
"d-properties": "1.0.5", "d-properties": "1.0.5",
"handy-httpd": "7.10.5", "d2sqlite3": "1.0.0",
"handy-httpd": "7.11.0",
"httparsed": "1.2.1", "httparsed": "1.2.1",
"slf4d": "2.4.3", "slf4d": "2.4.3",
"streams": "3.5.0" "streams": "3.5.0"

View File

@ -11,16 +11,18 @@ See test-site/index.html for an example.
* @type {WebSocket | null} * @type {WebSocket | null}
*/ */
let WS = null; let WS = null;
let REMOTE_URL = null;
const RETRY_TIMEOUT = 1000;
/** /**
* Sends some JSON object to sitestat. * Sends some JSON object to sitestat.
* @param {object} data The data to send. * @param {object} data The data to send.
*/ */
function sendStat(data) { function sendStat(data) {
if (WS.readyState === WebSocket.OPEN) { if (WS !== null && WS.readyState === WebSocket.OPEN) {
WS.send(JSON.stringify(data)); WS.send(JSON.stringify(data));
} else { } else {
console.warn("Couldn't send data because websocket is not open: ", data); console.warn("Couldn't send sitestat data because websocket is not open: ", data);
} }
} }
@ -55,37 +57,46 @@ function getRemoteUrl() {
throw new Error("Missing `remote-url=...` query parameter on script src attribute.") throw new Error("Missing `remote-url=...` query parameter on script src attribute.")
} }
function initWS() {
console.info("Trying to open a connection to sitestat...");
WS = new WebSocket(`ws://${REMOTE_URL}/ws`);
WS.onopen = () => {
console.info(
"📈 Established a connection to %csitestat%c for %cnon-intrusive, non-identifiable%csite analytics.\nLearn more here: https://github.com/andrewlalis/sitestat or check your browser's network tab to see what's being sent.",
"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;
WS.onclose = (closeEvent) => {
if (closeEvent.code > 1001) {
console.warn("sitestat connection closed unexpectedly with code "+closeEvent.code+". Trying to reestablish the connection in " + RETRY_TIMEOUT + "ms.");
window.setTimeout(initWS, RETRY_TIMEOUT);
}
}
}
// The main script starts below: // The main script starts below:
if (window.navigator.webdriver) { if (window.navigator.webdriver) {
throw new Error("sitestat disabled for automated user agents."); throw new Error("sitestat disabled for automated user agents.");
} }
const remoteUrl = getRemoteUrl(); REMOTE_URL = getRemoteUrl();
WS = new WebSocket(`ws://${remoteUrl}/ws`); initWS();
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. // Register various event listeners.
const events = ["click", "keyup", "keydown", "scroll", "copy"]; const events = ["click", "keyup", "keydown", "copy"];
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
document.addEventListener(events[i], handleEvent); document.addEventListener(events[i], handleEvent);
} }

View File

@ -1,52 +1,10 @@
import handy_httpd; import server : startServer;
import handy_httpd.handlers.path_delegating_handler; import report : makeReport;
void main() { void main(string[] args) {
import slf4d; if (args.length <= 1) {
import slf4d.default_provider; startServer();
auto provider = new shared DefaultProvider(true, Levels.INFO); } else if (args[1] == "report") {
// provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG); makeReport(args[2..$]);
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;
} }

124
source/data.d Normal file
View File

@ -0,0 +1,124 @@
module data;
import std.file;
import std.datetime;
import std.format;
import d2sqlite3;
static immutable FS_ORIGIN = "_LOCAL_FILESYSTEM_";
struct StoredSession {
long id;
SysTime startTimestamp;
SysTime endTimestamp;
string url;
string fullUrl;
string userAgent;
long eventCount;
}
void storeSession(StoredSession s) {
string origin = extractOrigin(s.url);
if (origin is null) {
throw new Exception("Unable to parse origin from url: " ~ s.url);
} else if (origin.length == 0) {
origin = FS_ORIGIN;
}
Database db = getOrCreateDatabase(origin);
Statement stmt = db.prepare(
"INSERT INTO session " ~
"(start_timestamp, end_timestamp, url, full_url, user_agent, event_count) " ~
"VALUES (?, ?, ?, ?, ?, ?)"
);
stmt.bind(1, formatTimestamp(s.startTimestamp));
stmt.bind(2, formatTimestamp(s.endTimestamp));
stmt.bind(3, s.url);
stmt.bind(4, s.fullUrl);
stmt.bind(5, s.userAgent);
stmt.bind(6, s.eventCount);
stmt.execute();
}
Database getOrCreateDatabase(string origin) {
string filename = dbPath(origin);
if (!exists(filename)) {
initDb(filename);
}
return Database(filename, SQLITE_OPEN_READWRITE);
}
private void initDb(string path) {
if (exists(path)) std.file.remove(path);
Database db = Database(path);
db.run(q"SQL
CREATE TABLE session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_timestamp TEXT NOT NULL,
end_timestamp TEXT NOT NULL,
url TEXT NOT NULL,
full_url TEXT NOT NULL,
user_agent TEXT NOT NULL,
event_count INTEGER NOT NULL
);
SQL"
);
}
ulong countSessions(string origin) {
Database db = getOrCreateDatabase(origin);
return db.execute("SELECT COUNT(id) FROM session;").oneValue!ulong;
}
private string formatTimestamp(SysTime t) {
return format!"%04d-%02d-%02d %02d:%02d:%02d"(
t.year, t.month, t.day,
t.hour, t.minute, t.second
);
}
string extractOrigin(string url) {
import std.algorithm : countUntil, startsWith;
ptrdiff_t idx = countUntil(url, "://");
if (idx == -1) return null;
string origin = url[idx + 3 .. $];
ptrdiff_t trailingSlashIdx = countUntil(origin, "/");
if (trailingSlashIdx != -1) {
origin = origin[0 .. trailingSlashIdx];
}
if (startsWith(origin, "www.")) {
origin = origin[4 .. $];
}
return origin;
}
unittest {
assert(extractOrigin("https://www.google.com/search") == "google.com");
assert(extractOrigin("https://litelist.andrewlalis.com") == "litelist.andrewlalis.com");
}
string dbPath(string origin) {
return "sitestat-db_" ~ origin ~ ".sqlite";
}
string originFromDbPath(string path) {
import std.algorithm : countUntil;
ptrdiff_t idx = countUntil(path, "sitestat-db_");
if (idx == -1) return null;
return path[(idx + 12)..$-7];
}
unittest {
assert(originFromDbPath("sitestat-db__LOCAL_FILESYSTEM_.sqlite") == "_LOCAL_FILESYSTEM_");
}
string[] listAllOrigins(string dir = ".") {
import std.array;
auto app = appender!(string[]);
foreach (DirEntry entry; dirEntries(dir, SpanMode.shallow, false)) {
string origin = originFromDbPath(entry.name);
if (origin !is null) {
app ~= origin;
}
}
return app[];
}

View File

@ -1,11 +1,14 @@
module live_tracker; module live_tracker;
import handy_httpd.components.websocket; import data;
import handy_httpd.components.websocket.handler;
import slf4d; import slf4d;
import std.uuid; import std.uuid;
import std.datetime; import std.datetime;
import std.json; import std.json;
import std.string; import std.string;
import std.parallelism;
import core.sync.rwmutex;
/** /**
* A websocket message handler that keeps track of each connected session, and * A websocket message handler that keeps track of each connected session, and
@ -13,54 +16,87 @@ import std.string;
*/ */
class LiveTracker : WebSocketMessageHandler { class LiveTracker : WebSocketMessageHandler {
private StatSession[UUID] sessions; private StatSession[UUID] sessions;
private immutable uint minSessionDurationMillis = 1000; static immutable uint DEFAULT_MIN_SESSION_DURATION = 1000;
private immutable uint minSessionDurationMillis;
private TaskPool sessionPersistencePool;
private ReadWriteMutex sessionsMutex;
this(uint minSessionDurationMillis = DEFAULT_MIN_SESSION_DURATION) {
this.minSessionDurationMillis = minSessionDurationMillis;
this.sessionPersistencePool = new TaskPool(1);
this.sessionsMutex = new ReadWriteMutex();
}
override void onConnectionEstablished(WebSocketConnection conn) { override void onConnectionEstablished(WebSocketConnection conn) {
debugF!"Connection established: %s"(conn.getId()); debugF!"Connection established: %s"(conn.id);
sessions[conn.getId()] = StatSession( synchronized(sessionsMutex.writer) {
conn.getId(), sessions[conn.id] = StatSession(
Clock.currTime(UTC()) conn.id,
); Clock.currTime(UTC())
);
}
infoF!"Started tracking session %s"(conn.id);
} }
override void onTextMessage(WebSocketTextMessage msg) { override void onTextMessage(WebSocketTextMessage msg) {
debugF!"Got message from %s: %s"(msg.conn.getId(), msg.payload); StatSession* session;
StatSession* session = msg.conn.getId() in sessions; synchronized(sessionsMutex.reader) {
session = msg.conn.id in sessions;
}
if (session is null) { if (session is null) {
warnF!"Got a websocket text message from a client without a session: %s"(msg.conn.getId()); warnF!"Got a websocket text message from a client without a session: %s"(msg.conn.id);
return; return;
} }
JSONValue obj = parseJSON(msg.payload); JSONValue obj = parseJSON(msg.payload);
immutable string msgType = obj.object["type"].str; immutable string msgType = obj.object["type"].str;
if (msgType == MessageTypes.IDENT) { if (msgType == MessageTypes.IDENT) {
string fullUrl = obj.object["href"].str; handleIdent(obj, session);
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) { } else if (msgType == MessageTypes.EVENT) {
session.events ~= EventRecord( session.eventCount++;
Clock.currTime(UTC()), // session.events ~= EventRecord(
obj.object["event"].str // Clock.currTime(UTC()),
); // obj.object["event"].str
// );
} }
} }
override void onConnectionClosed(WebSocketConnection conn) { override void onConnectionClosed(WebSocketConnection conn) {
debugF!"Connection closed: %s"(conn.getId()); StatSession* session;
StatSession* session = conn.getId() in sessions; synchronized(sessionsMutex.reader) {
if (session !is null && session.isValid) { session = conn.id in sessions;
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()); if (session !is null && session.isValid) {
SysTime endTimestamp = Clock.currTime(UTC());
Duration dur = endTimestamp - session.connectedAt;
if (dur.total!"msecs" >= minSessionDurationMillis) {
infoF!"Session lasted %d seconds, %d events."(dur.total!"seconds", session.eventCount);
}
immutable storedSession = StoredSession(
-1,
session.connectedAt,
endTimestamp,
session.url,
session.href,
session.userAgent,
session.eventCount
);
this.sessionPersistencePool.put(task!storeSession(storedSession));
}
synchronized(sessionsMutex.writer) {
sessions.remove(conn.id);
}
}
private void handleIdent(JSONValue msg, StatSession* session) {
string fullUrl = msg.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 = msg.object["userAgent"].str;
} }
} }
@ -75,7 +111,7 @@ struct StatSession {
string url = null; string url = null;
string href = null; string href = null;
string userAgent = null; string userAgent = null;
EventRecord[] events; uint eventCount = 0;
bool isValid() const { bool isValid() const {
return url !is null && href !is null && userAgent !is null; return url !is null && href !is null && userAgent !is null;

41
source/report.d Normal file
View File

@ -0,0 +1,41 @@
module report;
import std.stdio;
import std.datetime;
import std.algorithm;
import std.array;
import data;
void makeReport(string[] sites) {
string[] sitesToReport = sites.length == 0 ? listAllOrigins() : sites;
writeln("Creating report for " ~ createSiteNamesSummary(sitesToReport) ~ ".");
foreach (site; sitesToReport) {
writeln("Site: " ~ site);
writefln!"\nTotal sessions recorded: %d"(countSessions(site));
}
}
string createSiteNamesSummary(string[] sites) {
if (sites.length == 0) return "all sites";
if (sites.length == 1) return "site " ~ sites[0];
import std.array;
auto app = appender!string();
app ~= "sites ";
foreach (i, site; sites) {
app ~= site;
if (i + 2 == sites.length) {
app ~= ", and ";
} else if (i + 1 < sites.length) {
app ~= ", ";
}
}
return app[];
}
struct SiteReport {
string siteName;
SysTime periodStart;
SysTime periodEnd;
ulong totalVisitors;
}

62
source/server.d Normal file
View File

@ -0,0 +1,62 @@
module server;
import std.file;
import handy_httpd;
import handy_httpd.handlers.path_delegating_handler;
import d_properties;
void startServer() {
Properties props;
if (exists("sitestat.properties")) {
props = Properties("sitestat.properties");
}
import slf4d;
import slf4d.default_provider;
auto provider = new shared DefaultProvider(true, Levels.INFO);
provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN);
// provider.getLoggerFactory().setModuleLevel("live_tracker", Levels.DEBUG);
configureLoggingProvider(provider);
new HttpServer(prepareHandler(props), prepareConfig(props)).start();
}
/**
* Prepares the main request handler for the server.
* Returns: The request handler.
*/
private HttpRequestHandler prepareHandler(Properties props) {
import live_tracker;
PathDelegatingHandler pathHandler = new PathDelegatingHandler();
pathHandler.addMapping(
Method.GET,
"/ws",
new WebSocketHandler(new LiveTracker(props.get!uint(
"minSessionDurationMillis",
LiveTracker.DEFAULT_MIN_SESSION_DURATION
)))
);
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(Properties props) {
ServerConfig config = ServerConfig.defaultValues();
config.workerPoolSize = 3;
config.port = 8081;
config.enableWebSockets = true;
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;
}

View File

@ -13,6 +13,29 @@
<p> <p>
This is a page with information about this testing website. This is a page with information about this testing website.
</p> </p>
<p style="max-width: 50ch;">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum aliquam justo in sagittis dapibus. Nunc volutpat dolor quis velit interdum tempor. Mauris ac tortor accumsan, tristique sapien ac, scelerisque risus. Aliquam molestie finibus urna ornare tincidunt. Sed a eleifend ipsum. Proin nulla magna, semper iaculis velit ut, consequat laoreet magna. Vivamus id metus nec purus facilisis rutrum. Suspendisse potenti. Phasellus rhoncus nec ipsum et efficitur. Pellentesque condimentum egestas justo gravida imperdiet. Ut eu rhoncus urna. Nam sit amet tellus ut ex ultrices faucibus. Cras mi tellus, tempus quis ullamcorper sed, luctus vel purus. Proin porta ligula a turpis venenatis viverra. Vivamus id sollicitudin metus, quis ornare mi.
Mauris lobortis orci vitae accumsan placerat. Fusce blandit porttitor mattis. Integer mauris purus, dignissim et metus tincidunt, varius laoreet massa. Nunc consequat facilisis urna eget fermentum. Pellentesque faucibus fermentum faucibus. In facilisis lacus vitae eros accumsan commodo. Suspendisse pharetra sem ut lorem malesuada egestas. Duis sollicitudin ornare efficitur. Proin in urna volutpat, porta est scelerisque, euismod libero. Ut dignissim turpis at eros scelerisque ultrices. Curabitur hendrerit enim et odio lobortis, at elementum risus varius.
Donec luctus ornare leo. Praesent dapibus porta ipsum sed auctor. Suspendisse a efficitur sapien, nec finibus nibh. Donec fringilla mollis lacinia. In eu sapien eu arcu euismod ultricies. In hac habitasse platea dictumst. Donec eget tellus facilisis, molestie elit sit amet, convallis turpis. Suspendisse non aliquet turpis.
Mauris nisi nibh, finibus ac laoreet efficitur, dapibus ac mi. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus quis consectetur erat, in vulputate leo. Etiam consequat consequat velit. Nam finibus tellus eget lorem gravida condimentum. Quisque porttitor ac odio ac egestas. Sed quis risus massa. Duis placerat laoreet risus, nec sodales odio malesuada id. Integer a tincidunt turpis.
Mauris ac elementum quam. In vestibulum ante ac quam consequat varius. Quisque tortor lectus, lacinia interdum nisl vitae, feugiat aliquet nisi. Duis eget aliquet massa, eget faucibus mi. Suspendisse cursus ultrices augue varius vulputate. Sed in ligula eget nulla cursus vehicula. Proin condimentum varius felis non rutrum. Nullam dictum sed massa ac viverra. Quisque at tortor sed mi pretium viverra. Sed ac fermentum arcu. Ut laoreet libero ut mauris rutrum porta. Phasellus est massa, ultricies sed ultrices ut, lacinia at neque.
In suscipit mauris vel tellus accumsan, vitae hendrerit arcu bibendum. Aenean eu sodales dolor. Nullam nec risus urna. Fusce congue erat id tellus hendrerit, non bibendum neque molestie. Vestibulum quis velit vitae odio iaculis tincidunt a id ligula. Cras at lectus nec justo volutpat hendrerit. Nullam volutpat dolor vitae magna molestie, vel egestas elit fermentum. Pellentesque fermentum commodo massa, sit amet hendrerit orci consequat at. Nulla facilisi. Nullam ultrices cursus accumsan. Proin sit amet cursus odio, eu hendrerit justo. Donec ultrices bibendum ante. Donec nunc ipsum, cursus et tempor vel, malesuada at diam. Duis varius aliquet tristique. Donec non rutrum lorem, vitae molestie magna. Proin vulputate leo est, molestie pharetra enim aliquam eu.
Ut quis consequat augue. Curabitur tempus fermentum viverra. Fusce non ligula sit amet augue gravida viverra nec id nisl. Maecenas quis luctus orci. Cras blandit ut neque sit amet vehicula. Ut tempus luctus felis quis sodales. Pellentesque luctus elit porttitor, molestie nulla in, sollicitudin mauris. Nullam accumsan mauris nec dui vehicula aliquam. Suspendisse potenti. Sed eu auctor orci, non sagittis dui. Quisque nec eleifend ex. In ut dui ac ipsum tincidunt pharetra. Quisque commodo lorem libero, mattis interdum risus sodales ac. Vestibulum blandit interdum eros, id ornare lacus. Sed id justo tristique, mollis risus sit amet, hendrerit magna.
Phasellus placerat porttitor nisi id convallis. Mauris vitae odio neque. Proin accumsan magna felis, et convallis odio elementum eu. Morbi non condimentum leo, nec lobortis arcu. Donec vitae scelerisque orci. Aenean erat urna, maximus sit amet libero et, tempus scelerisque ipsum. Cras in blandit leo, ut aliquam massa. Duis a leo cursus, pharetra arcu at, accumsan nisi. Proin laoreet iaculis tortor, efficitur ullamcorper quam sollicitudin quis. Nulla facilisi. Vestibulum non rutrum eros.
Proin justo ante, ornare mattis nisi in, venenatis iaculis ipsum. Pellentesque vitae rhoncus nunc. Donec quis velit justo. Praesent sit amet egestas metus. Donec laoreet ligula id diam imperdiet iaculis. Nulla facilisi. Suspendisse potenti. Donec sed tincidunt velit. Donec convallis consequat dolor quis pellentesque. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Integer ac metus urna. Sed nec posuere nulla, nec vehicula sapien. Fusce sed sollicitudin felis, ac bibendum diam. Cras venenatis dictum lectus nec pulvinar. Suspendisse ultricies, justo et hendrerit malesuada, elit tortor bibendum dolor, mattis sagittis mauris ipsum a odio. Aenean vestibulum tortor gravida, dictum ante ut, interdum turpis. Ut sit amet sem non lacus eleifend vehicula et eget nunc. Morbi quam nunc, commodo ut laoreet eget, tristique sed ligula. In gravida dapibus est et congue. Morbi cursus augue non urna ultricies, nec dictum lacus ultricies. Donec leo ipsum, eleifend in egestas eu, tristique a mi. Nullam sed commodo orci, id aliquam libero. Fusce ultricies magna est, nec interdum eros accumsan a.
</p>
</body> </body>
</html> </html>