Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optionally record user activity in web frontend to database for analysis #4

Draft
wants to merge 11 commits into
base: develop
Choose a base branch
from
10 changes: 10 additions & 0 deletions analytics/index_dev.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package analytics

import (
"net/http"
"os"

"vimagination.zapto.org/tsserver"
)

var index = http.FileServer(http.FS(tsserver.WrapFS(os.DirFS("./src"))))
222 changes: 222 additions & 0 deletions analytics/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package analytics

import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"

_ "github.com/mattn/go-sqlite3" //
)

func StartServer(addr, dbPath string) error {
db, err := newDB(dbPath)
if err != nil {
return err
}

http.Handle("/", index)
http.Handle("/summary", handle[summaryInput](db.summary))
http.Handle("/user", handle[userInput](db.user))
http.Handle("/session", handle[sessionInput](db.session))

return http.ListenAndServe(addr, nil)
}

type HTTPError struct {
code int
msg string
}

func (h HTTPError) Error() string {
return fmt.Sprintf("%d: %s", h.code, h.msg)
}

type handle[T any] func(T) (any, error)

func (h handle[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var val T

if err := readBody(r.Body, &val); err != nil {
handleError(w, err)
} else if data, err := h(val); err != nil {
handleError(w, err)
} else if err = json.NewEncoder(w).Encode(data); err != nil {
slog.Error("error writing response", "err", err)
}
}

func readBody(r io.ReadCloser, data any) error {
defer r.Close()

if err := json.NewDecoder(r).Decode(data); err != nil {
return HTTPError{
code: http.StatusBadRequest,
msg: err.Error(),
}
}

return nil
}

func handleError(w http.ResponseWriter, err error) {
var herr HTTPError

if errors.As(err, &herr) {
http.Error(w, herr.msg, herr.code)
} else {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

type DB struct {
db *sql.DB

summaryStmt *sql.Stmt
userStmt *sql.Stmt
sessionStmt *sql.Stmt
}

func newDB(dbPath string) (*DB, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}

rdb := &DB{db: db}

for stmt, sql := range map[**sql.Stmt]string{
&rdb.summaryStmt: "SELECT [user], [session], [state], [time] FROM [events] WHERE [time] BETWEEN ? AND ?;",
&rdb.userStmt: "SELECT [session], [state], [time] FROM [events] WHERE [user] = ? AND [time] BETWEEN ? AND ?;",
&rdb.sessionStmt: "SELECT [state], [time] FROM [events] WHERE [username] = ? AND [session] = ?;",
} {
if *stmt, err = db.Prepare(sql); err != nil {
return nil, err
}
}

return rdb, nil
}

type summaryInput struct {
StartTime uint64 `json:"startTime"`
EndTime uint64 `json:"endTime"`
}

type Summary struct {
Users map[string]uint `json:"users"`
Sessions map[string]uint `json:"sessions"`
}

func newSummary() *Summary {
return &Summary{
Users: make(map[string]uint),
Sessions: make(map[string]uint),
}
}

func (s *Summary) addToSummary(user, session string, state json.RawMessage, timestamp uint64) {
s.Users[user] = s.Users[user] + 1
s.Sessions[session] = s.Sessions[session] + 1
}

func (d *DB) summary(i summaryInput) (any, error) {
if i.StartTime > i.EndTime {
return nil, ErrInvalidRange
}

rows, err := d.summaryStmt.Query(i.StartTime, i.EndTime)
if err != nil {
return nil, err
}

s := newSummary()

for rows.Next() {
var (
username string
session string
state json.RawMessage
timestamp uint64
)

if err := rows.Scan(&username, &session, &state, &timestamp); err != nil {
return nil, err
}

s.addToSummary(username, session, state, timestamp)
}

return s, nil
}

type userInput struct {
Username string `json:"username"`
StartTime uint64 `json:"startTime"`
EndTime uint64 `json:"endTime"`
}

func (d *DB) user(i userInput) (any, error) {
if i.StartTime > i.EndTime {
return nil, ErrInvalidRange
}

rows, err := d.userStmt.Query(i.Username, i.StartTime, i.EndTime)
if err != nil {
return nil, err
}

s := newSummary()

for rows.Next() {
var (
session string
state json.RawMessage
timestamp uint64
)

if err := rows.Scan(&session, &state, &timestamp); err != nil {
return nil, err
}

s.addToSummary(i.Username, session, state, timestamp)
}

return s, nil
}

type sessionInput struct {
Username string `json:"username"`
Session string `json:"string"`
}

type Event struct {
Data json.RawMessage `json:"data"`
Timestamp uint64 `json:"timestamp"`
}

func (d *DB) session(i sessionInput) (any, error) {
rows, err := d.sessionStmt.Query(i.Username, i.Session)
if err != nil {
return nil, err
}

var events []Event

for rows.Next() {
var e Event

if err := rows.Scan(&e.Data, &e.Timestamp); err != nil {
return nil, err
}

events = append(events, e)
}

return events, nil
}

var ErrInvalidRange = HTTPError{http.StatusBadRequest, "invalid date range"}
87 changes: 87 additions & 0 deletions analytics/src/code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
type Children = string | Element | DocumentFragment | Children[];

type Properties = Record<string, string | Function>;

type PropertiesOrChildren = Properties | Children;

type Summary = {
filters: Record<string, number>;
}

type TopSummary = Summary & {
Users: string[];
}

type UserSummary = Summary & {
sessions: UserSession[];
}

type UserSession = {
id: string;
start: number;
end: number;
}

type UserEvent = {
data: string;
time: number;
}

const amendNode = (node: Element, propertiesOrChildren: PropertiesOrChildren, children?: Children) => {
const [p, c] = typeof propertiesOrChildren === "string" || propertiesOrChildren instanceof Node || propertiesOrChildren instanceof Array ? [{}, propertiesOrChildren] : [propertiesOrChildren, children ?? []];

Object.entries(p).forEach(([key, value]) => node[value instanceof Function ? "addEventListener" : "setAttribute"](key, value as any));

node.append(...[c as any].flat(Infinity));

return node;
},
clearNode = (node: Element, propertiesOrChildren: PropertiesOrChildren = {}, children?: Children) => amendNode((node.replaceChildren(), node), propertiesOrChildren, children),
{br, button, div, label, input} = new Proxy({}, {"get": (_, element: keyof HTMLElementTagNameMap) => (props: PropertiesOrChildren = {}, children?: Children) => amendNode(document.createElementNS("http://www.w3.org/1999/xhtml", element), props, children)}) as {[K in keyof HTMLElementTagNameMap]: (props?: PropertiesOrChildren, children?: Children) => HTMLElementTagNameMap[K]},
rpc = (() => {
const base = "/",
getData = <T>(url: string, body: string) => fetch(base + url, {body}).then(j => j.json() as T);

return {
"getSummary": (startTime: number, endTime: number) => getData<TopSummary>("summary", JSON.stringify({startTime, endTime})),
"getUser": (username: string, startTime: number, endTime: number) => getData<UserSummary>("user", JSON.stringify({username, startTime, endTime})),
"getSession": (username: string, session: string) => getData<UserEvent[]>("session", JSON.stringify({username, session}))
};
})(),
yesterday = (() => {
const d = new Date();

d.setDate(d.getDate() - 1);

return d.toISOString().split("T")[0];
})(),
startTime = input({"id": "startTime", "type": "date", "value": yesterday}),
endTime = input({"id": "endTime", "type": "date", "value": yesterday});

rpc.getSummary((+new Date()/1000|0) - 86400, +new Date()/1000|0);

amendNode(document.body, [
div([
label({"for": "startTime"}, "Start Time"),
startTime,
br(),
label({"for": "endTime"}, "End Time"),
endTime,
br(),
button({"click": () => {
const start = startTime.valueAsNumber/1000|0,
end = (endTime.valueAsNumber/1000|0)+86400;

if (isNaN(start) || isNaN(end) || start >= end) {
alert("Invalid time range");

return;
}

rpc.getSummary(start, end)
.then(data => {
console.log(data);
});
}}, "Go!")
])
])
9 changes: 9 additions & 0 deletions analytics/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>WRStat Analytics</title>
<link rel="shortcut icon" sizes="any" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cdefs%3E%3Cpath id='b' d='M10,105 c-31,37 84,57 101,88 c33,-22 -87,-56 -101,-88 z' stroke='%23000' stroke-width='3' /%3E%3Cpath id='t' d='M106,0 c53,42 -142,52 -98,127 c-24,-60 176,-65 98,-127 z' stroke='%23000' stroke-width='3' /%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='%23074987' /%3E%3Cg%3E%3CanimateTransform attributeName='transform' attributeType='XML' type='translate' from='0 0' to='0 170' dur='5s' begin='click' repeatCount='indefinite' /%3E%3Cuse href='%23b' x='37' y='-195' fill='%23d2ecf5' /%3E%3Cuse href='%23b' x='37' y='-25' fill='%23d2ecf5' /%3E%3Cuse href='%23b' x='37' y='-255' fill='%2374c2dd' /%3E%3Cuse href='%23b' x='37' y='-85' fill='%2374c2dd' /%3E%3Cuse href='%23b' x='37' y='85' fill='%2374c2dd' /%3E%3Cuse href='%23t' x='37' y='-85' fill='%2374c2ff' /%3E%3Cuse href='%23t' x='37' y='-255' fill='%2374c2ff' /%3E%3Cuse href='%23t' x='37' y='85' fill='%2374c2ff' /%3E%3Cuse href='%23t' x='37' y='-195' fill='%23bbddff' /%3E%3Cuse href='%23t' x='37' y='-25' fill='%23bbddff' /%3E%3Cuse href='%23t' x='37' y='145' fill='%23bbddff' /%3E%3C/g%3E%3C/svg%3E"/>
<script type="module" src="code.js"></script>
</head>
<body></body>
</html>
16 changes: 16 additions & 0 deletions analytics/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUnusedParameters": true,
"experimentalDecorators": true
},
"include": ["*.ts"]
}
8 changes: 8 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ var (
oktaOAuthClientSecret string
areasPath string
ownersPath string
spywareDB string
)

// serverCmd represents the server command.
Expand Down Expand Up @@ -190,6 +191,12 @@ creation time in reports.
die("failed to add tree page: %s", err)
}

if spywareDB != "" {
if err = s.InitAnalyticsDB(spywareDB); err != nil {
die("failed to init spyware db: %s", err)
}
}

defer s.Stop()

sayStarted()
Expand Down Expand Up @@ -223,6 +230,7 @@ func init() {
serverCmd.Flags().StringVarP(&ownersPath, "owners", "o", "", "gid,owner csv file")
serverCmd.Flags().StringVar(&serverLogPath, "logfile", "",
"log to this file instead of syslog")
serverCmd.Flags().StringVar(&spywareDB, "spyware", "s", "path to sqlite database to record analytics")
}

// checkOAuthArgs ensures we have the necessary args/ env vars for Okta auth.
Expand Down
Loading
Loading