aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore9
-rw-r--r--Makefile53
-rw-r--r--client/sws.js137
-rw-r--r--client/test.html19
-rw-r--r--cmd/server/handlers.go60
-rw-r--r--cmd/server/main.go103
-rw-r--r--counter/gen.go73
-rw-r--r--counter/sws.js53
-rw-r--r--domain.go26
-rw-r--r--domain_store.go (renamed from models/domain.go)11
-rw-r--r--go.mod18
-rw-r--r--go.sum34
-rw-r--r--hit.go94
-rw-r--r--hit_store.go82
-rw-r--r--logger.go17
-rw-r--r--migrations/001_page_views.sql26
-rw-r--r--models/page_view.go133
-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.go117
-rw-r--r--sql/sqlite3/01_domains.sql8
-rw-r--r--sql/sqlite3/02_hits.sql14
-rw-r--r--utils.go14
23 files changed, 721 insertions, 384 deletions
diff --git a/.gitignore b/.gitignore
index 805e203..8203f40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/Makefile b/Makefile
index ba76e61..dd27774 100644
--- a/Makefile
+++ b/Makefile
@@ -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`
)
diff --git a/go.mod b/go.mod
index 256deb6..ecad963 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 968928b..a56efb7 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/hit.go b/hit.go
new file mode 100644
index 0000000..de3bb54
--- /dev/null
+++ b/hit.go
@@ -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
+}