diff options
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | Makefile | 53 | ||||
| -rw-r--r-- | client/sws.js | 137 | ||||
| -rw-r--r-- | client/test.html | 19 | ||||
| -rw-r--r-- | cmd/server/handlers.go | 60 | ||||
| -rw-r--r-- | cmd/server/main.go | 103 | ||||
| -rw-r--r-- | counter/gen.go | 73 | ||||
| -rw-r--r-- | counter/sws.js | 53 | ||||
| -rw-r--r-- | domain.go | 26 | ||||
| -rw-r--r-- | domain_store.go (renamed from models/domain.go) | 11 | ||||
| -rw-r--r-- | go.mod | 18 | ||||
| -rw-r--r-- | go.sum | 34 | ||||
| -rw-r--r-- | hit.go | 94 | ||||
| -rw-r--r-- | hit_store.go | 82 | ||||
| -rw-r--r-- | logger.go | 17 | ||||
| -rw-r--r-- | migrations/001_page_views.sql | 26 | ||||
| -rw-r--r-- | models/page_view.go | 133 | ||||
| -rw-r--r-- | query_args.go (renamed from models/query_args.go) | 2 | ||||
| -rw-r--r-- | queryer.go (renamed from models/queryer.go) | 2 | ||||
| -rw-r--r-- | sql/gen.go | 117 | ||||
| -rw-r--r-- | sql/sqlite3/01_domains.sql | 8 | ||||
| -rw-r--r-- | sql/sqlite3/02_hits.sql | 14 | ||||
| -rw-r--r-- | utils.go | 14 |
23 files changed, 721 insertions, 384 deletions
@@ -1,4 +1,7 @@ -collector -server -client/sws.min.js +dist/ node_modules/ + +# Generated content +counter/sws.min.js +cmd/server/migrations.go +cmd/server/counter.go @@ -1,38 +1,49 @@ -binary := collector server -src := $(shell find . -type f -name '*.go') -extras := cmd/collector/snippet.go -tests := $(shell find . -type f -name '*_test.go') +BINARY= $(patsubst %,dist/%,$(shell find cmd/* -maxdepth 0 -type d -exec basename {} \;)) +SRC= $(shell find . -type f -name '*.go') +SQL= $(shell find sql -type f) +EXTRAS= cmd/server/migrations.go counter/sws.min.js cmd/server/counter.go -.PHONY: help build lint clean +.PHONY: build +build: $(BINARY) -build: $(binary) ## Build a binary +dist/%: $(SRC) $(EXTRAS) + go build -ldflags "-X main.Version=$(VERSION)" -o $@ ./cmd/$* -$(binary): $(src) $(extras) - go build -ldflags "-w -s -X main.version=$(version)" -o $@ ./cmd/$@ +# cmd/server/counter.go: counter/sws.min.js +# go generate ./counter >$@ -cmd/collector/snippet.go: client/sws.min.js - @printf "package main\n\nconst snippet = \`" >$@ +cmd/server/counter.go: counter/sws.min.js + @printf "package main\n\nconst counter = \`" >$@ @cat $< >>$@ @printf "\`\n" >>$@ + +cmd/server/migrations.go: $(SQL) + go generate ./sql >$@ + %.min.js: %.js node_modules - @yarn run uglifyjs -o $@ $< + yarn run uglifyjs -c -m -o $@ $< node_modules: package.json yarn -test: lint $(tests) $(src) ## Run tests - go test -v -short -coverprofile=coverage.out -cover ./... - go tool cover -html=coverage.out -o coverage.html +.PHONY: test +test: lint + go test -v -short -coverprofile=coverage.out -cover ./... \ + && go tool cover -html=coverage.out -o coverage.html -lint: $(src) - golint $< +.PHONY: lint +lint: + go vet ./... -clean: ## Clean all test files - rm -f $(binary) $(extras) +.PHONY: clean +clean: + rm -fr dist rm -f client/sws.min.js - rm -rf coverage* + rm -f $(EXTRAS) + rm -fr coverage* -help: ## This help - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |sort |awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' +.PHONY: dist-clean +dist-clean: clean + rm -fr node_modules diff --git a/client/sws.js b/client/sws.js deleted file mode 100644 index a67d8b9..0000000 --- a/client/sws.js +++ /dev/null @@ -1,137 +0,0 @@ -var d = document -var w = window -var l = window.location -var n = w.navigator -var esc = encodeURIComponent - -function send (p, obj) { - console.log('sending', p, JSON.stringify(obj)) - var qs = Object.keys(obj) - .map(function(k) { - return esc(k) + '=' + esc(obj[k]) - }) - .join('&') - var r = new w.XMLHttpRequest() - r.open('GET', p + "?" + qs, true) - r.send() -} - -/* -function ch (e) { - var href - var el = e.target || e.srcElement - if (el.tagName === 'A') { - href = el.getAttribute('href') - } - var rect = el.getBoundingClientRect() - send('/click', { - url: d.URL, - href: href, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - left: rect.left - }) -} - -function frameBuster () { - if (w.location !== w.top.location) { - w.top.location = w.location - } -} -*/ - -function ready (fn) { - if (d.attachEvent - ? d.readyState === 'complete' - : d.readyState !== 'loading') { - fn() - /* - if (_sws.events) { - d.attachEvent('onclick', ch) - } - */ - } else { - d.addEventListener('DOMContentLoaded', fn) - /* - if (_sws.events) { - d.addEventListener('click', ch) - } - */ - } -} - -/* -function detectBrowserFeatures() { - var i, - mimeType, - pluginMap = { - // document types - pdf: 'application/pdf', - - // media players - qt: 'video/quicktime', - realp: 'audio/x-pn-realaudio-plugin', - wma: 'application/x-mplayer2', - - // interactive multimedia - dir: 'application/x-director', - fla: 'application/x-shockwave-flash', - - // RIA - java: 'application/x-java-vm', - gears: 'application/x-googlegears', - ag: 'application/x-silverlight' - }; - - // detect browser features except IE < 11 (IE 11 user agent is no longer MSIE) - if (!((new RegExp('MSIE')).test(n.userAgent))) { - // general plugin detection - if (n.mimeTypes && n.mimeTypes.length) { - for (i in pluginMap) { - if (Object.prototype.hasOwnProperty.call(pluginMap, i)) { - mimeType = n.mimeTypes[pluginMap[i]]; - browserFeatures[i] = (mimeType && mimeType.enabledPlugin) ? '1' : '0'; - } - } - } - // Safari and Opera - // IE6/IE7 navigator.javaEnabled can't be aliased, so test directly - // on Edge navigator.javaEnabled() always returns `true`, so ignore it - if (!((new RegExp('Edge[ /](\\d+[\\.\\d]+)')).test(n.userAgent)) && - typeof navigator.javaEnabled !== 'unknown' && - isDefined(n.javaEnabled) && - n.javaEnabled()) { - browserFeatures.java = '1'; - } - -// Firefox - if (isFunction(windowAlias.GearsFactory)) { - browserFeatures.gears = '1'; - } - -// other browser features - browserFeatures.cookie = hasCookies(); - } -} - -var width = parseInt(screenAlias.width, 10); -var height = parseInt(screenAlias.height, 10); -browserFeatures.res = parseInt(width, 10) + 'x' + parseInt(height, 10); -*/ - -var viewPort = (w.innerWidth || d.documentElement.clientWidth || d.body.clientWidth) -viewPort += "x" -viewPort += (w.innerHeight || d.documentElement.clientHeight || d.body.clientHeight) - -ready(function () { - send('http://localhost:5000/sws.gif', { - s: l.protocol, - h: l.host, - p: l.pathname + l.search + l.hash, // page - t: _sws.title || d.title, - r: d.referrer, - u: n.userAgent, - v: viewPort, - }) -}) diff --git a/client/test.html b/client/test.html deleted file mode 100644 index ef2f621..0000000 --- a/client/test.html +++ /dev/null @@ -1,19 +0,0 @@ -<!doctype html> -<html> - <head> - <meta charset="utf-8"> - <script> - var _sws = { - title: "test title" - } - </script> - <script async src="http://localhost:5000/sws.js"></script> - <title>This is the title</title> - <noscript> - <img src="http://localhost:5000/sws.gif?s=http%3A&h=localhost%3A5000&p=%2F?noscript&r=&u=Mozilla%2F5.0%20(Windows%20NT%2010.0%3B%20Win64%3B%20x64)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F69.0.3497.32%20Safari%2F537.36&v=1916x563" /> - </noscript> - </head> - <body> - <a href="?referred">test</a> - </body> -</html> diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go index d6095c7..26b21cb 100644 --- a/cmd/server/handlers.go +++ b/cmd/server/handlers.go @@ -1,45 +1,87 @@ package main import ( + "encoding/base64" + "io" "net/http" "strings" - "src.userspace.com.au/sws/models" + "src.userspace.com.au/sws" + //"src.userspace.com.au/sws/counter" ) +const gif = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + func handleIndex() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - http.ServeFile(w, r, "client/test.html") + http.ServeFile(w, r, "counter/test.html") + } +} + +func handleDomains(db sws.Queryer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + return } } -func handleDomains(db models.Queryer) http.HandlerFunc { +func handleHits(db sws.Queryer) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { return } } -func handlePageViews(db models.Queryer) http.HandlerFunc { +func handleHitCounter(db sws.Queryer) http.HandlerFunc { + gifBytes, err := base64.StdEncoding.DecodeString(gif) + if err != nil { + panic(err) + } + return func(w http.ResponseWriter, r *http.Request) { - pv, err := models.PageViewFromURL(*r.URL) + hit, err := sws.HitFromRequest(r) if err != nil { + log("failed to create hit", err) http.Error(w, err.Error(), http.StatusBadRequest) return } - domain, err := models.GetDomainByName(db, *pv.Host) + domain, err := sws.GetDomainByName(db, *hit.Host) if err != nil { + log("failed to get domain", err) http.Error(w, err.Error(), http.StatusNotFound) return } - pv.DomainID = domain.ID - pv.Address = &(strings.Split(r.RemoteAddr, ":")[0]) + hit.DomainID = domain.ID + hit.Addr = &r.RemoteAddr - if err := pv.Save(db); err != nil { + if err := hit.Save(db); err != nil { + log("failed to save hit", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } + w.Header().Set("Content-Type", "image/gif") + w.Write(gifBytes) + log("hit", hit) return } } + +func handleCounter(addr string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + reader := strings.NewReader(counter) + if _, err := io.Copy(w, reader); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func handleExample() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><script>var _sws = { title: "test title" }</script> +<script async src="http://localhost:5000/sws.js" data-sws="http://localhost:5000/sws.gif"></script> + <title>This is the title</title> + <noscript><img src="http://localhost:5000/sws.gif" /></noscript></head><body><a href="?referred">test</a></body></html>`)) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 9b9fa6f..3abca0b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,47 +2,114 @@ package main import ( "database/sql" + "flag" "fmt" "net/http" "os" + "strings" + "time" "github.com/go-chi/chi" _ "github.com/jackc/pgx/stdlib" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "src.userspace.com.au/flags" + "src.userspace.com.au/go-migrate" + "src.userspace.com.au/sws" ) +// Flags +var ( + verbose *bool + addr *string + dsn *string + noMigrate *bool +) + +var log, debug sws.Logger + +func init() { + verbose = flags.Bool("verbose", "v", false, "VERBOSE", "enable verbose output") + addr = flags.String("listen", "l", "localhost:5000", "LISTEN", "listen address") + dsn = flags.String("dsn", "", "file:sws.db?cache=shared", "DSN", "database password") + noMigrate = flags.Bool("no-migrate", "m", false, "NOMIGRATE", "disable migrations") + + // Default to no log + log = func(v ...interface{}) {} + debug = func(v ...interface{}) {} +} + func main() { - db, err := sql.Open( - "pgx", - fmt.Sprintf( - "postgres://%s:%s@%s:5432/%s?sslmode=disable", - os.Getenv("PG_USER"), - os.Getenv("PG_PASS"), - envString("PG_HOST", "localhost"), - envString("PG_DB", "swa"), - ), - ) + flag.Parse() + + if *verbose { + log = func(v ...interface{}) { + fmt.Fprintf(os.Stdout, "[%s] ", time.Now().Format(time.RFC3339)) + fmt.Fprintln(os.Stdout, v...) + } + } + if d := os.Getenv("DEBUG"); d != "" { + debug = func(v ...interface{}) { + fmt.Fprintf(os.Stdout, "[%s] ", time.Now().Format(time.RFC3339)) + fmt.Fprintln(os.Stdout, v...) + } + } + + driver := strings.SplitN(*dsn, ":", 2)[0] + if driver == "file" { + driver = "sqlite3" + } + db, err := sqlx.Open(driver, *dsn) if err != nil { fmt.Println(err) os.Exit(1) } + defer db.Close() if err := db.Ping(); err != nil { fmt.Println(err) os.Exit(1) } + if noMigrate == nil || !*noMigrate { + version, err := migrateDatabase(db, driver) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to migrate: %s", err) + os.Exit(2) + } + log("database at version", version) + } + r := chi.NewRouter() - r.Get("/page_views", handlePageViews(db)) + r.Get("/sws.js", handleCounter(*addr)) + r.Get("/sws.gif", handleHitCounter(db)) + r.Get("/hits", handleHits(db)) r.Get("/domains", handleDomains(db)) + r.Get("/test.html", handleTest()) r.Get("/", handleIndex()) - http.ListenAndServe(":5000", r) + + log("listening at", *addr) + http.ListenAndServe(*addr, r) } -// Get envvar string with default -func envString(v, d string) string { - out := os.Getenv(v) - if out == "" { - out = d +func migrateDatabase(db *sql.DB, driver string) (int64, error) { + var v int64 + // Load migrations + ms, err := decodeMigrations(driver) + if err != nil { + return 0, err + } + debug("found", len(ms), "migrations for driver", driver) + migrator, err := migrate.NewStringMigrator(db, ms) + if err != nil { + return v, fmt.Errorf("failed to initialise: %w", err) + } + + err = migrator.Migrate() + if err != nil { + return v, fmt.Errorf("failed to migrate: %w", err) } - return out + + v, err = migrator.Version() + return v, nil } diff --git a/counter/gen.go b/counter/gen.go new file mode 100644 index 0000000..34875b2 --- /dev/null +++ b/counter/gen.go @@ -0,0 +1,73 @@ +package main + +//go:generate go run gen.go + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "io" + "os" + "text/template" +) + +func main() { + f, err := os.Open("sws.min.js") + if err != nil { + panic(err) + } + defer f.Close() + + var buf bytes.Buffer + + encoder := base64.NewEncoder(base64.StdEncoding, &buf) + compressor := gzip.NewWriter(encoder) + defer compressor.Close() + defer encoder.Close() + + _, err = io.Copy(compressor, f) + + tmpl, err := template.New("counter").Parse(tmplData) + if err != nil { + panic(err) + } + tmpl.Execute(os.Stdout, struct{ B64 string }{buf.String()}) +} + +var tmplData = `package main + +// Automatically generated, don't bother editing. + +import ( + "bytes" + "strings" + "compress/gzip" + "encoding/base64" + "io" +) + +const data = "{{ .B64 }}" + +func GetCounter() []byte { + b := GetCounterGzipped() + decompressor, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return panic(err) + } + var buf bytes.Buffer + _, err = io.Copy(&buf, decompressor) + if err != nil { + panic(err) + } + return buf.Bytes() +} + +func GetCounterGzipped() []byte { + decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(data)) + out, err := base64.StdEncoding.DecodeString(data) + if err != nil { + panic(err) + } + return out +} +` diff --git a/counter/sws.js b/counter/sws.js new file mode 100644 index 0000000..98834c0 --- /dev/null +++ b/counter/sws.js @@ -0,0 +1,53 @@ +var d = document +var de = document.documentElement +var w = window +var l = window.location +var n = w.navigator +var esc = encodeURIComponent +var me = document.currentScript +console.log('me:', me) +console.log('me.sws:', me.dataset.sws) + +_sws = _sws || {} +console.log('_sws:', _sws) +_sws.d = _sws.d || me.dataset.sws || 'http://sws.userspace.com.au/sws.gif' +console.log('using', _sws.d) + +function send (p, obj) { + console.log('sending', p, JSON.stringify(obj)) + var qs = Object.keys(obj) + .map(function (k) { + return esc(k) + '=' + esc(obj[k]) + }) + .join('&') + var r = new w.XMLHttpRequest() + r.open('GET', p + '?' + qs, true) + r.send() +} + +function ready (fn) { + if (d.attachEvent + ? d.readyState === 'complete' + : d.readyState !== 'loading') { + fn() + } else { + d.addEventListener('DOMContentLoaded', fn) + } +} + +var viewPort = (w.innerWidth || de.clientWidth || d.body.clientWidth) + + 'x' + + (w.innerHeight || de.clientHeight || d.body.clientHeight) + +ready(function () { + send(_sws.d, { + s: l.protocol, + h: l.host, + p: l.pathname, + q: l.search + l.hash, + t: _sws.title || d.title, + r: d.referrer, + u: n.userAgent, + v: viewPort + }) +}) diff --git a/domain.go b/domain.go new file mode 100644 index 0000000..4eab85c --- /dev/null +++ b/domain.go @@ -0,0 +1,26 @@ +package sws + +import ( + "time" + + "github.com/speps/go-hashids" +) + +const slugSalt = "saltyslugs" + +type Domain struct { + ID *int `json:"id"` + Name *string `json:"name"` + Description *string `json:"description"` + Enabled bool `json:"enabled"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +func (d Domain) Slug() string { + hd := hashids.NewData() + hd.Salt = slugSalt + h, _ := hashids.NewWithData(hd) + out, _ := h.Encode([]int{*d.ID}) + return out +} diff --git a/models/domain.go b/domain_store.go index ef3fb77..26568ee 100644 --- a/models/domain.go +++ b/domain_store.go @@ -1,17 +1,9 @@ -package models +package sws import ( "strings" - "time" ) -type Domain struct { - ID *int - Name *string - CreatedAt *time.Time - UpdatedAt *time.Time -} - func GetDomainByName(db Queryer, name string) (*Domain, error) { d := Domain{} name = strings.Split(name, ":")[0] @@ -31,6 +23,5 @@ const ( id, name, created_at, updated_at from domains where $1 = name -or right($1, length(name)) = name limit 1` ) @@ -1,9 +1,23 @@ module src.userspace.com.au/sws require ( + github.com/cockroachdb/apd v1.1.0 // indirect github.com/go-chi/chi v3.3.3+incompatible - github.com/go-chi/cors v1.0.0 + github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect github.com/jackc/pgx v3.3.0+incompatible + github.com/jmoiron/sqlx v1.2.0 + github.com/kr/pretty v0.2.0 // indirect + github.com/lib/pq v1.3.0 // indirect + github.com/mattn/go-sqlite3 v1.10.0 github.com/pkg/errors v0.8.0 // indirect - src.userspace.com.au/go-migrate v0.0.0-20181217162214-45b3f8f7b423 + github.com/satori/go.uuid v1.2.0 // indirect + github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 // indirect + github.com/speps/go-hashids v2.0.0+incompatible + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + src.userspace.com.au/flags v0.0.0-20200208094111-eef94fa594cc + src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f ) + +go 1.13 + +//replace src.userspace.com.au/flags => ../flags @@ -1,11 +1,37 @@ +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/go-chi/chi v3.3.3+incompatible h1:KHkmBEMNkwKuK4FdQL7N2wOeB9jnIx7jR5wsuSBEFI8= github.com/go-chi/chi v3.3.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-chi/cors v1.0.0 h1:e6x8k7uWbUwYs+aXDoiUzeQFT6l0cygBYyNhD7/1Tg0= -github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I= +github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc= +github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ= github.com/jackc/pgx v3.3.0+incompatible h1:Wa90/+qsITBAPkAZjiByeIGHFcj3Ztu+VzrrIpHjL90= github.com/jackc/pgx v3.3.0+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -src.userspace.com.au/go-migrate v0.0.0-20181217162214-45b3f8f7b423 h1:t6O2mJfUCIH0fHsGfo+1QS8eB4vzroYvw4wkBGpa72I= -src.userspace.com.au/go-migrate v0.0.0-20181217162214-45b3f8f7b423/go.mod h1:WxJE6mvzbDEZd0SHDi8mNU4CsA9coiB12uXrpFq9zBU= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 h1:Pm6R878vxWWWR+Sa3ppsLce/Zq+JNTs6aVvRu13jv9A= +github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw= +github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +src.userspace.com.au/flags v0.0.0-20200208094111-eef94fa594cc h1:Y3l3njFS/I5rMaNKEBWR/GyPiqUn7H3A1UDyESmIkj4= +src.userspace.com.au/flags v0.0.0-20200208094111-eef94fa594cc/go.mod h1:O1fCxS7L0DERX5Qj9MVZhUHWBhidpztZUPpG/7roPoU= +src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f h1:Vdn/5kMeLXWfkXF/wm9lioSBASBn02aA5DeVjLAYjLM= +src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f/go.mod h1:QaSWOcvGubR8TBPs8XgLN67muYgAKnmIgHAaQk5ZR1c= @@ -0,0 +1,94 @@ +package sws + +import ( + "fmt" + "net/http" + "net/url" + "time" +) + +type Hit struct { + DomainID *int `json:"domain_id,omitempty"` + Addr *string `json:"addr,omitempty"` + // URL components + Scheme *string `json:"scheme,omitempty"` + Host *string `json:"host,omitempty"` + Path *string `json:"page,omitempty"` + Query *string `json:"query,omitempty"` + Fragment *string `json:"fragment,omitempty"` + + Title *string `json:"title,omitempty"` + Referrer *string `json:"referrer,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` + ViewPort *string `json:"view_port,omitempty"` + //Features map[string]string `json:"features,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + + // TODO + Domain *Domain `json:"-"` +} + +func HitFromRequest(r *http.Request) (*Hit, error) { + q := r.URL.Query() + host := q.Get("h") + ref, err := url.ParseRequestURI(r.Referer()) + if err != nil || ref == nil { + ref = new(url.URL) + } + if host == "" { + if ref.Host == "" { + return nil, fmt.Errorf("missing host") + } + host = ref.Host + } + out := Hit{ + Host: &host, + CreatedAt: ptrTime(time.Now()), + } + + scheme := q.Get("s") + if scheme != "" { + out.Scheme = &scheme + } else { + out.Scheme = &ref.Scheme + } + + path := q.Get("p") + if path != "" { + out.Path = &path + } else { + out.Path = &ref.Path + } + + query := q.Get("q") + if query != "" { + out.Path = &query + } else { + out.Path = &ref.Path + } + + if title := q.Get("t"); title != "" { + out.Title = &title + } + + referrer := q.Get("r") + if referrer != "" { + out.Referrer = &referrer + } else { + s := ref.String() + out.Referrer = &s + } + + agent := q.Get("u") + if agent != "" { + out.UserAgent = &agent + } else { + s := r.UserAgent() + out.UserAgent = &s + } + + if view := q.Get("v"); view != "" { + out.ViewPort = &view + } + return &out, nil +} diff --git a/hit_store.go b/hit_store.go new file mode 100644 index 0000000..5668486 --- /dev/null +++ b/hit_store.go @@ -0,0 +1,82 @@ +package sws + +import ( + "fmt" +) + +func GetHits(db Queryer, f map[string]interface{}) ([]*Hit, error) { + pvs := make([]*Hit, 0) + qa := queryArgs{} + + sql := sqlFilterHits + if len(f) > 0 { + sql = sql + " where " + for k, v := range f { + sql += fmt.Sprintf(" %s = %s", k, qa.Append(v)) + } + } + + rows, err := db.Query(sql, qa...) + if err != nil { + return nil, err + } + + for rows.Next() { + pv := &Hit{} + if err := rows.Scan(pv); err != nil { + return pvs, err + } + pvs = append(pvs, pv) + } + return pvs, nil +} + +func (s *Hit) Save(db Queryer) error { + if _, err := db.Exec( + sqlSaveHit, + s.DomainID, + s.Addr, + s.Scheme, + s.Host, + s.Path, + s.Query, + s.Title, + s.Referrer, + s.UserAgent, + s.ViewPort, + s.CreatedAt, + ); err != nil { + return err + } + return nil +} + +const ( + sqlSaveHit = `insert into hits ( +domain_id, +addr, +scheme, +host, +path, +query, +title, +referrer, +user_agent, +view_port, +created_at +) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +` + sqlFilterHits = `select +domain_id, +address, +scheme, +host, +page, +title, +referrer, +user_agent, +view_port, +created_at +from hits +` +) diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..47045e8 --- /dev/null +++ b/logger.go @@ -0,0 +1,17 @@ +package sws + +import ( + "fmt" + "os" + "time" +) + +type Logger func(...interface{}) + +var ( + DebugLog Logger = func(v ...interface{}) {} + ErrorLog Logger = func(v ...interface{}) { + fmt.Fprintf(os.Stderr, "[%s] ", time.Now().Format(time.RFC3339)) + fmt.Fprintln(os.Stderr, v...) + } +) diff --git a/migrations/001_page_views.sql b/migrations/001_page_views.sql deleted file mode 100644 index d3a8ad2..0000000 --- a/migrations/001_page_views.sql +++ /dev/null @@ -1,26 +0,0 @@ --- create extension if not exists hstore; - --- set role felix; - -create table if not exists domains ( - id serial primary key, - name varchar(255) not null, - created_at timestamp without time zone not null, - updated_at timestamp without time zone not null -); -create unique index if not exists domains_name_idx on domains (name); - -create table if not exists page_views ( - id bigserial primary key, - domain_id integer not null references domains (id), - address varchar(50), - scheme varchar(20) not null, - host varchar(255) not null, - page varchar(1000), - title varchar(255), - referrer varchar(2000), - user_agent varchar(255), - view_port varchar(20), - attributes hstore, - created_at timestamp without time zone not null -); diff --git a/models/page_view.go b/models/page_view.go deleted file mode 100644 index cc86afe..0000000 --- a/models/page_view.go +++ /dev/null @@ -1,133 +0,0 @@ -package models - -import ( - "fmt" - "net/url" - "time" -) - -type PageView struct { - ID *int `json:"id"` - Address *string `json:"address,omitempty"` - Scheme *string `json:"scheme,omitempty"` - Host *string `json:"host,omitempty"` - Page *string `json:"page,omitempty"` - Title *string `json:"title,omitempty"` - Referrer *string `json:"referrer,omitempty"` - UserAgent *string `json:"user_agent,omitempty"` - ViewPort *string `json:"view_port,omitempty"` - Features map[string]string `json:"features,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` - DomainID *int `json:"domain_id,omitempty"` - - // TODO - Domain *Domain `json:"-"` -} - -func ptrString(s string) *string { - if s == "" { - return nil - } - return &s -} - -func ptrTime(t time.Time) *time.Time { - return &t -} - -func PageViewFromURL(u url.URL) (*PageView, error) { - q := u.Query() - host := q.Get("h") - if host == "" { - return nil, fmt.Errorf("missing host") - } - pv := PageView{ - Scheme: ptrString(q.Get("s")), - Host: &host, - Page: ptrString(q.Get("p")), - Title: ptrString(q.Get("t")), - Referrer: ptrString(q.Get("r")), - UserAgent: ptrString(q.Get("u")), - ViewPort: ptrString(q.Get("v")), - CreatedAt: ptrTime(time.Now()), - } - return &pv, nil -} - -func GetPageViews(f map[string]interface{}) ([]*PageView, error) { - pvs := make([]*PageView, 0) - qa := new(queryArgs) - - sql := sqlFilterPageViews - if len(f) > 0 { - sql = sql + " where " - for k, v := range f { - sql += fmt.Sprintf(" %s = %s", k, qa.Append(v)) - } - } - - rows, err := db.Query(sql, qa...) - if err != nil { - return nil, err - } - - for rows.Next() { - pv := &PageView{} - if err := rows.Scan(pv); err != nil { - return pvs, err - } - pvs = append(pvs, pv) - } - return pvs, nil -} - -func (s *PageView) Save(db Queryer) error { - var id int - if err := db.QueryRow( - sqlSavePageView, - s.DomainID, - s.Address, - s.Scheme, - s.Host, - s.Page, - s.Title, - s.Referrer, - s.UserAgent, - s.ViewPort, - s.CreatedAt, - ).Scan(&id); err != nil { - return err - } - s.ID = &id - return nil -} - -const ( - sqlSavePageView = `insert into page_views ( -domain_id, -address, -scheme, -host, -page, -title, -referrer, -user_agent, -view_port, -created_at -) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) -returning id -` - sqlFilterPageViews = `select -domain_id, -address, -scheme, -host, -page, -title, -referrer, -user_agent, -view_port, -created_at -from page_views -` -) diff --git a/models/query_args.go b/query_args.go index 4484b01..2676d74 100644 --- a/models/query_args.go +++ b/query_args.go @@ -1,4 +1,4 @@ -package models +package sws import ( "strconv" diff --git a/models/queryer.go b/queryer.go index 2e37ef6..1f0b8d7 100644 --- a/models/queryer.go +++ b/queryer.go @@ -1,4 +1,4 @@ -package models +package sws import ( "database/sql" diff --git a/sql/gen.go b/sql/gen.go new file mode 100644 index 0000000..ca3d962 --- /dev/null +++ b/sql/gen.go @@ -0,0 +1,117 @@ +package main + +//go:generate go run gen.go + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/template" +) + +func main() { + tmpls := make(map[string][]string) + + err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to access path %q: %v\n", path, err) + return err + } + details := strings.SplitN(path, ".", 2) + if len(details) != 2 || details[1] != "sql" { + //fmt.Fprintf(os.Stderr, "Skipping non-template: %+v \n", info.Name()) + return nil + } + pathDetails := strings.SplitN(details[0], "/", 2) + driver := pathDetails[0] + //name := pathDetails[1] + + fmt.Fprintf(os.Stderr, "Processing file: %s\n", path) + input, err := os.Open(path) + if err != nil { + return err + } + var out bytes.Buffer + + encoder := base64.NewEncoder(base64.StdEncoding, &out) + compressor := zlib.NewWriter(encoder) + _, err = io.Copy(compressor, input) + + input.Close() + compressor.Close() + encoder.Close() + + if err != nil { + return err + } + tmpls[driver] = append(tmpls[driver], out.String()) + return nil + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load templates: %s\n", err) + } + + // Now generate our encoded template file + metaTmpl, err := template.New("meta").Parse(tmplData) + if err != nil { + fmt.Printf("Failed to parse template: %s", err) + } else { + metaTmpl.Execute(os.Stdout, tmpls) + } +} + +var tmplData = `package main + +// Automatically generated, don't bother editing. + +import ( + "bytes" + "strings" + "compress/zlib" + "encoding/base64" + "fmt" + "io" +) + +// migrations holds a set of base64 encoded SQL scripts. +var migrations = map[string][]string{ +{{- range $driver, $schema := . }} + "{{$driver}}": []string{ +{{- range $b := $schema }} + "{{ $b }}", +{{ end }} + }, +{{ end }} +} + +func decodeMigrations(driver string) ([]string, error) { + data, ok := migrations[driver] + if !ok { + return nil, fmt.Errorf("no migrations for driver %q", driver) + } + + out := make([]string, len(data)) + + for i, b := range data { + decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(b)) + decompressor, err := zlib.NewReader(decoder) + if err != nil { + return nil, fmt.Errorf("unable to decode migration: %w", err) + } + var buf bytes.Buffer + _, err = io.Copy(&buf, decompressor) + if err != nil { + return nil, fmt.Errorf("failed to decompress migration: %w", err) + } + out[i] = buf.String() + } + + return out, nil +} +` diff --git a/sql/sqlite3/01_domains.sql b/sql/sqlite3/01_domains.sql new file mode 100644 index 0000000..feb40bb --- /dev/null +++ b/sql/sqlite3/01_domains.sql @@ -0,0 +1,8 @@ +create table domains ( + id integer primary key autoincrement, + name varchar not null check(length(name) >= 4 and length(name) <= 255), + description varchar null, + enabled integer not null default 0, + created_at timestamp not null check(created_at = strftime('%Y-%m-%d %H:%M:%S', created_at)), + updated_at timestamp not null check(updated_at = strftime('%Y-%m-%d %H:%M:%S', updated_at)) +); diff --git a/sql/sqlite3/02_hits.sql b/sql/sqlite3/02_hits.sql new file mode 100644 index 0000000..34f54cf --- /dev/null +++ b/sql/sqlite3/02_hits.sql @@ -0,0 +1,14 @@ +create table hits ( + domain_id integer check(domain_id >0), + addr varchar not null, + scheme varchar not null, + host varchar not null, + path varchar not null, + query varchar null, + title varchar null, + referrer varchar null, + user_agent varchar null, + view_port varchar null, + created_at timestamp not null check(created_at = strftime('%Y-%m-%d %H:%M:%S', created_at)) +); +create index "hits#domain_id#created" on hits(domain_id, created_at); diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..8e4835e --- /dev/null +++ b/utils.go @@ -0,0 +1,14 @@ +package sws + +import "time" + +func ptrString(s string) *string { + if s == "" { + return nil + } + return &s +} + +func ptrTime(t time.Time) *time.Time { + return &t +} |
