├── .env.example ├── .github ├── FUNDING.yml └── workflows │ ├── goimports.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assets └── src │ ├── 404.html │ ├── css │ ├── chart.css │ ├── fonts-overpass.css │ ├── pikaday.css │ ├── styles.css │ └── util.css │ ├── fonts │ ├── overpass-bold-italic.eot │ ├── overpass-bold-italic.ttf │ ├── overpass-bold-italic.woff │ ├── overpass-bold-italic.woff2 │ ├── overpass-bold.eot │ ├── overpass-bold.ttf │ ├── overpass-bold.woff │ ├── overpass-bold.woff2 │ ├── overpass-extrabold-italic.eot │ ├── overpass-extrabold-italic.ttf │ ├── overpass-extrabold-italic.woff │ ├── overpass-extrabold-italic.woff2 │ ├── overpass-extrabold.eot │ ├── overpass-extrabold.ttf │ ├── overpass-extrabold.woff │ ├── overpass-extrabold.woff2 │ ├── overpass-extralight-italic.eot │ ├── overpass-extralight-italic.ttf │ ├── overpass-extralight-italic.woff │ ├── overpass-extralight-italic.woff2 │ ├── overpass-extralight.eot │ ├── overpass-extralight.ttf │ ├── overpass-extralight.woff │ ├── overpass-extralight.woff2 │ ├── overpass-heavy-italic.eot │ ├── overpass-heavy-italic.ttf │ ├── overpass-heavy-italic.woff │ ├── overpass-heavy-italic.woff2 │ ├── overpass-heavy.eot │ ├── overpass-heavy.ttf │ ├── overpass-heavy.woff │ ├── overpass-heavy.woff2 │ ├── overpass-italic.eot │ ├── overpass-italic.ttf │ ├── overpass-italic.woff │ ├── overpass-italic.woff2 │ ├── overpass-light-italic.eot │ ├── overpass-light-italic.ttf │ ├── overpass-light-italic.woff │ ├── overpass-light-italic.woff2 │ ├── overpass-light.eot │ ├── overpass-light.ttf │ ├── overpass-light.woff │ ├── overpass-light.woff2 │ ├── overpass-regular.eot │ ├── overpass-regular.ttf │ ├── overpass-regular.woff │ ├── overpass-regular.woff2 │ ├── overpass-semibold-italic.eot │ ├── overpass-semibold-italic.ttf │ ├── overpass-semibold-italic.woff │ ├── overpass-semibold-italic.woff2 │ ├── overpass-semibold.eot │ ├── overpass-semibold.ttf │ ├── overpass-semibold.woff │ ├── overpass-semibold.woff2 │ ├── overpass-thin-italic.eot │ ├── overpass-thin-italic.ttf │ ├── overpass-thin-italic.woff │ ├── overpass-thin-italic.woff2 │ ├── overpass-thin.eot │ ├── overpass-thin.ttf │ ├── overpass-thin.woff │ └── overpass-thin.woff2 │ ├── img │ ├── fathom.jpg │ └── favicon.png │ ├── index.html │ └── js │ ├── components │ ├── Chart.js │ ├── CountWidget.js │ ├── DatePicker.js │ ├── Gearwheel.js │ ├── LoginForm.js │ ├── LogoutButton.js │ ├── Notification.js │ ├── Pikadayer.js │ ├── Realtime.js │ ├── Sidebar.js │ ├── SiteSettings.js │ ├── SiteSwitcher.js │ └── Table.js │ ├── lib │ ├── client.js │ ├── numbers.js │ └── util.js │ ├── pages │ ├── dashboard.js │ └── login.js │ ├── script.js │ └── tracker.js ├── docker-compose.yml ├── docs ├── Configuration.md ├── FAQ.md ├── Installation instructions.md ├── README.md ├── Updating to the latest version.md └── misc │ ├── Heroku.md │ ├── NGINX.md │ └── Systemd.md ├── go.mod ├── go.sum ├── gulpfile.js ├── main.go ├── package-lock.json ├── package.json └── pkg ├── aggregator ├── aggregator.go ├── aggregator_test.go ├── bindata.go ├── blacklist.go ├── blacklist_test.go ├── data │ └── blacklist.txt └── store.go ├── api ├── api.go ├── auth.go ├── auth_test.go ├── collect.go ├── collect_test.go ├── health.go ├── http.go ├── http_test.go ├── page_stats.go ├── params.go ├── params_test.go ├── referrer_stats.go ├── routes.go ├── site_stats.go └── sites.go ├── cli ├── cli.go ├── server.go ├── stats.go └── user.go ├── config ├── config.go └── config_test.go ├── datastore ├── datastore.go └── sqlstore │ ├── config.go │ ├── config_test.go │ ├── hostnames.go │ ├── migrations │ ├── mysql │ │ ├── 10_alter_stats_table_constraints.sql │ │ ├── 11_add_pageview_finished_column.sql │ │ ├── 12_create_hostnames_table.sql │ │ ├── 13_create_unique_hostname_index.sql │ │ ├── 14_create_pathnames_table.sql │ │ ├── 15_create_unique_pathname_index.sql │ │ ├── 16_fill_hostnames_table.sql │ │ ├── 17_fill_pathnames_table.sql │ │ ├── 18_alter_page_stats_table.sql │ │ ├── 19_alter_referrer_stats_table.sql │ │ ├── 1_initial_tables.sql │ │ ├── 20_recreate_stats_indices.sql │ │ ├── 21_alter_page_stats_table.sql │ │ ├── 22_alter_site_stats_table.sql │ │ ├── 23_alter_referrer_stats_table.sql │ │ ├── 24_recreate_stat_table_indices.sql │ │ ├── 2_known_durations_column.sql │ │ ├── 3_referrer_group_column.sql │ │ ├── 4_pageview_id_column.sql │ │ ├── 5_create_sites_table.sql │ │ ├── 6_add_site_tracking_id_column_to_pageviews_table.sql │ │ ├── 7_add_site_id_to_site_stats_table.sql │ │ ├── 8_add_site_id_to_page_stats_table.sql │ │ └── 9_add_site_id_to_referrer_stats_table.sql │ ├── postgres │ │ ├── 10_alter_numeric_column_precision.sql │ │ ├── 11_alter_stats_table_constraints.sql │ │ ├── 12_add_pageview_finished_column.sql │ │ ├── 13_create_hostnames_table.sql │ │ ├── 14_create_unique_hostname_index.sql │ │ ├── 15_create_pathnames_table.sql │ │ ├── 16_create_unique_pathname_index.sql │ │ ├── 17_fill_hostnames_table.sql │ │ ├── 18_fill_pathnames_table.sql │ │ ├── 19_alter_page_stats_table.sql │ │ ├── 1_initial_tables.sql │ │ ├── 20_alter_referrer_stats_table.sql │ │ ├── 21_recreate_stats_indices.sql │ │ ├── 22_alter_page_stats_table.sql │ │ ├── 23_alter_referrer_stats_table.sql │ │ ├── 24_alter_site_stats_table.sql │ │ ├── 25_recreate_stat_table_indices.sql │ │ ├── 26_alter_pageviews_table.sql │ │ ├── 2_known_durations_column.sql │ │ ├── 3_referrer_group_column.sql │ │ ├── 4_pageview_id_column.sql │ │ ├── 5_create_sites_table.sql │ │ ├── 6_add_site_tracking_id_column_to_pageviews_table.sql │ │ ├── 7_add_site_id_to_site_stats_table.sql │ │ ├── 8_add_site_id_to_page_stats_table.sql │ │ └── 9_add_site_id_to_referrer_stats_table.sql │ └── sqlite3 │ │ ├── 10_alter_stats_table_constraints.sql │ │ ├── 11_add_pageview_finished_column.sql │ │ ├── 12_create_hostnames_table.sql │ │ ├── 13_create_unique_hostname_index.sql │ │ ├── 14_create_pathnames_table.sql │ │ ├── 15_create_unique_pathname_index.sql │ │ ├── 15_vacuum.sql │ │ ├── 16_fill_hostnames_table.sql │ │ ├── 17_fill_pathnames_table.sql │ │ ├── 18_alter_page_stats_table.sql │ │ ├── 19_alter_referrer_stats_table.sql │ │ ├── 1_initial_tables.sql │ │ ├── 20_recreate_stats_indices.sql │ │ ├── 21_alter_page_stats_table.sql │ │ ├── 22_alter_site_stats_table.sql │ │ ├── 23_alter_referrer_stats_table.sql │ │ ├── 24_recreate_stat_table_indices.sql │ │ ├── 25_vacuum.sql │ │ ├── 26_sites_id_autoinc.sql │ │ ├── 2_known_durations_column.sql │ │ ├── 3_referrer_group_column.sql │ │ ├── 4_pageview_id_column.sql │ │ ├── 5_create_sites_table.sql │ │ ├── 6_add_site_tracking_id_column_to_pageviews_table.sql │ │ ├── 7_add_site_id_to_site_stats_table.sql │ │ ├── 8_add_site_id_to_page_stats_table.sql │ │ └── 9_add_site_id_to_referrer_stats_table.sql │ ├── page_stats.go │ ├── pageviews.go │ ├── pathnames.go │ ├── referrer_stats.go │ ├── seed │ └── pageviews.sql │ ├── site_stats.go │ ├── sites.go │ ├── sqlstore.go │ └── users.go └── models ├── page_stats.go ├── page_stats_test.go ├── pageview.go ├── referrer_stats.go ├── referrer_stats_test.go ├── site.go ├── site_stats.go ├── site_stats_test.go ├── user.go └── user_test.go /.env.example: -------------------------------------------------------------------------------- 1 | FATHOM_GZIP=true 2 | FATHOM_DEBUG=true 3 | FATHOM_DATABASE_DRIVER="sqlite3" 4 | FATHOM_DATABASE_NAME="./fathom.db" 5 | FATHOM_DATABASE_USER="" 6 | FATHOM_DATABASE_PASSWORD="" 7 | FATHOM_DATABASE_HOST="" 8 | FATHOM_SECRET="abcdefghijklmnopqrstuvwxyz1234567890" 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=LJ5WZVA9ER9GJ'] 3 | -------------------------------------------------------------------------------- /.github/workflows/goimports.yml: -------------------------------------------------------------------------------- 1 | name: Check imports 2 | on: [ push, pull_request ] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/setup-go@v2 8 | with: 9 | go-version: '1.19' 10 | - uses: actions/checkout@master 11 | - name: Check imports 12 | shell: bash 13 | run: | 14 | export PATH=$(go env GOPATH)/bin:$PATH 15 | go get golang.org/x/tools/cmd/goimports 16 | diff -u <(echo -n) <(goimports -d .) 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | - name: Set up Go 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: '1.19' 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v3 20 | with: 21 | # either 'goreleaser' (default) or 'goreleaser-pro' 22 | distribution: goreleaser 23 | version: latest 24 | args: release --rm-dist -p 1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/setup-go@v2 9 | with: 10 | go-version: '1.19' 11 | - uses: actions/checkout@master 12 | - name: Run tests 13 | run: | 14 | make test 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env* 3 | !.env.example 4 | coverage.out 5 | build 6 | dist 7 | *.db 8 | fathom 9 | !cmd/fathom 10 | 11 | assets/build 12 | assets/dist 13 | bin/ 14 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # Documentation http://goreleaser.com 2 | before: 3 | hooks: 4 | - make assets/dist 5 | - go install github.com/gobuffalo/packr/v2/packr2@latest 6 | builds: 7 | - main: main.go 8 | goos: 9 | - linux 10 | goarch: 11 | - amd64 12 | - 386 13 | - arm64 14 | ldflags: 15 | - -extldflags "-static" -s -w -X "main.version={{.Version}}" -X "main.commit={{.Commit}}" -X "main.date={{.Date}}" 16 | hooks: 17 | pre: packr2 18 | post: packr2 clean 19 | checksum: 20 | name_template: 'checksums.txt' 21 | snapshot: 22 | name_template: "{{ .Tag }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - '^docs:' 28 | - '^test:' 29 | release: 30 | draft: true 31 | mode: append 32 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@usefathom.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS assetbuilder 2 | WORKDIR /app 3 | COPY package*.json ./ 4 | COPY gulpfile.js ./ 5 | COPY assets/ ./assets/ 6 | RUN npm install && NODE_ENV=production ./node_modules/gulp/bin/gulp.js 7 | 8 | FROM golang:latest AS binarybuilder 9 | RUN go install github.com/gobuffalo/packr/v2/packr2@latest 10 | WORKDIR /go/src/github.com/usefathom/fathom 11 | COPY . /go/src/github.com/usefathom/fathom 12 | COPY --from=assetbuilder /app/assets/build ./assets/build 13 | ARG GOARCH=amd64 14 | ARG GOOS=linux 15 | RUN make ARCH=${GOARCH} OS=${GOOS} docker 16 | 17 | FROM alpine:latest 18 | EXPOSE 8080 19 | HEALTHCHECK --retries=10 CMD ["wget", "-qO-", "http://localhost:8080/health"] 20 | RUN apk add --update --no-cache bash ca-certificates 21 | WORKDIR /app 22 | COPY --from=binarybuilder /go/src/github.com/usefathom/fathom/fathom . 23 | CMD ["./fathom", "server"] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Conva Ventures Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXECUTABLE := fathom 2 | LDFLAGS += -extldflags "-static" -X "main.version=$(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')" -X "main.commit=$(shell git rev-parse HEAD)" -X "main.date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" 3 | MAIN_PKG := ./main.go 4 | PACKAGES ?= $(shell go list ./... | grep -v /vendor/) 5 | ASSET_SOURCES ?= $(shell find assets/src/. -type f) 6 | GO_SOURCES ?= $(shell find . -name "*.go" -type f) 7 | GOPATH=$(shell go env GOPATH) 8 | ARCH := amd64 9 | OS := linux 10 | 11 | .PHONY: all 12 | all: build 13 | 14 | .PHONY: build 15 | build: $(EXECUTABLE) 16 | 17 | $(EXECUTABLE): $(GO_SOURCES) assets/build 18 | go build -o $@ $(MAIN_PKG) 19 | 20 | .PHONY: docker 21 | docker: $(GO_SOURCES) $(GOPATH)/bin/packr2 22 | GOOS=$(OS) GOARCH=$(ARCH) $(GOPATH)/bin/packr2 build -v -ldflags '-w $(LDFLAGS)' -o $(EXECUTABLE) $(MAIN_PKG) 23 | 24 | $(GOPATH)/bin/packr2: 25 | GOBIN=$(GOPATH)/bin go install github.com/gobuffalo/packr/v2/packr2@latest 26 | 27 | .PHONY: npm 28 | npm: 29 | if [ ! -d "node_modules" ]; then npm install; fi 30 | 31 | assets/build: $(ASSET_SOURCES) npm 32 | ./node_modules/gulp/bin/gulp.js 33 | 34 | assets/dist: $(ASSET_SOURCES) npm 35 | NODE_ENV=production ./node_modules/gulp/bin/gulp.js 36 | 37 | .PHONY: clean 38 | clean: 39 | go clean -i ./... 40 | $(GOPATH)/bin/packr clean 41 | rm -rf $(EXECUTABLE) 42 | 43 | .PHONY: fmt 44 | fmt: 45 | go fmt $(PACKAGES) 46 | 47 | .PHONY: vet 48 | vet: 49 | go vet $(PACKAGES) 50 | 51 | .PHONY: errcheck 52 | errcheck: 53 | @which errcheck > /dev/null; if [ $$? -ne 0 ]; then \ 54 | go install github.com/kisielk/errcheck@latest; \ 55 | fi 56 | errcheck $(PACKAGES) 57 | 58 | .PHONY: lint 59 | lint: 60 | @which golint > /dev/null; if [ $$? -ne 0 ]; then \ 61 | go install github.com/golang/lint/golint@latest; \ 62 | fi 63 | for PKG in $(PACKAGES); do golint -set_exit_status $$PKG || exit 1; done; 64 | 65 | .PHONY: test 66 | test: 67 | for PKG in $(PACKAGES); do go test $$PKG || exit 1; done; 68 | 69 | .PHONY: referrer-spam-blacklist 70 | referrer-spam-blacklist: 71 | wget https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -O pkg/aggregator/data/blacklist.txt 72 | go-bindata -prefix "pkg/aggregator/data/" -o pkg/aggregator/bindata.go -pkg aggregator pkg/aggregator/data/ 73 | -------------------------------------------------------------------------------- /assets/src/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Not found - Fathom 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Page not found

14 |

Sorry, it seems that the requested page does not exist.

15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /assets/src/css/chart.css: -------------------------------------------------------------------------------- 1 | .box-graph { 2 | background: white; 3 | margin-left: 0 !important; 4 | } 5 | 6 | #chart * { 7 | box-sizing: content-box; 8 | } 9 | 10 | #chart .muted { 11 | fill: #98a0a6; 12 | } 13 | 14 | #chart { 15 | height: 240px; 16 | } 17 | 18 | .bar-pageviews { 19 | fill: #88ffc6; 20 | } 21 | 22 | .bar-visitors { 23 | fill: #533feb; 24 | } 25 | 26 | .axis .domain{ 27 | stroke: none; 28 | } 29 | 30 | .axis line { 31 | stroke: rgba(218, 218, 218, 0.5); 32 | } 33 | 34 | .axis text { 35 | font-size: 12px; 36 | fill: #98a0a6; 37 | } 38 | 39 | .d3-tip { 40 | font-size: 12px; 41 | color: #959da5; 42 | text-align: left; 43 | background: rgba(0,0,0,.8); 44 | border-radius: 3px; 45 | } 46 | 47 | .tip-heading { 48 | font-weight: 600; 49 | padding: 10px; 50 | line-height: 1; 51 | } 52 | 53 | .tip-content { 54 | display: flex; 55 | 56 | } 57 | 58 | .tip-content > div { 59 | padding: 5px 10px; 60 | width: 50%; 61 | display: block; 62 | flex: 1; 63 | min-width: 90px; 64 | } 65 | 66 | 67 | .tip-pageviews { 68 | border-top: 3px solid rgb(136, 255, 198); 69 | } 70 | 71 | .tip-visitors { 72 | border-top: 3px solid rgb(83, 63, 235); 73 | } 74 | 75 | .tip-number { 76 | color: #dfe2e5; 77 | font-weight: 600; 78 | } 79 | -------------------------------------------------------------------------------- /assets/src/css/util.css: -------------------------------------------------------------------------------- 1 | .small-margin { 2 | margin-top: 20px; 3 | margin-bottom: 20px; 4 | } 5 | 6 | .cf:after { 7 | content: ""; 8 | display: table; 9 | clear: both; 10 | } 11 | 12 | .ac { 13 | text-transform: uppercase; 14 | } 15 | 16 | .sm { 17 | font-size: 11px; 18 | font-weight: 500; 19 | color: #98a0a6; 20 | } 21 | 22 | @media(max-width: 600px) { 23 | .hide-on-mobile { display: none !important; } 24 | } 25 | 26 | .right { 27 | float: right; 28 | } 29 | 30 | .left { 31 | float: left; 32 | } 33 | 34 | .notification { 35 | position: fixed; 36 | top: 20px; 37 | left: 0; right: 0; 38 | text-align: center; 39 | width: 100%; 40 | } 41 | 42 | .notification .notification-error { 43 | padding: 4px; 44 | display: inline-block; 45 | background-color: #f2dede; 46 | border: 1px solid #ebccd1; 47 | } 48 | 49 | @keyframes fadeInUp { 50 | 0% { opacity: 0; transform: translateY(20px); } 51 | 100% { opacity: 1; transform: translateY(0); } 52 | } 53 | 54 | @keyframes fadeInDown { 55 | 0% { opacity: 0; transform: translateY(-20px); } 56 | 100% { opacity: 1; transform: translateY(0); } 57 | } 58 | 59 | .animated { animation-duration: .4s; animation-fill-mode: both; } 60 | 61 | .delayed_02s { animation-delay: .2s; } 62 | .delayed_03s { animation-delay: .3s; } 63 | .delayed_04s { animation-delay: .4s; } 64 | .delayed_05s { animation-delay: .5s; } 65 | .delayed_06s { animation-delay: .6s; } 66 | 67 | .fadeInUp { animation-name: fadeInUp; } 68 | .fadeInDown { animation-name: fadeInDown; } 69 | 70 | .loading { 71 | opacity: 0.6; 72 | } 73 | -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-bold.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extrabold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extrabold.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-extralight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-extralight.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-heavy.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-heavy.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-light.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-regular.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-regular.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-regular.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-regular.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-semibold.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin-italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin-italic.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin-italic.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin-italic.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin-italic.woff2 -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin.eot -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin.ttf -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin.woff -------------------------------------------------------------------------------- /assets/src/fonts/overpass-thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/fonts/overpass-thin.woff2 -------------------------------------------------------------------------------- /assets/src/img/fathom.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/img/fathom.jpg -------------------------------------------------------------------------------- /assets/src/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/usefathom/fathom/3fcc689f64d8d58533ca107d128adb87c9e4e1b5/assets/src/img/favicon.png -------------------------------------------------------------------------------- /assets/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fathom - simple website analytics 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /assets/src/js/components/CountWidget.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | import * as numbers from '../lib/numbers.js'; 5 | import { bind } from 'decko'; 6 | import classNames from 'classnames'; 7 | 8 | const duration = 600; 9 | const easeOutQuint = function (t) { return 1+(--t)*t*t*t*t }; 10 | 11 | class CountWidget extends Component { 12 | componentWillReceiveProps(newProps, newState) { 13 | if(newProps.value == this.props.value) { 14 | return; 15 | } 16 | 17 | this.countUp(this.props.value || 0, newProps.value); 18 | } 19 | 20 | // TODO: Move to component of its own 21 | @bind 22 | countUp(fromValue, toValue) { 23 | const format = this.formatValue.bind(this); 24 | const startValue = isFinite(fromValue) ? fromValue : 0; 25 | const numberEl = this.numberEl; 26 | const diff = toValue - startValue; 27 | let startTime = performance.now(); 28 | 29 | const tick = function(t) { 30 | let progress = Math.min(( t - startTime ) / duration, 1); 31 | let newValue = startValue + (easeOutQuint(progress) * diff); 32 | numberEl.textContent = format(newValue) 33 | 34 | if(progress < 1) { 35 | window.requestAnimationFrame(tick); 36 | } 37 | } 38 | 39 | window.requestAnimationFrame(tick); 40 | } 41 | 42 | @bind 43 | formatValue(value) { 44 | let formattedValue = "-"; 45 | 46 | if(isFinite(value)) { 47 | switch(this.props.format) { 48 | case "percentage": 49 | formattedValue = numbers.formatPercentage(value) 50 | break; 51 | 52 | default: 53 | case "number": 54 | formattedValue = numbers.formatPretty(Math.round(value)) 55 | break; 56 | 57 | case "duration": 58 | formattedValue = numbers.formatDuration(value) 59 | break; 60 | } 61 | } 62 | 63 | return formattedValue; 64 | } 65 | 66 | render(props, state) { 67 | return ( 68 |
69 |
{props.title}
70 |
{ this.numberEl = e; }}>{this.formatValue(props.value)}
71 |
72 | ) 73 | } 74 | } 75 | 76 | export default CountWidget 77 | -------------------------------------------------------------------------------- /assets/src/js/components/Gearwheel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | 5 | class Gearwheel extends Component { 6 | render(props, state) { 7 | // don't show if visible prop is false 8 | if(!props.visible) { 9 | return ''; 10 | } 11 | 12 | return ( 13 |
  • 14 | 15 |
  • 16 | ) 17 | } 18 | } 19 | 20 | export default Gearwheel 21 | -------------------------------------------------------------------------------- /assets/src/js/components/LoginForm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, render, Component } from 'preact'; 4 | import Client from '../lib/client.js'; 5 | import Notification from '../components/Notification.js'; 6 | import { bind } from 'decko'; 7 | 8 | class LoginForm extends Component { 9 | 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | email: '', 14 | password: '', 15 | message: '' 16 | } 17 | } 18 | 19 | @bind 20 | handleSubmit(e) { 21 | e.preventDefault(); 22 | this.setState({ message: '' }); 23 | 24 | Client.request('session', { 25 | method: "POST", 26 | data: { 27 | email: this.state.email, 28 | password: this.state.password, 29 | } 30 | }).then((r) => { 31 | this.props.onSuccess() 32 | }).catch((e) => { 33 | this.setState({ 34 | message: e.code === 'invalid_credentials' ? "Invalid username or password" : e.message, 35 | password: '' 36 | }); 37 | }); 38 | } 39 | 40 | @bind 41 | updatePassword(e) { 42 | this.setState({ password: e.target.value }); 43 | } 44 | 45 | @bind 46 | updateEmail(e) { 47 | this.setState({ email: e.target.value }); 48 | } 49 | 50 | @bind 51 | clearMessage() { 52 | this.setState({ message: '' }); 53 | } 54 | 55 | render(props, state) { 56 | return ( 57 |
    58 |
    59 | 60 | 61 |
    62 | 63 |
    64 | 65 | 66 |
    67 | 68 |
    69 | 70 | 71 | 72 | ) 73 | } 74 | } 75 | 76 | export default LoginForm 77 | -------------------------------------------------------------------------------- /assets/src/js/components/LogoutButton.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | import Client from '../lib/client.js'; 5 | import { bind } from 'decko'; 6 | 7 | class LogoutButton extends Component { 8 | 9 | @bind 10 | handleSubmit(e) { 11 | e.preventDefault(); 12 | 13 | Client.request('session', { 14 | method: "DELETE", 15 | }).then((r) => { this.props.onSuccess() }) 16 | } 17 | 18 | render() { 19 | if(document.cookie.indexOf('auth') < 0) { 20 | return '' 21 | } 22 | 23 | return ( 24 | Sign out 25 | ) 26 | } 27 | } 28 | 29 | export default LogoutButton 30 | -------------------------------------------------------------------------------- /assets/src/js/components/Notification.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { h, Component } from 'preact'; 4 | import { bind } from 'decko'; 5 | 6 | class Notification extends Component { 7 | constructor(props) { 8 | super(props) 9 | 10 | this.state = { 11 | message: props.message, 12 | kind: props.kind || 'error' 13 | } 14 | this.timeout = 0 15 | } 16 | 17 | componentWillReceiveProps(newProps) { 18 | if(newProps.message === this.state.message) { 19 | return; 20 | } 21 | 22 | this.setState({ 23 | message: newProps.message, 24 | kind: newProps.kind || 'error' 25 | }) 26 | 27 | window.clearTimeout(this.timeout) 28 | this.timeout = window.setTimeout(this.props.onDismiss, 5000) 29 | } 30 | 31 | render(props, state) { 32 | if(state.message === '') { 33 | return '' 34 | } 35 | 36 | return ( 37 |
    38 |
    39 | {state.message} 40 |
    41 |
    42 | )} 43 | } 44 | 45 | export default Notification 46 | -------------------------------------------------------------------------------- /assets/src/js/components/Pikadayer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Pikaday from 'pikaday'; 4 | import { h, Component } from 'preact'; 5 | 6 | class Pikadayer extends Component { 7 | componentDidMount() { 8 | this.pikaday = new Pikaday({ 9 | field: this.base, 10 | onSelect: this.props.onSelect, 11 | position: 'bottom right', 12 | }) 13 | } 14 | 15 | componentWillReceiveProps(newProps) { 16 | // make sure pikaday updates if we set a date using one of our presets 17 | if(this.pikaday && newProps.value !== this.props.value) { 18 | this.pikaday.setDate(newProps.value, true) 19 | } 20 | } 21 | 22 | componentWillUnmount() { 23 | this.pikaday.destroy() 24 | } 25 | 26 | render(props) { 27 | return 28 | } 29 | } 30 | 31 | export default Pikadayer 32 | -------------------------------------------------------------------------------- /assets/src/js/components/Realtime.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | import Client from '../lib/client.js'; 5 | import { bind } from 'decko'; 6 | import * as numbers from '../lib/numbers.js'; 7 | 8 | class Realtime extends Component { 9 | 10 | constructor(props) { 11 | super(props) 12 | 13 | this.state = { 14 | count: 0 15 | } 16 | } 17 | 18 | componentDidMount() { 19 | this.fetchData(this.props.siteId); 20 | this.interval = window.setInterval(this.handleIntervalEvent, 15000); 21 | } 22 | 23 | componentWillUnmount() { 24 | window.clearInterval(this.interval); 25 | } 26 | 27 | componentWillReceiveProps(newProps, newState) { 28 | if(!this.paramsChanged(this.props, newProps)) { 29 | return; 30 | } 31 | 32 | this.fetchData(newProps.siteId) 33 | } 34 | 35 | paramsChanged(o, n) { 36 | return o.siteId != n.siteId; 37 | } 38 | 39 | @bind 40 | setDocumentTitle() { 41 | // update document title 42 | let visitorText = this.state.count == 1 ? 'visitor' : 'visitors'; 43 | document.title = ( this.state.count > 0 ? `${numbers.formatPretty(this.state.count)} current ${visitorText} — Fathom` : 'Fathom' ); 44 | } 45 | 46 | @bind 47 | handleIntervalEvent() { 48 | this.fetchData(this.props.siteId) 49 | } 50 | 51 | @bind 52 | fetchData(siteId) { 53 | let url = `/sites/${siteId}/stats/site/realtime` 54 | Client.request(url) 55 | .then((d) => { 56 | this.setState({ count: d }) 57 | this.setDocumentTitle(); 58 | }) 59 | } 60 | 61 | render(props, state) { 62 | let visitorText = state.count == 1 ? 'visitor' : 'visitors'; 63 | return ( 64 | {numbers.formatPretty(state.count)} current {visitorText} 65 | ) 66 | } 67 | } 68 | 69 | export default Realtime 70 | -------------------------------------------------------------------------------- /assets/src/js/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | import Client from '../lib/client.js'; 5 | import { bind } from 'decko'; 6 | import CountWidget from './CountWidget.js'; 7 | 8 | 9 | class Sidebar extends Component { 10 | constructor(props) { 11 | super(props) 12 | 13 | this.state = { 14 | data: {}, 15 | loading: false, 16 | } 17 | } 18 | 19 | componentWillReceiveProps(newProps, newState) { 20 | if(!this.paramsChanged(this.props, newProps)) { 21 | return; 22 | } 23 | 24 | this.fetchData(newProps); 25 | } 26 | 27 | paramsChanged(o, n) { 28 | return o.siteId != n.siteId || o.dateRange[0] != n.dateRange[0] || o.dateRange[1] != n.dateRange[1]; 29 | } 30 | 31 | @bind 32 | fetchData(props) { 33 | this.setState({ loading: true }) 34 | let before = props.dateRange[1]/1000; 35 | let after = props.dateRange[0]/1000; 36 | 37 | Client.request(`/sites/${props.siteId}/stats/site/agg?before=${before}&after=${after}`) 38 | .then((data) => { 39 | // request finished; check if timestamp range is still the one user wants to see 40 | if(this.paramsChanged(props, this.props)) { 41 | return; 42 | } 43 | 44 | // Make sure we always show at least 1 visitor when there are pageviews 45 | if ( data.Visitors == 0 && data.Pageviews > 0 ) { 46 | data.Visitors = 1 47 | } 48 | 49 | this.setState({ 50 | loading: false, 51 | data: data 52 | }) 53 | }) 54 | } 55 | 56 | render(props, state) { 57 | return ( 58 |
    59 | 60 | 61 | 62 | 63 |
    64 | ) 65 | } 66 | } 67 | 68 | export default Sidebar 69 | -------------------------------------------------------------------------------- /assets/src/js/components/SiteSwitcher.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, Component } from 'preact'; 4 | import { bind } from 'decko'; 5 | import { hashParams } from "../lib/util"; 6 | 7 | 8 | 9 | function arrayToQueryString(array_in){ 10 | var out = new Array(); 11 | 12 | for(var key in array_in){ 13 | out.push(key + '=' + encodeURIComponent(array_in[key])); 14 | } 15 | 16 | return out.join('&'); 17 | } 18 | class SiteSwitcher extends Component { 19 | constructor() { 20 | super(); 21 | this.state = { 22 | isExpanded: false 23 | }; 24 | } 25 | 26 | @bind 27 | selectSite(evt) { 28 | let itemId = evt.target.getAttribute("data-id") 29 | this.props.sites.some((s) => { 30 | if (s.id != itemId) { 31 | return false; 32 | } 33 | let params = hashParams() 34 | params["site"] = s.id 35 | window.history.replaceState(this.state, null, `#!${arrayToQueryString(params)}`) 36 | this.props.onChange(s) 37 | return true; 38 | }) 39 | } 40 | 41 | @bind 42 | addSite() { 43 | this.props.onAdd({ id: 1, name: "New site", unsaved: true }) 44 | } 45 | 46 | @bind 47 | expand() { 48 | this.setState({ 49 | isExpanded: true 50 | }); 51 | } 52 | 53 | @bind 54 | collapse() { 55 | this.setState({ 56 | isExpanded: false 57 | }); 58 | } 59 | 60 | @bind 61 | toggleExpanded() { 62 | this.setState({ 63 | isExpanded: !this.state.isExpanded 64 | }); 65 | } 66 | 67 | render(props, state) { 68 | // show nothing if there is only 1 site and no option to add additional sites 69 | if(!props.showAdd && props.sites.length == 1) { 70 | return ''; 71 | } 72 | 73 | // otherwise, render list of sites + add button 74 | return ( 75 |
  • 76 | {props.selectedSite.name} 77 | 81 |
  • 82 | ) 83 | } 84 | } 85 | 86 | export default SiteSwitcher 87 | -------------------------------------------------------------------------------- /assets/src/js/lib/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Client = {}; 4 | Client.request = function(url, args) { 5 | args = args || {}; 6 | args.credentials = 'same-origin' 7 | args.headers = args.headers || {}; 8 | args.headers['Accept'] = 'application/json'; 9 | 10 | if( args.method && args.method === 'POST') { 11 | args.headers['Content-Type'] = 'application/json'; 12 | 13 | if(args.data) { 14 | if( typeof(args.data) !== "string") { 15 | args.data = JSON.stringify(args.data) 16 | } 17 | args.body = args.data 18 | delete args.data 19 | } 20 | } 21 | 22 | // trim leading slash from URL 23 | url = (url[0] === '/') ? url.substring(1) : url; 24 | 25 | return window.fetch(`api/${url}`, args) 26 | .then(handleRequestErrors) 27 | .then(parseJSON) 28 | .then(parseData) 29 | } 30 | 31 | function handleRequestErrors(r) { 32 | // if response is not JSON (eg timeout), throw a generic error 33 | if (! r.ok && r.headers.get("Content-Type") !== "application/json") { 34 | throw { code: "request_error", message: "An error occurred" } 35 | } 36 | 37 | return r 38 | } 39 | 40 | function parseJSON(r) { 41 | return r.json() 42 | } 43 | 44 | function parseData(d) { 45 | 46 | // if JSON response contains an Error property, use that as error code 47 | // Message is generic here, so that individual components can set their own specific messages based on the error code 48 | if(d.Error) { 49 | throw { code: d.Error, message: "An error occurred" } 50 | } 51 | 52 | return d.Data 53 | } 54 | 55 | export default Client 56 | -------------------------------------------------------------------------------- /assets/src/js/lib/numbers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const M = 1000000 4 | const K = 1000 5 | const rx = new RegExp('\\.0$'); 6 | const commaRx = new RegExp('(\\d+)(\\d{3})'); 7 | 8 | function formatPretty(num) { 9 | let decimals = 0; 10 | 11 | if (num >= M) { 12 | num /= M 13 | decimals = 3 - ((Math.round(num) + "").length) || 0; 14 | return (num.toFixed(decimals > -1 ? decimals : 0).replace(rx, '') + 'M').replace('.00', ''); 15 | } 16 | 17 | if (num >= (K * 10)) { 18 | num /= K 19 | decimals = 3 - ((Math.round(num) + "").length) || 0; 20 | return num.toFixed(decimals).replace(rx, '') + 'K'; 21 | } 22 | 23 | return formatWithComma(num); 24 | } 25 | 26 | function formatWithComma(nStr) { 27 | nStr += ''; 28 | 29 | if(nStr.length < 4 ) { 30 | return nStr; 31 | } 32 | 33 | var x = nStr.split('.'); 34 | var x1 = x[0]; 35 | var x2 = x.length > 1 ? '.' + x[1] : ''; 36 | while (commaRx.test(x1)) { 37 | x1 = x1.replace(commaRx, '$1' + ',' + '$2'); 38 | } 39 | return x1 + x2; 40 | } 41 | 42 | function formatDuration(seconds) { 43 | seconds = Math.round(seconds); 44 | var date = new Date(null); 45 | date.setSeconds(seconds); // specify value for SECONDS here 46 | return date.toISOString().substr(14, 5); 47 | } 48 | 49 | function formatPercentage(p) { 50 | return Math.round(p*100) + "%"; 51 | } 52 | 53 | export { 54 | formatPretty, 55 | formatWithComma, 56 | formatDuration, 57 | formatPercentage 58 | } 59 | -------------------------------------------------------------------------------- /assets/src/js/lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // convert object to query string 4 | function stringifyObject(json) { 5 | var keys = Object.keys(json); 6 | 7 | return '?' + 8 | keys.map(function (k) { 9 | return encodeURIComponent(k) + '=' + 10 | encodeURIComponent(json[k]); 11 | }).join('&'); 12 | } 13 | 14 | function randomString(n) { 15 | var s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 16 | return Array(n).join().split(',').map(() => s.charAt(Math.floor(Math.random() * s.length))).join(''); 17 | } 18 | 19 | function hashParams() { 20 | var params = {}, 21 | match, 22 | matches = window.location.hash.substring(2).split("&"); 23 | 24 | for (var i = 0; i < matches.length; i++) { 25 | match = matches[i].split('=') 26 | params[match[0]] = decodeURIComponent(match[1]); 27 | } 28 | 29 | return params; 30 | } 31 | 32 | export { 33 | randomString, 34 | stringifyObject, 35 | hashParams 36 | } 37 | -------------------------------------------------------------------------------- /assets/src/js/pages/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, render, Component } from 'preact'; 4 | import LoginForm from '../components/LoginForm.js'; 5 | 6 | class Login extends Component { 7 | render(props, state) { 8 | return ( 9 | 15 | ) 16 | } 17 | } 18 | 19 | export default Login 20 | -------------------------------------------------------------------------------- /assets/src/js/script.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { h, render, Component } from 'preact' 4 | import Login from './pages/login.js' 5 | import Dashboard from './pages/dashboard.js' 6 | import { bind } from 'decko'; 7 | import Client from './lib/client.js'; 8 | 9 | class App extends Component { 10 | constructor(props) { 11 | super(props) 12 | 13 | this.state = { 14 | authenticated: document.cookie.indexOf('auth') > -1 15 | } 16 | 17 | this.fetchAuthStatus() 18 | } 19 | 20 | @bind 21 | fetchAuthStatus() { 22 | Client.request(`session`) 23 | .then((d) => { 24 | this.setState({ authenticated: d }) 25 | }) 26 | } 27 | 28 | @bind 29 | toggleAuth() { 30 | this.setState({ 31 | authenticated: !this.state.authenticated 32 | }) 33 | } 34 | 35 | render(props, state) { 36 | // logged-in 37 | if( state.authenticated ) { 38 | return 39 | } 40 | 41 | // logged-out 42 | return 43 | } 44 | } 45 | 46 | render(, document.getElementById('root')); 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | fathom: 4 | image: usefathom/fathom:latest 5 | ports: 6 | - "8080:8080" 7 | environment: 8 | - "FATHOM_SERVER_ADDR=:8080" 9 | - "FATHOM_GZIP=true" 10 | - "FATHOM_DEBUG=false" 11 | - "FATHOM_DATABASE_DRIVER=mysql" 12 | - "FATHOM_DATABASE_NAME=fathom" 13 | - "FATHOM_DATABASE_USER=fathom" 14 | - "FATHOM_DATABASE_PASSWORD=password01" 15 | - "FATHOM_DATABASE_HOST=mysql:3306" 16 | - "FATHOM_SECRET=TWEn6GXQDx45PZfmJWvyGpXf5M8b94bszgw8JcJWEd6WxgrnUkLatS34GwjPTvZb" 17 | links: 18 | - "mysql:mysql" 19 | depends_on: 20 | - mysql 21 | restart: always 22 | mysql: 23 | image: "mysql:5" 24 | volumes: 25 | - ./mysql-data:/var/lib/mysql 26 | ports: 27 | - "127.0.0.1:3306:3306" 28 | environment: 29 | - "MYSQL_ALLOW_EMPTY_PASSWORD=false" 30 | - "MYSQL_DATABASE=fathom" 31 | - "MYSQL_PASSWORD=password01" 32 | - "MYSQL_ROOT_PASSWORD=password01" 33 | - "MYSQL_USER=fathom" 34 | restart: always 35 | -------------------------------------------------------------------------------- /docs/Configuration.md: -------------------------------------------------------------------------------- 1 | # Configuring Fathom 2 | 3 | All configuration in Fathom is optional. If you supply no configuration values then Fathom will default to using a SQLite database in the current working directory. 4 | 5 | If you're already running MySQL or PostgreSQL on the server you're installing Fathom on, you'll most likely want to use one of those as your database driver. 6 | 7 | To do so, either create a `.env` file in the working directory of your Fathom application or point Fathom to your configuration file by specifying the `--config` flag when starting Fathom. 8 | 9 | ` 10 | fathom --config=/home/john/fathom.env server 11 | ` 12 | 13 | The default configuration looks like this: 14 | 15 | ``` 16 | FATHOM_GZIP=true 17 | FATHOM_DEBUG=true 18 | FATHOM_DATABASE_DRIVER="sqlite3" 19 | FATHOM_DATABASE_NAME="./fathom.db" 20 | FATHOM_DATABASE_USER="" 21 | FATHOM_DATABASE_PASSWORD="" 22 | FATHOM_DATABASE_HOST="" 23 | FATHOM_DATABASE_SSLMODE="" 24 | FATHOM_SECRET="random-secret-string" 25 | ``` 26 | 27 | ### Accepted values & defaults 28 | 29 | | Name | Default | Description 30 | | :---- | :---| :--- 31 | | FATHOM_DEBUG | `false` | If `true` will write more log messages. 32 | | FATHOM_SERVER_ADDR | `:8080` | The server address to listen on 33 | | FATHOM_GZIP | `false` | if `true` will HTTP content gzipped 34 | | FATHOM_DATABASE_DRIVER | `sqlite3` | The database driver to use: `mysql`, `postgres` or `sqlite3` 35 | | FATHOM_DATABASE_NAME | | The name of the database to connect to (or path to database file if using sqlite3) 36 | | FATHOM_DATABASE_USER | | Database connection user 37 | | FATHOM_DATABASE_PASSWORD | | Database connection password 38 | | FATHOM_DATABASE_HOST | | Database connection host 39 | | FATHOM_DATABASE_SSLMODE | | For a list of valid values, look [here for Postgres](https://www.postgresql.org/docs/9.1/static/libpq-ssl.html#LIBPQ-SSL-PROTECTION) and [here for MySQL](https://github.com/Go-SQL-Driver/MySQL/#tls) 40 | | FATHOM_DATABASE_URL | | Can be used to specify the connection string for your database, as an alternative to the previous 5 settings. 41 | | FATHOM_SECRET | | Random string, used for signing session cookies 42 | 43 | ### Common issues 44 | 45 | ##### Fathom panics when trying to connect to Postgres: `pq: SSL is not enabled on the server` 46 | 47 | This usually means that you're running Postgres without SSL enabled. Set the `FATHOM_DATABASE_SSLMODE` config option to remedy this. 48 | 49 | ``` 50 | FATHOM_DATABASE_SSLMODE=disable 51 | ``` 52 | 53 | ##### Using `FATHOM_DATABASE_URL` 54 | 55 | When using `FATHOM_DATABASE_URL` to manually specify your database connection string, there are a few important things to consider. 56 | 57 | - When using MySQL, include `?parseTime=true&loc=Local` in your DSN. 58 | - When using SQLite, include `?_loc=auto` in your DSN. 59 | 60 | Examples of valid values: 61 | 62 | ``` 63 | FATHOM_DATABASE_DRIVER=mysql 64 | FATHOM_DATABASE_URL=root:@tcp/fathom1?loc=Local&parseTime=true 65 | ``` 66 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ### How do I install Fathom on my server? 4 | 5 | Have a look at the [installation instructions](Installation%20instructions.md). 6 | 7 | --- 8 | 9 | ### How do I upgrade Fathom to the latest version? 10 | 11 | By overwriting the fathom binary with the new version. Make sure to restart any running processes for the changes to take effect. More detailed instructions can be found here: [upgrading Fathom](Updating%20to%20the%20latest%20version.md). 12 | 13 | --- 14 | 15 | ### What databases can I use with Fathom? 16 | 17 | You can use Fathom with either Postgres, MySQL or SQLite. 18 | 19 | 20 | --- 21 | 22 | ### How to configure Fathom? 23 | 24 | Create a file named `.env` in the working directory of your Fathom process. You can [find a list of accepted configuration values here](Configuration.md). 25 | 26 | --- 27 | 28 | ### How to start tracking pageviews? 29 | 30 | Add the tracking snippet to all pages on your site that you want to keep track of. Get your tracking snippet by clicking the gearwheel icon in your Fathom dashboard. 31 | 32 | --- 33 | 34 | ### What data does Fathom track? 35 | 36 | Fathom tracks no personally identifiable information on your visitors. 37 | 38 | When Fathom tracks a pageview, your visitor is assigned a random string which is used to determine whether it's a unique pageview. If your visitor visits another page on your site, the previous pageview is processed & deleted within 1 minute. If the visitor leaves your site, the pageview is processed & deleted when the session ends (in 30 minutes). 39 | 40 | If "Do Not Track" is enabled in the browser settings, Fathom respects that. 41 | 42 | --- 43 | 44 | ### Fathom is not tracking my pageviews 45 | 46 | If you have the tracking snippet in place and Fathom is still not tracking you, most likely you have `navigator.doNotTrack` enabled. Fathom is respecting your browser's "Do Not Track" setting right now. 47 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Welcome to the fathom wiki! 2 | 3 | How to: 4 | 5 | * [Install Fathom with our One-Click DigitalOcean installer](DigitalOcean%20One-Click%20Installation%20Instructions.md) 6 | * [Installing and running Fathom](Installation%20instructions.md) 7 | * [Upgrading Fathom to the latest version](Updating%20to%20the%20latest%20version.md) 8 | * [Configuration](Configuration.md) 9 | * [Frequently asked questions](FAQ.md) 10 | 11 | Misc: 12 | 13 | * [Using Fathom with Systemd](misc/Systemd.md) 14 | * [Running Fathom with NGINX](misc/NGINX.md) 15 | * [Running Fathom on Heroku](misc/Heroku.md) 16 | -------------------------------------------------------------------------------- /docs/Updating to the latest version.md: -------------------------------------------------------------------------------- 1 | # Updating Fathom to the latest version 2 | 3 | To update your existing Fathom installation to the latest version, first rename your existing Fathom installation so that we can move the new version in its place. 4 | 5 | ``` 6 | mv /usr/local/bin/fathom /usr/local/bin/fathom-old 7 | ``` 8 | 9 | Then, [download the latest release archive suitable for your system architecture from the releases page](https://github.com/usefathom/fathom/releases/latest) and place it in `/usr/local/bin`. 10 | 11 | ``` 12 | tar -C /usr/local/bin -xzf fathom_$VERSION_$OS_$ARCH.tar.gz 13 | chmod +x /usr/local/bin/fathom 14 | ``` 15 | 16 | If you now run `fathom --version`, you should see that your system is running the latest version. 17 | 18 | ``` 19 | $ fathom --version 20 | Fathom version 1.0.0 21 | ``` 22 | 23 | 24 | ### Restarting your Fathom web server 25 | 26 | To start serving up the updated Fathom web application, you will have to restart the Fathom process that is running the web server. 27 | 28 | If you've followed the [installation instructions](Installation%20instructions.md) then you are using Systemd to manage the Fathom process. Run `systemctl restart ` to restart it. 29 | 30 | ``` 31 | systemctl restart my-fathom-site 32 | ``` 33 | 34 | Alternatively, kill all running Fathom process by issuing the following command. 35 | 36 | ``` 37 | pkill fathom 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/misc/Heroku.md: -------------------------------------------------------------------------------- 1 | # Running Fathom on Heroku 2 | 3 | ### Requirements 4 | 5 | * heroku cli (logged in) 6 | * git 7 | * curl 8 | * wget 9 | * tar are required 10 | * ~ openssl is required to generate the secret_key, but you're free to use what you want 11 | 12 | ### Create the app 13 | 14 | First you need to choose a unique app name, as Heroku generates a subdomain for your app. 15 | 16 | * create the app via the buildpack 17 | 18 | ```bash 19 | heroku create UNIQUE_APP_NAME --buildpack https://github.com/ph3nx/heroku-binary-buildpack.git 20 | ``` 21 | 22 | * locally clone the newly created app 23 | 24 | ```bash 25 | heroku git:clone -a UNIQUE_APP_NAME 26 | cd UNIQUE_APP_NAME 27 | ``` 28 | 29 | * create the folder that will contain fathom 30 | 31 | ```bash 32 | mkdir -p bin 33 | ``` 34 | 35 | * download latest version of fathom for linux 64bit 36 | 37 | ```bash 38 | curl -s https://api.github.com/repos/usefathom/fathom/releases/latest \ 39 | | grep browser_download_url \ 40 | | grep linux_amd64.tar.gz \ 41 | | cut -d '"' -f 4 \ 42 | | wget -qi - -O- \ 43 | | tar --directory bin -xz - fathom 44 | ``` 45 | 46 | * create the Procfile for Heroku 47 | 48 | ```bash 49 | echo "web: bin/fathom server" > Procfile 50 | ``` 51 | 52 | * create a Postgres database (you can change the type of plan if you want - https://elements.heroku.com/addons/heroku-postgresql#pricing) 53 | 54 | ```bash 55 | heroku addons:create heroku-postgresql:hobby-dev 56 | ``` 57 | 58 | * update the environment variables, generate a secret_key 59 | 60 | here you can change the way you generate your secret_key. 61 | 62 | ```bash 63 | heroku config:set PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app/bin \ 64 | FATHOM_DATABASE_DRIVER=postgres \ 65 | FATHOM_DATABASE_URL=$(heroku config:get DATABASE_URL) \ 66 | FATHOM_DEBUG=true \ 67 | FATHOM_SECRET=$(openssl rand -base64 32) \ 68 | FATHOM_GZIP=true 69 | ``` 70 | 71 | * add, commit and push all our files 72 | 73 | ```bash 74 | git add --all 75 | git commit -m "First Commit" 76 | git push heroku master 77 | ``` 78 | 79 | * the created app runs as a free-tier. A free-tier dyno uses the account-based pool 80 | of free dyno hours. If you have other free dynos running, you will need to upgrade your app to a 'hobby' one. - https://www.heroku.com/pricing 81 | 82 | ```bash 83 | heroku dyno:resize hobby 84 | ``` 85 | 86 | * check that everything is working 87 | 88 | ```bash 89 | heroku run fathom --version 90 | ``` 91 | 92 | * add the first user 93 | 94 | ```bash 95 | heroku run fathom user add --email="test@test.com" --password="test_password" 96 | ``` 97 | 98 | * open the browser to login and add your first website 99 | 100 | ```bash 101 | heroku open 102 | ``` 103 | 104 | * ENJOY :) 105 | -------------------------------------------------------------------------------- /docs/misc/NGINX.md: -------------------------------------------------------------------------------- 1 | # Using NGINX with Fathom 2 | 3 | Let's say you have the Fathom server listening on port 9000 and want to serve it on your domain, `yourfathom.com`. 4 | 5 | We can use NGINX to redirect all traffic for a certain domain to our Fathom application by using the `proxy_pass` directive combined with the port Fathom is listening on. 6 | 7 | Create the following file in `/etc/nginx/sites-enabled/yourfathom.com` 8 | 9 | ``` 10 | server { 11 | server_name yourfathom.com; 12 | 13 | location / { 14 | proxy_set_header X-Real-IP $remote_addr; 15 | proxy_set_header X-Forwarded-For $remote_addr; 16 | proxy_set_header Host $host; 17 | proxy_pass http://127.0.0.1:9000; 18 | } 19 | } 20 | ``` 21 | 22 | If you wish to protect your site using a [Let's Encrypt](https://letsencrypt.org/) HTTPS certificate, you can do so using the [Certbot webroot plugin](https://certbot.eff.org/docs/using.html#webroot). 23 | 24 | ``` 25 | certbot certonly --webroot --webroot-path /var/www/yourfathom.com -d yourfathom.com 26 | ``` 27 | 28 | Your `/etc/nginx/sites-enabled/yourfathom.com` file should be updated accordingly: 29 | 30 | ``` 31 | server { 32 | listen 443 ssl http2; 33 | listen [::]:443 ssl http2; 34 | 35 | server_name yourfathom.com; 36 | 37 | ssl_certificate /path/to/your/fullchain.pem; 38 | ssl_certificate_key /path/to/your/privkey.pem; 39 | 40 | location /.well-known { 41 | alias /var/www/yourfathom.com/.well-known; 42 | } 43 | 44 | location / { 45 | proxy_set_header X-Real-IP $remote_addr; 46 | proxy_set_header X-Forwarded-For $remote_addr; 47 | proxy_set_header Host $host; 48 | proxy_pass http://127.0.0.1:9000; 49 | } 50 | } 51 | ``` 52 | 53 | The `alias` directive should point to the location where your `--webroot-path` is specified when generating the certificate (with `/.well-known` appended). 54 | 55 | ### Test NGINX configuration 56 | ``` 57 | sudo nginx -t 58 | ``` 59 | 60 | ### Reload NGINX configuration 61 | 62 | ``` 63 | sudo service nginx reload 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/misc/Systemd.md: -------------------------------------------------------------------------------- 1 | # Managing the Fathom process with Systemd 2 | 3 | To run Fathom as a service (so it keeps on running in the background and is automatically restarted in case of a server reboot) on Ubuntu 16.04 or later, first ensure you have the `fathom` binary installed and in your `$PATH` so that the command exists. 4 | 5 | Then, create a new service config file in the `/etc/systemd/system/` directory. 6 | 7 | Example file: `/etc/systemd/system/fathom.service` 8 | 9 | The file should have the following contents, with `$USER` substituted with your actual username. 10 | 11 | ``` 12 | [Unit] 13 | Description=Starts the fathom server 14 | Requires=network.target 15 | After=network.target 16 | 17 | [Service] 18 | Type=simple 19 | User=$USER 20 | Restart=always 21 | RestartSec=6 22 | WorkingDirectory=/etc/fathom # (or where fathom should store its files) 23 | ExecStart=fathom server 24 | 25 | [Install] 26 | WantedBy=multi-user.target 27 | ``` 28 | 29 | Save the file and run `sudo systemctl daemon-reload` to load the changes from disk. 30 | 31 | Then, run `sudo systemctl enable fathom` to start the service whenever the system boots. 32 | 33 | ### Starting or stopping the Fathom service manually 34 | ``` 35 | sudo systemctl start fathom 36 | sudo systemctl stop fathom 37 | ``` 38 | 39 | ### Using a custom configuration file 40 | 41 | If you want to [modify the configuration values for your Fathom service](../Configuration.md), then change the line starting with `ExecStart=...` to include the path to your configuration file. 42 | 43 | For example, if you have a configuration file `/home/john/fathom.env` then the line should look like this: 44 | 45 | ``` 46 | ExecStart=fathom --config=/home/john/fathom.env server --addr=:9000 47 | ``` 48 | 49 | #### Start Fathom automatically at boot 50 | ``` 51 | sudo systemctl enable fathom 52 | ``` 53 | 54 | #### Stop Fathom from starting at boot 55 | 56 | ``` 57 | sudo systemctl disable fathom 58 | ``` 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/usefathom/fathom 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.6.0 7 | github.com/gobuffalo/packr/v2 v2.8.3 8 | github.com/google/uuid v1.3.0 9 | github.com/gorilla/context v1.1.1 10 | github.com/gorilla/handlers v1.5.1 11 | github.com/gorilla/mux v1.8.0 12 | github.com/gorilla/sessions v1.2.1 13 | github.com/jmoiron/sqlx v1.3.5 14 | github.com/joho/godotenv v1.4.0 15 | github.com/kelseyhightower/envconfig v1.4.0 16 | github.com/lib/pq v1.10.7 17 | github.com/mattn/go-sqlite3 v1.14.16 18 | github.com/mssola/user_agent v0.5.3 19 | github.com/rubenv/sql-migrate v1.2.0 20 | github.com/sirupsen/logrus v1.9.0 21 | github.com/urfave/cli v1.22.10 22 | golang.org/x/crypto v0.3.0 23 | ) 24 | 25 | require ( 26 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 27 | github.com/felixge/httpsnoop v1.0.1 // indirect 28 | github.com/go-gorp/gorp/v3 v3.0.2 // indirect 29 | github.com/gobuffalo/logger v1.0.6 // indirect 30 | github.com/gobuffalo/packd v1.0.1 // indirect 31 | github.com/gorilla/securecookie v1.1.2-0.20180608144417-78f3d318a8bf // indirect 32 | github.com/karrick/godirwalk v1.16.1 // indirect 33 | github.com/markbates/errx v1.1.0 // indirect 34 | github.com/markbates/oncer v1.0.0 // indirect 35 | github.com/markbates/safe v1.0.1 // indirect 36 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 37 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 38 | github.com/ziutek/mymysql v1.5.5-0.20171217234033-ff6cc86d3d93 // indirect 39 | golang.org/x/net v0.2.0 // indirect 40 | golang.org/x/sys v0.2.0 // indirect 41 | golang.org/x/term v0.2.0 // indirect 42 | golang.org/x/text v0.4.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browserify = require('browserify') 4 | const gulp = require('gulp') 5 | const source = require('vinyl-source-stream') 6 | const buffer = require('vinyl-buffer') 7 | const uglify = require('gulp-uglify') 8 | const babel = require('gulp-babel'); 9 | const cachebust = require('gulp-cache-bust'); 10 | const concat = require('gulp-concat'); 11 | const gulpif = require('gulp-if') 12 | 13 | const debug = process.env.NODE_ENV !== 'production'; 14 | 15 | gulp.task('app-js', function () { 16 | return browserify({ 17 | entries: './assets/src/js/script.js', 18 | debug: debug, 19 | ignoreMissing: true, 20 | }) 21 | .transform("babelify", { 22 | presets: ["@babel/preset-env"], 23 | plugins: [ 24 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 25 | ["@babel/plugin-transform-react-jsx", { "pragma":"h" } ] 26 | ] 27 | }) 28 | .bundle() 29 | .pipe(source('script.js')) 30 | .pipe(buffer()) 31 | .pipe(gulpif(!debug, uglify())) 32 | .pipe(gulp.dest(`./assets/build/js`)) 33 | }); 34 | 35 | gulp.task('tracker-js', function () { 36 | return gulp.src('./assets/src/js/tracker.js') 37 | .pipe(babel({ 38 | presets: ["@babel/preset-env"], 39 | })) 40 | .pipe(gulpif(!debug, uglify())) 41 | .pipe(gulp.dest('./assets/build/js')); 42 | }); 43 | 44 | gulp.task('fonts', function() { 45 | return gulp.src('./assets/src/fonts/**/*') 46 | .pipe(gulp.dest(`./assets/build/fonts`)) 47 | }); 48 | 49 | gulp.task('img', function() { 50 | return gulp.src('./assets/src/img/**/*') 51 | .pipe(gulp.dest(`./assets/build/img`)) 52 | }); 53 | 54 | gulp.task('html', function() { 55 | return gulp.src('./assets/src/**/*.html') 56 | .pipe(cachebust({ 57 | type: 'timestamp' 58 | })) 59 | .pipe(gulp.dest(`./assets/build/`)) 60 | }); 61 | 62 | gulp.task('css', function () { 63 | return gulp.src('./assets/src/css/*.css') 64 | .pipe(concat('styles.css')) 65 | .pipe(gulp.dest(`./assets/build/css`)) 66 | }); 67 | 68 | gulp.task('default', gulp.series('app-js', 'tracker-js', 'css', 'html', 'img', 'fonts' ) ); 69 | 70 | gulp.task('watch', gulp.series('default', function() { 71 | gulp.watch(['./assets/src/js/**/*.js'], gulp.parallel('app-js', 'tracker-js') ); 72 | gulp.watch(['./assets/src/css/**/*.css'], gulp.parallel( 'css') ); 73 | gulp.watch(['./assets/src/**/*.html'], gulp.parallel( 'html') ); 74 | gulp.watch(['./assets/src/img/**/*'], gulp.parallel( 'img') ); 75 | gulp.watch(['./assets/src/fonts/**/*'], gulp.parallel( 'fonts') ); 76 | })); 77 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/usefathom/fathom/pkg/cli" 8 | ) 9 | 10 | var ( 11 | version = "dev" 12 | commit = "none" 13 | date = "unknown" 14 | ) 15 | 16 | func main() { 17 | err := cli.Run(version, commit, date) 18 | if err != nil { 19 | fmt.Print(err) 20 | os.Exit(1) 21 | } 22 | 23 | os.Exit(0) 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/usefathom/fathom.git" 6 | }, 7 | "devDependencies": { 8 | "@babel/core": "^7.0.1", 9 | "@babel/plugin-proposal-decorators": "^7.0.0", 10 | "@babel/plugin-transform-react-jsx": "^7.0.0", 11 | "@babel/preset-env": "^7.0.0", 12 | "babelify": "^10.0.0", 13 | "browserify": "^16.2.0", 14 | "gulp": "^4.0.0", 15 | "gulp-babel": "^8.0.0", 16 | "gulp-cache-bust": "^1.4.0", 17 | "gulp-concat": "^2.6.1", 18 | "gulp-if": "^2.0.2", 19 | "gulp-uglify": "^3.0.0", 20 | "vinyl-buffer": "^1.0.0", 21 | "vinyl-source-stream": "^2.0.0" 22 | }, 23 | "dependencies": { 24 | "classnames": "^2.2.6", 25 | "d3": "^5.7.0", 26 | "d3-tip": "^0.9.1", 27 | "d3-transition": "^1.1.3", 28 | "decko": "^1.2.0", 29 | "pikaday": "^1.8.0", 30 | "preact": "^8.3.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/aggregator/aggregator_test.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func TestParseReferrer(t *testing.T) { 9 | testsValid := map[string]*url.URL{ 10 | "https://www.usefathom.com/?utm_source=github": &url.URL{ 11 | Scheme: "https", 12 | Host: "www.usefathom.com", 13 | Path: "/", 14 | }, 15 | "https://www.usefathom.com/privacy/amp/?utm_source=github": &url.URL{ 16 | Scheme: "https", 17 | Host: "www.usefathom.com", 18 | Path: "/privacy/", 19 | }, 20 | } 21 | testsErr := []string{ 22 | "mysite.com", 23 | "foobar", 24 | "", 25 | } 26 | 27 | for r, e := range testsValid { 28 | v, err := parseReferrer(r) 29 | if err != nil { 30 | t.Error(err) 31 | } 32 | 33 | if v.Host != e.Host { 34 | t.Errorf("Invalid Host: expected %s, got %s", e.Host, v.Host) 35 | } 36 | 37 | if v.Scheme != e.Scheme { 38 | t.Errorf("Invalid Scheme: expected %s, got %s", e.Scheme, v.Scheme) 39 | } 40 | 41 | if v.Path != e.Path { 42 | t.Errorf("Invalid Path: expected %s, got %s", e.Path, v.Path) 43 | } 44 | 45 | } 46 | 47 | for _, r := range testsErr { 48 | v, err := parseReferrer(r) 49 | if err == nil { 50 | t.Errorf("Expected err, got %#v", v) 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /pkg/aggregator/blacklist.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "strings" 7 | ) 8 | 9 | type blacklist struct { 10 | data []byte 11 | } 12 | 13 | func newBlacklist() (*blacklist, error) { 14 | var err error 15 | b := &blacklist{} 16 | b.data, err = Asset("blacklist.txt") 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return b, nil 22 | } 23 | 24 | // Has returns true if the given domain appears on the blacklist 25 | // Uses sub-string matching, so if usesfathom.com is blacklisted then this function will also return true for danny.usesfathom.com 26 | func (b *blacklist) Has(r string) bool { 27 | if r == "" { 28 | return false 29 | } 30 | 31 | scanner := bufio.NewScanner(bytes.NewReader(b.data)) 32 | domain := "" 33 | 34 | for scanner.Scan() { 35 | domain = scanner.Text() 36 | if strings.HasSuffix(r, domain) { 37 | return true 38 | } 39 | } 40 | 41 | return false 42 | } 43 | -------------------------------------------------------------------------------- /pkg/aggregator/blacklist_test.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBlacklistHas(t *testing.T) { 8 | b, err := newBlacklist() 9 | if err != nil { 10 | t.Error(err) 11 | } 12 | 13 | table := map[string]bool{ 14 | "03e.info": true, 15 | "zvetki.ru": true, 16 | "usefathom.com": false, 17 | "foo.03e.info": true, // sub-string match 18 | } 19 | 20 | for r, e := range table { 21 | if v := b.Has(r); v != e { 22 | t.Errorf("Expected %v, got %v", e, v) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/aggregator/store.go: -------------------------------------------------------------------------------- 1 | package aggregator 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/usefathom/fathom/pkg/datastore" 9 | "github.com/usefathom/fathom/pkg/models" 10 | ) 11 | 12 | func (agg *Aggregator) getSiteStats(r *results, siteID int64, t time.Time) (*models.SiteStats, error) { 13 | cacheKey := fmt.Sprintf("%d-%s", siteID, t.Format("2006-01-02T15")) 14 | if stats, ok := r.Sites[cacheKey]; ok { 15 | return stats, nil 16 | 17 | } 18 | 19 | // get from db 20 | stats, err := agg.database.GetSiteStats(siteID, t) 21 | if err != nil && err != datastore.ErrNoResults { 22 | return nil, err 23 | } 24 | 25 | if stats == nil { 26 | stats = &models.SiteStats{ 27 | SiteID: siteID, 28 | New: true, 29 | Date: t, 30 | } 31 | } 32 | 33 | r.Sites[cacheKey] = stats 34 | return stats, nil 35 | } 36 | 37 | func (agg *Aggregator) getPageStats(r *results, siteID int64, t time.Time, hostname string, pathname string) (*models.PageStats, error) { 38 | cacheKey := fmt.Sprintf("%d-%s-%s-%s", siteID, t.Format("2006-01-02T15"), hostname, pathname) 39 | if stats, ok := r.Pages[cacheKey]; ok { 40 | return stats, nil 41 | } 42 | 43 | hostnameID, err := agg.database.HostnameID(hostname) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | pathnameID, err := agg.database.PathnameID(pathname) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | stats, err := agg.database.GetPageStats(siteID, t, hostnameID, pathnameID) 54 | if err != nil && err != datastore.ErrNoResults { 55 | return nil, err 56 | } 57 | 58 | if stats == nil { 59 | stats = &models.PageStats{ 60 | SiteID: siteID, 61 | New: true, 62 | HostnameID: hostnameID, 63 | PathnameID: pathnameID, 64 | Date: t, 65 | } 66 | 67 | } 68 | 69 | r.Pages[cacheKey] = stats 70 | return stats, nil 71 | } 72 | 73 | func (agg *Aggregator) getReferrerStats(r *results, siteID int64, t time.Time, hostname string, pathname string) (*models.ReferrerStats, error) { 74 | cacheKey := fmt.Sprintf("%d-%s-%s-%s", siteID, t.Format("2006-01-02T15"), hostname, pathname) 75 | if stats, ok := r.Referrers[cacheKey]; ok { 76 | return stats, nil 77 | } 78 | 79 | hostnameID, err := agg.database.HostnameID(hostname) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | pathnameID, err := agg.database.PathnameID(pathname) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | // get from db 90 | stats, err := agg.database.GetReferrerStats(siteID, t, hostnameID, pathnameID) 91 | if err != nil && err != datastore.ErrNoResults { 92 | return nil, err 93 | } 94 | 95 | if stats == nil { 96 | stats = &models.ReferrerStats{ 97 | SiteID: siteID, 98 | New: true, 99 | HostnameID: hostnameID, 100 | PathnameID: pathnameID, 101 | Date: t, 102 | Group: "", 103 | } 104 | 105 | if strings.Contains(hostname, "www.google.") { 106 | stats.Group = "Google" 107 | } else if strings.Contains(stats.Hostname, "www.bing.") { 108 | stats.Group = "Bing" 109 | } else if strings.Contains(stats.Hostname, "www.baidu.") { 110 | stats.Group = "Baidu" 111 | } else if strings.Contains(stats.Hostname, "www.yandex.") { 112 | stats.Group = "Yandex" 113 | } else if strings.Contains(stats.Hostname, "search.yahoo.") { 114 | stats.Group = "Yahoo!" 115 | } else if strings.Contains(stats.Hostname, "www.findx.") { 116 | stats.Group = "Findx" 117 | } 118 | } 119 | 120 | r.Referrers[cacheKey] = stats 121 | return stats, nil 122 | } 123 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gorilla/sessions" 5 | "github.com/usefathom/fathom/pkg/datastore" 6 | ) 7 | 8 | type API struct { 9 | database datastore.Datastore 10 | sessions sessions.Store 11 | } 12 | 13 | // New instantiates a new API object 14 | func New(db datastore.Datastore, secret string) *API { 15 | return &API{ 16 | database: db, 17 | sessions: sessions.NewCookieStore([]byte(secret)), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | gcontext "github.com/gorilla/context" 9 | "github.com/usefathom/fathom/pkg/datastore" 10 | ) 11 | 12 | type key int 13 | 14 | const ( 15 | userKey key = 0 16 | ) 17 | 18 | type login struct { 19 | Email string `json:"email"` 20 | Password string `json:"password"` 21 | } 22 | 23 | func (l *login) Sanitize() { 24 | l.Email = strings.ToLower(strings.TrimSpace(l.Email)) 25 | } 26 | 27 | // GET /api/session 28 | func (api *API) GetSession(w http.ResponseWriter, r *http.Request) error { 29 | userCount, err := api.database.CountUsers() 30 | if err != nil { 31 | return err 32 | } 33 | 34 | // if 0 users in database, dashboard is public 35 | if userCount == 0 { 36 | return respond(w, http.StatusOK, envelope{Data: true}) 37 | } 38 | 39 | // if existing session, assume logged-in 40 | session, _ := api.sessions.Get(r, "auth") 41 | if !session.IsNew { 42 | return respond(w, http.StatusOK, envelope{Data: true}) 43 | } 44 | 45 | // otherwise: not logged-in yet 46 | return respond(w, http.StatusOK, envelope{Data: false}) 47 | } 48 | 49 | // URL: POST /api/session 50 | func (api *API) CreateSession(w http.ResponseWriter, r *http.Request) error { 51 | // check login creds 52 | var l login 53 | err := json.NewDecoder(r.Body).Decode(&l) 54 | if err != nil { 55 | return err 56 | } 57 | l.Sanitize() 58 | 59 | // find user with given email 60 | u, err := api.database.GetUserByEmail(l.Email) 61 | if err != nil && err != datastore.ErrNoResults { 62 | return err 63 | } 64 | 65 | // compare pwd 66 | if err == datastore.ErrNoResults || u.ComparePassword(l.Password) != nil { 67 | return respond(w, http.StatusUnauthorized, envelope{Error: "invalid_credentials"}) 68 | } 69 | 70 | // ignore error here as we want a (new) session regardless 71 | session, _ := api.sessions.Get(r, "auth") 72 | session.Values["user_id"] = u.ID 73 | err = session.Save(r, w) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | return respond(w, http.StatusOK, envelope{Data: true}) 79 | } 80 | 81 | // URL: DELETE /api/session 82 | func (api *API) DeleteSession(w http.ResponseWriter, r *http.Request) error { 83 | session, _ := api.sessions.Get(r, "auth") 84 | if !session.IsNew { 85 | session.Options.MaxAge = -1 86 | err := session.Save(r, w) 87 | if err != nil { 88 | return err 89 | } 90 | } 91 | 92 | return respond(w, http.StatusOK, envelope{Data: true}) 93 | } 94 | 95 | // Authorize is middleware that aborts the request if unauthorized 96 | func (api *API) Authorize(next http.Handler) http.Handler { 97 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 98 | // clear context from request after it is handled 99 | // see http://www.gorillatoolkit.org/pkg/sessions#overview 100 | defer gcontext.Clear(r) 101 | 102 | // first count users in datastore 103 | // if 0, assume dashboard is public 104 | userCount, err := api.database.CountUsers() 105 | if err != nil { 106 | w.WriteHeader(http.StatusInternalServerError) 107 | return 108 | } 109 | 110 | if userCount > 0 { 111 | session, err := api.sessions.Get(r, "auth") 112 | // an err is returned if cookie has been tampered with, so check that 113 | if err != nil { 114 | respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) 115 | return 116 | } 117 | 118 | userID, ok := session.Values["user_id"] 119 | if session.IsNew || !ok { 120 | respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) 121 | return 122 | } 123 | 124 | // validate user ID in session 125 | if _, err := api.database.GetUser(userID.(int64)); err != nil { 126 | respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) 127 | return 128 | } 129 | } 130 | 131 | next.ServeHTTP(w, r) 132 | }) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/api/auth_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "testing" 4 | 5 | func TestLoginSanitize(t *testing.T) { 6 | rawEmail := "Foo@foobar.com " 7 | l := &login{ 8 | Email: rawEmail, 9 | } 10 | 11 | l.Sanitize() 12 | if l.Email != "foo@foobar.com" { 13 | t.Errorf("Expected normalized email address, got %s", l.Email) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/api/collect_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestShouldCollect(t *testing.T) { 9 | r, _ := http.NewRequest("GET", "/", nil) 10 | r.Header.Add("User-Agent", "Mozilla/1.0") 11 | r.Header.Add("Referer", "http://usefathom.com/") 12 | if v := shouldCollect(r); v != false { 13 | t.Errorf("Expected %#v, got %#v", true, false) 14 | } 15 | } 16 | 17 | func TestParsePathname(t *testing.T) { 18 | if v := parsePathname("/"); v != "/" { 19 | t.Errorf("error parsing pathname. expected %#v, got %#v", "/", v) 20 | } 21 | 22 | if v := parsePathname("about"); v != "/about" { 23 | t.Errorf("error parsing pathname. expected %#v, got %#v", "/about", v) 24 | } 25 | if v := parsePathname("about/"); v != "/about" { 26 | t.Errorf("error parsing pathname. expected %#v, got %#v", "/about", v) 27 | } 28 | } 29 | 30 | func TestParseHostname(t *testing.T) { 31 | e := "https://usefathom.com" 32 | if v := parseHostname("https://usefathom.com"); v != e { 33 | t.Errorf("error parsing hostname. expected %#v, got %#v", e, v) 34 | } 35 | 36 | e = "http://usefathom.com" 37 | if v := parseHostname("http://usefathom.com"); v != e { 38 | t.Errorf("error parsing hostname. expected %#v, got %#v", e, v) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | 5 | // GET /health 6 | func (api *API) Health(w http.ResponseWriter, _ *http.Request) error { 7 | if err := api.database.Health(); err != nil { 8 | w.WriteHeader(http.StatusServiceUnavailable) 9 | return err 10 | } 11 | 12 | w.WriteHeader(http.StatusOK) 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /pkg/api/http.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gobuffalo/packr/v2" 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // Handler is our custom HTTP handler with error returns 12 | type Handler func(w http.ResponseWriter, r *http.Request) error 13 | 14 | type envelope struct { 15 | Data interface{} `json:",omitempty"` 16 | Error interface{} `json:",omitempty"` 17 | } 18 | 19 | func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 | if err := h(w, r); err != nil { 21 | HandleError(w, r, err) 22 | } 23 | } 24 | 25 | // HandlerFunc takes a custom Handler func and converts it to http.HandlerFunc 26 | func HandlerFunc(fn Handler) http.HandlerFunc { 27 | return http.HandlerFunc(Handler(fn).ServeHTTP) 28 | } 29 | 30 | // HandleError handles errors 31 | func HandleError(w http.ResponseWriter, r *http.Request, err error) { 32 | log.WithFields(log.Fields{ 33 | "request": r.Method + " " + r.RequestURI, 34 | "error": err, 35 | }).Error("error handling request") 36 | 37 | w.WriteHeader(http.StatusInternalServerError) 38 | w.Header().Set("Content-Type", "application/json") 39 | w.Write([]byte("false")) 40 | } 41 | 42 | func respond(w http.ResponseWriter, statusCode int, d interface{}) error { 43 | w.Header().Set("Content-Type", "application/json") 44 | w.WriteHeader(statusCode) 45 | err := json.NewEncoder(w).Encode(d) 46 | return err 47 | } 48 | 49 | func serveFileHandler(box *packr.Box, filename string) http.Handler { 50 | return HandlerFunc(serveFile(box, filename)) 51 | } 52 | 53 | func serveFile(box *packr.Box, filename string) Handler { 54 | return func(w http.ResponseWriter, r *http.Request) error { 55 | f, err := box.Open(filename) 56 | if err != nil { 57 | return err 58 | } 59 | defer f.Close() 60 | 61 | d, err := f.Stat() 62 | if err != nil { 63 | return err 64 | } 65 | 66 | // setting security and cache headers 67 | w.Header().Set("X-Content-Type-Options", "nosniff") 68 | w.Header().Set("X-Xss-Protection", "1; mode=block") 69 | w.Header().Set("Cache-Control", "max-age=432000") // 5 days 70 | 71 | http.ServeContent(w, r, filename, d.ModTime(), f) 72 | return nil 73 | } 74 | } 75 | 76 | func NotFoundHandler(box *packr.Box) http.Handler { 77 | return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { 78 | w.WriteHeader(http.StatusNotFound) 79 | w.Write(box.Bytes("404.html")) 80 | return nil 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/api/http_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestRespond(t *testing.T) { 11 | w := httptest.NewRecorder() 12 | respond(w, http.StatusOK, 15) 13 | 14 | if w.Code != 200 { 15 | t.Errorf("Invalid response code") 16 | } 17 | 18 | // assert json header 19 | if w.Header().Get("Content-Type") != "application/json" { 20 | t.Errorf("Invalid response header for Content-Type") 21 | } 22 | 23 | // assert json response 24 | var d int 25 | err := json.NewDecoder(w.Body).Decode(&d) 26 | if err != nil { 27 | t.Errorf("Invalid response body: %s", err) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /pkg/api/page_stats.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // URL: /api/sites/{id:[0-9]+}/stats/pages/agg 8 | func (api *API) GetAggregatedPageStatsHandler(w http.ResponseWriter, r *http.Request) error { 9 | params := GetRequestParams(r) 10 | result, err := api.database.SelectAggregatedPageStats(params.SiteID, params.StartDate, params.EndDate, params.Offset, params.Limit) 11 | if err != nil { 12 | return err 13 | } 14 | return respond(w, http.StatusOK, envelope{Data: result}) 15 | } 16 | 17 | func (api *API) GetAggregatedPageStatsPageviewsHandler(w http.ResponseWriter, r *http.Request) error { 18 | params := GetRequestParams(r) 19 | result, err := api.database.GetAggregatedPageStatsPageviews(params.SiteID, params.StartDate, params.EndDate) 20 | if err != nil { 21 | return err 22 | } 23 | return respond(w, http.StatusOK, envelope{Data: result}) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/api/params.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // Params defines the commonly used API parameters 12 | type Params struct { 13 | SiteID int64 14 | Offset int 15 | Limit int 16 | StartDate time.Time 17 | EndDate time.Time 18 | } 19 | 20 | // GetRequestParams parses the query parameters and returns commonly used API parameters, with defaults 21 | func GetRequestParams(r *http.Request) *Params { 22 | params := &Params{ 23 | SiteID: 0, 24 | Limit: 20, 25 | Offset: 0, 26 | StartDate: time.Now(), 27 | EndDate: time.Now().AddDate(0, 0, -7), 28 | } 29 | 30 | vars := mux.Vars(r) 31 | if _, ok := vars["id"]; ok { 32 | if siteID, err := strconv.ParseInt(vars["id"], 10, 64); err == nil { 33 | params.SiteID = siteID 34 | } 35 | } 36 | 37 | q := r.URL.Query() 38 | if q.Get("after") != "" { 39 | if after, err := strconv.ParseInt(q.Get("after"), 10, 64); err == nil && after > 0 { 40 | params.StartDate = time.Unix(after, 0) 41 | } 42 | } 43 | 44 | if q.Get("before") != "" { 45 | if before, err := strconv.ParseInt(q.Get("before"), 10, 64); err == nil && before > 0 { 46 | params.EndDate = time.Unix(before, 0) 47 | } 48 | } 49 | 50 | if q.Get("limit") != "" { 51 | if limit, err := strconv.Atoi(q.Get("limit")); err == nil && limit > 0 { 52 | params.Limit = limit 53 | } 54 | } 55 | 56 | if q.Get("offset") != "" { 57 | if offset, err := strconv.Atoi(q.Get("offset")); err == nil && offset > 0 { 58 | params.Offset = offset 59 | } 60 | } 61 | 62 | return params 63 | } 64 | -------------------------------------------------------------------------------- /pkg/api/params_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestGetRequestParams(t *testing.T) { 11 | startDate := time.Now().AddDate(0, 0, -12) 12 | endDate := time.Now().AddDate(0, 0, -5) 13 | limit := 50 14 | 15 | url := fmt.Sprintf("/?after=%d&before=%d&limit=%d", startDate.Unix(), endDate.Unix(), limit) 16 | r, _ := http.NewRequest("GET", url, nil) 17 | params := GetRequestParams(r) 18 | 19 | if params.Limit != 50 { 20 | t.Errorf("Expected %#v, got %#v", 50, params.Limit) 21 | } 22 | 23 | if startDate.Unix() != params.StartDate.Unix() { 24 | t.Errorf("Expected %#v, got %#v", startDate.Format("2006-01-02 15:04"), params.StartDate.Format("2006-01-02 15:04")) 25 | } 26 | 27 | if params.EndDate.Unix() != endDate.Unix() { 28 | t.Errorf("Expected %#v, got %#v", endDate.Format("2006-01-02 15:04"), params.EndDate.Format("2006-01-02 15:04")) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/referrer_stats.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func (api *API) GetAggregatedReferrerStatsHandler(w http.ResponseWriter, r *http.Request) error { 8 | params := GetRequestParams(r) 9 | result, err := api.database.SelectAggregatedReferrerStats(params.SiteID, params.StartDate, params.EndDate, params.Offset, params.Limit) 10 | if err != nil { 11 | return err 12 | } 13 | return respond(w, http.StatusOK, envelope{Data: result}) 14 | } 15 | 16 | func (api *API) GetAggregatedReferrerStatsPageviewsHandler(w http.ResponseWriter, r *http.Request) error { 17 | params := GetRequestParams(r) 18 | result, err := api.database.GetAggregatedReferrerStatsPageviews(params.SiteID, params.StartDate, params.EndDate) 19 | if err != nil { 20 | return err 21 | } 22 | return respond(w, http.StatusOK, envelope{Data: result}) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gobuffalo/packr/v2" 7 | "github.com/gorilla/mux" 8 | ) 9 | 10 | func (api *API) Routes() *mux.Router { 11 | // register routes 12 | r := mux.NewRouter() 13 | r.Handle("/collect", NewCollector(api.database)).Methods(http.MethodGet) 14 | 15 | r.Handle("/api/session", HandlerFunc(api.GetSession)).Methods(http.MethodGet) 16 | r.Handle("/api/session", HandlerFunc(api.CreateSession)).Methods(http.MethodPost) 17 | r.Handle("/api/session", HandlerFunc(api.DeleteSession)).Methods(http.MethodDelete) 18 | 19 | r.Handle("/api/sites", api.Authorize(HandlerFunc(api.GetSitesHandler))).Methods(http.MethodGet) 20 | r.Handle("/api/sites", api.Authorize(HandlerFunc(api.SaveSiteHandler))).Methods(http.MethodPost) 21 | r.Handle("/api/sites/{id:[0-9]+}", api.Authorize(HandlerFunc(api.SaveSiteHandler))).Methods(http.MethodPost) 22 | r.Handle("/api/sites/{id:[0-9]+}", api.Authorize(HandlerFunc(api.DeleteSiteHandler))).Methods(http.MethodDelete) 23 | 24 | r.Handle("/api/sites/{id:[0-9]+}/stats/site", api.Authorize(HandlerFunc(api.GetSiteStatsHandler))).Methods(http.MethodGet) 25 | r.Handle("/api/sites/{id:[0-9]+}/stats/site/agg", api.Authorize(HandlerFunc(api.GetAggregatedSiteStatsHandler))).Methods(http.MethodGet) 26 | r.Handle("/api/sites/{id:[0-9]+}/stats/site/realtime", api.Authorize(HandlerFunc(api.GetSiteStatsRealtimeHandler))).Methods(http.MethodGet) 27 | 28 | r.Handle("/api/sites/{id:[0-9]+}/stats/pages/agg", api.Authorize(HandlerFunc(api.GetAggregatedPageStatsHandler))).Methods(http.MethodGet) 29 | r.Handle("/api/sites/{id:[0-9]+}/stats/pages/agg/pageviews", api.Authorize(HandlerFunc(api.GetAggregatedPageStatsPageviewsHandler))).Methods(http.MethodGet) 30 | 31 | r.Handle("/api/sites/{id:[0-9]+}/stats/referrers/agg", api.Authorize(HandlerFunc(api.GetAggregatedReferrerStatsHandler))).Methods(http.MethodGet) 32 | r.Handle("/api/sites/{id:[0-9]+}/stats/referrers/agg/pageviews", api.Authorize(HandlerFunc(api.GetAggregatedReferrerStatsPageviewsHandler))).Methods(http.MethodGet) 33 | 34 | r.Handle("/health", HandlerFunc(api.Health)).Methods(http.MethodGet) 35 | 36 | // static assets & 404 handler 37 | box := packr.NewBox("./../../assets/build") 38 | r.Path("/tracker.js").Handler(serveTrackerFile(box)) 39 | r.Path("/").Handler(serveFileHandler(box, "index.html")) 40 | r.Path("/index.html").Handler(serveFileHandler(box, "index.html")) 41 | r.PathPrefix("/assets").Handler(http.StripPrefix("/assets", http.FileServer(box))) 42 | r.NotFoundHandler = NotFoundHandler(box) 43 | 44 | return r 45 | } 46 | 47 | func serveTrackerFile(box *packr.Box) http.Handler { 48 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 | w.Header().Set("Tk", "N") 50 | next := serveFile(box, "js/tracker.js") 51 | next.ServeHTTP(w, r) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/api/site_stats.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // URL: /api/sites/{id:[0-9]+}/stats/site/agg 8 | func (api *API) GetAggregatedSiteStatsHandler(w http.ResponseWriter, r *http.Request) error { 9 | params := GetRequestParams(r) 10 | result, err := api.database.GetAggregatedSiteStats(params.SiteID, params.StartDate, params.EndDate) 11 | if err != nil { 12 | return err 13 | } 14 | return respond(w, http.StatusOK, envelope{Data: result}) 15 | } 16 | 17 | // URL: /api/sites/{id:[0-9]+}/stats/site/realtime 18 | func (api *API) GetSiteStatsRealtimeHandler(w http.ResponseWriter, r *http.Request) error { 19 | params := GetRequestParams(r) 20 | result, err := api.database.GetRealtimeVisitorCount(params.SiteID) 21 | if err != nil { 22 | return err 23 | } 24 | return respond(w, http.StatusOK, envelope{Data: result}) 25 | } 26 | 27 | // URL: /api/sites/{id:[0-9]+}/stats/site 28 | func (api *API) GetSiteStatsHandler(w http.ResponseWriter, r *http.Request) error { 29 | params := GetRequestParams(r) 30 | result, err := api.database.SelectSiteStats(params.SiteID, params.StartDate, params.EndDate) 31 | if err != nil { 32 | return err 33 | } 34 | return respond(w, http.StatusOK, envelope{Data: result}) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/sites.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "math/rand" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/usefathom/fathom/pkg/models" 12 | ) 13 | 14 | // seed rand pkg on program init 15 | func init() { 16 | rand.Seed(time.Now().UTC().UnixNano()) 17 | } 18 | 19 | // GET /api/sites 20 | func (api *API) GetSitesHandler(w http.ResponseWriter, r *http.Request) error { 21 | result, err := api.database.GetSites() 22 | if err != nil { 23 | return err 24 | } 25 | return respond(w, http.StatusOK, envelope{Data: result}) 26 | } 27 | 28 | // POST /api/sites 29 | // POST /api/sites/{id} 30 | func (api *API) SaveSiteHandler(w http.ResponseWriter, r *http.Request) error { 31 | var s *models.Site 32 | vars := mux.Vars(r) 33 | sid, ok := vars["id"] 34 | if ok { 35 | id, err := strconv.ParseInt(sid, 10, 64) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | s, err = api.database.GetSite(id) 41 | if err != nil { 42 | return err 43 | } 44 | } else { 45 | s = &models.Site{ 46 | TrackingID: generateTrackingID(), 47 | } 48 | } 49 | 50 | err := json.NewDecoder(r.Body).Decode(s) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if err := api.database.SaveSite(s); err != nil { 56 | return err 57 | } 58 | 59 | return respond(w, http.StatusOK, envelope{Data: s}) 60 | } 61 | 62 | // DELETE /api/sites/{id} 63 | func (api *API) DeleteSiteHandler(w http.ResponseWriter, r *http.Request) error { 64 | vars := mux.Vars(r) 65 | id, err := strconv.ParseInt(vars["id"], 10, 64) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | if err := api.database.DeleteSite(&models.Site{ID: id}); err != nil { 71 | return err 72 | } 73 | 74 | return respond(w, http.StatusOK, envelope{Data: true}) 75 | } 76 | 77 | func generateTrackingID() string { 78 | return randomString(5) 79 | } 80 | 81 | func randomString(len int) string { 82 | bytes := make([]byte, len) 83 | for i := 0; i < len; i++ { 84 | bytes[i] = byte(65 + rand.Intn(25)) //a=65 and z = 65+25 85 | } 86 | 87 | return string(bytes) 88 | } 89 | -------------------------------------------------------------------------------- /pkg/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/urfave/cli" 11 | "github.com/usefathom/fathom/pkg/config" 12 | "github.com/usefathom/fathom/pkg/datastore" 13 | ) 14 | 15 | type App struct { 16 | *cli.App 17 | database datastore.Datastore 18 | config *config.Config 19 | } 20 | 21 | // CLI application 22 | var app *App 23 | 24 | // Run parses the CLI arguments & run application command 25 | func Run(version string, commit string, buildDate string) error { 26 | // force all times in UTC, regardless of server timezone 27 | time.Local = time.UTC 28 | 29 | // setup CLI app 30 | app = &App{cli.NewApp(), nil, nil} 31 | app.Name = "Fathom" 32 | app.Usage = "simple & transparent website analytics" 33 | app.Version = fmt.Sprintf("%v, commit %v, built at %v", strings.TrimPrefix(version, "v"), commit, buildDate) 34 | app.HelpName = "fathom" 35 | app.Flags = []cli.Flag{ 36 | cli.StringFlag{ 37 | Name: "config, c", 38 | Value: ".env", 39 | Usage: "Load configuration from `FILE`", 40 | }, 41 | } 42 | app.Before = before 43 | app.After = after 44 | app.Commands = []cli.Command{ 45 | serverCmd, 46 | userCmd, 47 | statsCmd, 48 | } 49 | 50 | if len(os.Args) < 2 || os.Args[1] != "--version" { 51 | log.Printf("%s version %s", app.Name, app.Version) 52 | } 53 | 54 | err := app.Run(os.Args) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func before(c *cli.Context) error { 63 | configFile := c.String("config") 64 | config.LoadEnv(configFile) 65 | app.config = config.Parse() 66 | app.database = datastore.New(app.config.Database) 67 | return nil 68 | } 69 | 70 | func after(c *cli.Context) error { 71 | err := app.database.Close() 72 | return err 73 | } 74 | -------------------------------------------------------------------------------- /pkg/cli/server.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/urfave/cli" 10 | 11 | "github.com/gorilla/handlers" 12 | "github.com/usefathom/fathom/pkg/api" 13 | "golang.org/x/crypto/acme/autocert" 14 | ) 15 | 16 | var serverCmd = cli.Command{ 17 | Name: "server", 18 | Aliases: []string{"s"}, 19 | Usage: "start the fathom web server", 20 | Action: server, 21 | Flags: []cli.Flag{ 22 | cli.StringFlag{ 23 | EnvVar: "FATHOM_SERVER_ADDR,PORT", 24 | Name: "addr,port", 25 | Usage: "server address", 26 | Value: ":8080", 27 | }, 28 | 29 | cli.BoolFlag{ 30 | EnvVar: "FATHOM_LETS_ENCRYPT", 31 | Name: "lets-encrypt", 32 | }, 33 | 34 | cli.BoolFlag{ 35 | EnvVar: "FATHOM_GZIP", 36 | Name: "gzip", 37 | Usage: "enable gzip compression", 38 | }, 39 | 40 | cli.StringFlag{ 41 | EnvVar: "FATHOM_HOSTNAME", 42 | Name: "hostname", 43 | Usage: "domain when using --lets-encrypt", 44 | }, 45 | 46 | cli.BoolFlag{ 47 | EnvVar: "FATHOM_DEBUG", 48 | Name: "debug, d", 49 | }, 50 | }, 51 | } 52 | 53 | func server(c *cli.Context) error { 54 | var h http.Handler 55 | a := api.New(app.database, app.config.Secret) 56 | h = a.Routes() 57 | 58 | // set debug log level if --debug was passed 59 | if c.Bool("debug") { 60 | log.SetLevel(log.DebugLevel) 61 | } else { 62 | log.SetLevel(log.WarnLevel) 63 | } 64 | 65 | // set gzip compression if --gzip was passed 66 | if c.Bool("gzip") { 67 | h = handlers.CompressHandler(h) 68 | } 69 | 70 | // if addr looks like a number, prefix with : 71 | addr := c.String("addr") 72 | if _, err := strconv.Atoi(addr); err == nil { 73 | addr = ":" + addr 74 | } 75 | 76 | // start server without letsencrypt / tls enabled 77 | if !c.Bool("lets-encrypt") { 78 | // start listening 79 | server := &http.Server{ 80 | Addr: addr, 81 | Handler: h, 82 | ReadTimeout: 10 * time.Second, 83 | WriteTimeout: 10 * time.Second, 84 | } 85 | 86 | log.Infof("Server is now listening on %s", server.Addr) 87 | log.Fatal(server.ListenAndServe()) 88 | return nil 89 | } 90 | 91 | // start server with autocert (letsencrypt) 92 | hostname := c.String("hostname") 93 | log.Infof("Server is now listening on %s:443", hostname) 94 | log.Fatal(http.Serve(autocert.NewListener(hostname), h)) 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /pkg/cli/stats.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | var statsCmd = cli.Command{ 14 | Name: "stats", 15 | Usage: "view stats", 16 | Action: stats, 17 | Flags: []cli.Flag{ 18 | cli.Int64Flag{ 19 | Name: "site-id", 20 | Usage: "ID of the site to retrieve stats for", 21 | }, 22 | cli.StringFlag{ 23 | Name: "start-date", 24 | Usage: "start date, expects a date in format 2006-01-02", 25 | }, 26 | cli.StringFlag{ 27 | Name: "end-date", 28 | Usage: "end date, expects a date in format 2006-01-02", 29 | }, 30 | cli.BoolFlag{ 31 | Name: "json", 32 | Usage: "get a json response", 33 | }, 34 | }, 35 | } 36 | 37 | func stats(c *cli.Context) error { 38 | start, _ := time.Parse("2006-01-02", c.String("start-date")) 39 | if start.IsZero() { 40 | return errors.New("Invalid argument: supply a valid --start-date") 41 | } 42 | 43 | end, _ := time.Parse("2006-01-02", c.String("end-date")) 44 | if end.IsZero() { 45 | return errors.New("Invalid argument: supply a valid --end-date") 46 | } 47 | 48 | // TODO: add method for getting total sum of pageviews across sites 49 | siteID := c.Int64("site-id") 50 | result, err := app.database.GetAggregatedSiteStats(siteID, start, end) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if c.Bool("json") { 56 | return json.NewEncoder(os.Stdout).Encode(result) 57 | } 58 | 59 | fmt.Printf("%s - %s\n", start.Format("Jan 01, 2006"), end.Format("Jan 01, 2006")) 60 | fmt.Printf("===========================\n") 61 | fmt.Printf("Visitors: \t%d\n", result.Visitors) 62 | fmt.Printf("Pageviews: \t%d\n", result.Pageviews) 63 | fmt.Printf("Sessions: \t%d\n", result.Sessions) 64 | fmt.Printf("Avg duration: \t%s\n", result.FormattedDuration()) 65 | fmt.Printf("Bounce rate: \t%.0f%%\n", result.BounceRate*100.00) 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cli/user.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/usefathom/fathom/pkg/models" 8 | 9 | log "github.com/sirupsen/logrus" 10 | "github.com/urfave/cli" 11 | "github.com/usefathom/fathom/pkg/datastore" 12 | ) 13 | 14 | var userCmd = cli.Command{ 15 | Name: "user", 16 | Usage: "manage registered admin users", 17 | Action: userAdd, 18 | Subcommands: []cli.Command{ 19 | cli.Command{ 20 | Name: "add", 21 | Aliases: []string{"register"}, 22 | Action: userAdd, 23 | Flags: []cli.Flag{ 24 | cli.StringFlag{ 25 | Name: "email, e", 26 | Usage: "user email", 27 | }, 28 | cli.StringFlag{ 29 | Name: "password, p", 30 | Usage: "user password", 31 | }, 32 | cli.BoolFlag{ 33 | Name: "skip-bcrypt", 34 | Usage: "store password string as-is, skipping bcrypt", 35 | }, 36 | }, 37 | }, 38 | cli.Command{ 39 | Name: "delete", 40 | Action: userDelete, 41 | Flags: []cli.Flag{ 42 | cli.StringFlag{ 43 | Name: "email, e", 44 | Usage: "user email", 45 | }, 46 | }, 47 | }, 48 | }, 49 | } 50 | 51 | func userAdd(c *cli.Context) error { 52 | email := c.String("email") 53 | if email == "" { 54 | return errors.New("Invalid arguments: missing email") 55 | } 56 | 57 | password := c.String("password") 58 | if password == "" { 59 | return errors.New("Invalid arguments: missing password") 60 | } 61 | 62 | _, err := app.database.GetUserByEmail(email) 63 | if err != nil { 64 | if err == datastore.ErrNoResults { 65 | user := models.NewUser(email, password) 66 | 67 | // set password manually if --skip-bcrypt was given 68 | // this is used to supply an already encrypted password string 69 | if c.Bool("skip-bcrypt") { 70 | user.Password = password 71 | } 72 | 73 | if err := app.database.SaveUser(&user); err != nil { 74 | return fmt.Errorf("Error creating user: %s", err) 75 | } 76 | 77 | log.Infof("Created user %s", user.Email) 78 | return nil 79 | } 80 | 81 | return err 82 | } 83 | log.Infof("A user with this email %s already exists", email) 84 | return nil 85 | 86 | } 87 | 88 | func userDelete(c *cli.Context) error { 89 | email := c.String("email") 90 | if email == "" { 91 | return errors.New("Invalid arguments: missing email") 92 | } 93 | 94 | user, err := app.database.GetUserByEmail(email) 95 | if err != nil { 96 | if err == datastore.ErrNoResults { 97 | return fmt.Errorf("No user with email %s", email) 98 | } 99 | 100 | return err 101 | } 102 | 103 | if err := app.database.DeleteUser(user); err != nil { 104 | return err 105 | } 106 | 107 | log.Infof("Deleted user %s", user.Email) 108 | 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "math/rand" 5 | "net/url" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/joho/godotenv" 10 | "github.com/kelseyhightower/envconfig" 11 | log "github.com/sirupsen/logrus" 12 | "github.com/usefathom/fathom/pkg/datastore/sqlstore" 13 | ) 14 | 15 | // Config wraps the configuration structs for the various application parts 16 | type Config struct { 17 | Database *sqlstore.Config 18 | Secret string 19 | } 20 | 21 | // LoadEnv loads env values from the supplied file 22 | func LoadEnv(file string) { 23 | if file == "" { 24 | log.Warn("Missing configuration file. Using defaults.") 25 | return 26 | } 27 | 28 | absFile, _ := filepath.Abs(file) 29 | _, err := os.Stat(absFile) 30 | fileNotExists := os.IsNotExist(err) 31 | 32 | if fileNotExists { 33 | log.Warnf("Error reading configuration. File `%s` does not exist.", file) 34 | return 35 | } 36 | 37 | log.Printf("Configuration file: %s", absFile) 38 | 39 | // read file into env values 40 | err = godotenv.Load(absFile) 41 | if err != nil { 42 | log.Fatalf("Error parsing configuration file: %s", err) 43 | } 44 | } 45 | 46 | // Parse environment into a Config struct 47 | func Parse() *Config { 48 | var cfg Config 49 | 50 | // with config file loaded into env values, we can now parse env into our config struct 51 | err := envconfig.Process("Fathom", &cfg) 52 | if err != nil { 53 | log.Fatalf("Error parsing configuration from environment: %s", err) 54 | } 55 | 56 | if cfg.Database.URL != "" { 57 | u, err := url.Parse(cfg.Database.URL) 58 | if err != nil { 59 | log.Fatalf("Error parsing DATABASE_URL from environment: %s", err) 60 | } 61 | if u.Scheme == "postgres" { 62 | cfg.Database.Driver = "postgres" 63 | } 64 | } 65 | 66 | // alias sqlite to sqlite3 67 | if cfg.Database.Driver == "sqlite" { 68 | cfg.Database.Driver = "sqlite3" 69 | } 70 | 71 | // use absolute path to sqlite3 database 72 | if cfg.Database.Driver == "sqlite3" { 73 | cfg.Database.Name, _ = filepath.Abs(cfg.Database.Name) 74 | } 75 | 76 | // if secret key is empty, use a randomly generated one 77 | if cfg.Secret == "" { 78 | cfg.Secret = randomString(40) 79 | } 80 | 81 | return &cfg 82 | } 83 | 84 | func randomString(len int) string { 85 | bytes := make([]byte, len) 86 | for i := 0; i < len; i++ { 87 | bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25 88 | } 89 | 90 | return string(bytes) 91 | } 92 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestLoadEnv(t *testing.T) { 10 | before := len(os.Environ()) 11 | LoadEnv("") 12 | LoadEnv("1230") 13 | after := len(os.Environ()) 14 | 15 | if before != after { 16 | t.Errorf("Expected the same number of env values") 17 | } 18 | 19 | data := []byte("FATHOM_DATABASE_DRIVER=\"sqlite3\"") 20 | ioutil.WriteFile("env_values", data, 0644) 21 | defer os.Remove("env_values") 22 | 23 | LoadEnv("env_values") 24 | 25 | got := os.Getenv("FATHOM_DATABASE_DRIVER") 26 | if got != "sqlite3" { 27 | t.Errorf("Expected %v, got %v", "sqlite3", got) 28 | } 29 | } 30 | 31 | func TestParse(t *testing.T) { 32 | // empty config, should not fatal 33 | cfg := Parse() 34 | if cfg.Secret == "" { 35 | t.Errorf("expected secret, got empty string") 36 | } 37 | 38 | secret := "my-super-secret-string" 39 | os.Setenv("FATHOM_SECRET", secret) 40 | cfg = Parse() 41 | if cfg.Secret != secret { 42 | t.Errorf("Expected %#v, got %#v", secret, cfg.Secret) 43 | } 44 | 45 | os.Setenv("FATHOM_DATABASE_DRIVER", "sqlite") 46 | cfg = Parse() 47 | if cfg.Database.Driver != "sqlite3" { 48 | t.Errorf("expected %#v, got %#v", "sqlite3", cfg.Database.Driver) 49 | } 50 | } 51 | 52 | func TestDatabaseURL(t *testing.T) { 53 | data := []byte("FATHOM_DATABASE_URL=\"postgres://dbuser:dbsecret@dbhost:1234/dbname\"") 54 | ioutil.WriteFile("env_values", data, 0644) 55 | defer os.Remove("env_values") 56 | 57 | LoadEnv("env_values") 58 | cfg := Parse() 59 | driver := "postgres" 60 | url := "postgres://dbuser:dbsecret@dbhost:1234/dbname" 61 | if cfg.Database.Driver != driver { 62 | t.Errorf("Expected %#v, got %#v", driver, cfg.Database.Driver) 63 | } 64 | if cfg.Database.URL != url { 65 | t.Errorf("Expected %#v, got %#v", url, cfg.Database.URL) 66 | } 67 | } 68 | 69 | func TestRandomString(t *testing.T) { 70 | r1 := randomString(10) 71 | r2 := randomString(10) 72 | 73 | if r1 == r2 { 74 | t.Errorf("expected two different strings, got %#v", r1) 75 | } 76 | 77 | if l := len(r1); l != 10 { 78 | t.Errorf("expected string of length %d, got string of length %d", 10, l) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/datastore/datastore.go: -------------------------------------------------------------------------------- 1 | package datastore 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/usefathom/fathom/pkg/datastore/sqlstore" 7 | "github.com/usefathom/fathom/pkg/models" 8 | ) 9 | 10 | // ErrNoResults is returned whenever a single-item query returns 0 results 11 | var ErrNoResults = sqlstore.ErrNoResults // ??? 12 | 13 | // Datastore represents a database implementations 14 | type Datastore interface { 15 | // users 16 | GetUser(int64) (*models.User, error) 17 | GetUserByEmail(string) (*models.User, error) 18 | SaveUser(*models.User) error 19 | DeleteUser(*models.User) error 20 | CountUsers() (int64, error) 21 | 22 | // sites 23 | GetSites() ([]*models.Site, error) 24 | GetSite(id int64) (*models.Site, error) 25 | SaveSite(s *models.Site) error 26 | DeleteSite(s *models.Site) error 27 | 28 | // site stats 29 | GetSiteStats(int64, time.Time) (*models.SiteStats, error) 30 | GetAggregatedSiteStats(int64, time.Time, time.Time) (*models.SiteStats, error) 31 | SelectSiteStats(int64, time.Time, time.Time) ([]*models.SiteStats, error) 32 | GetRealtimeVisitorCount(int64) (int64, error) 33 | SaveSiteStats(*models.SiteStats) error 34 | 35 | // pageviews 36 | InsertPageviews([]*models.Pageview) error 37 | UpdatePageviews([]*models.Pageview) error 38 | GetPageview(string) (*models.Pageview, error) 39 | GetProcessablePageviews(limit int) ([]*models.Pageview, error) 40 | DeletePageviews([]*models.Pageview) error 41 | 42 | // page stats 43 | GetPageStats(int64, time.Time, int64, int64) (*models.PageStats, error) 44 | SavePageStats(*models.PageStats) error 45 | SelectAggregatedPageStats(int64, time.Time, time.Time, int, int) ([]*models.PageStats, error) 46 | GetAggregatedPageStatsPageviews(int64, time.Time, time.Time) (int64, error) 47 | 48 | // referrer stats 49 | GetReferrerStats(int64, time.Time, int64, int64) (*models.ReferrerStats, error) 50 | SaveReferrerStats(*models.ReferrerStats) error 51 | SelectAggregatedReferrerStats(int64, time.Time, time.Time, int, int) ([]*models.ReferrerStats, error) 52 | GetAggregatedReferrerStatsPageviews(int64, time.Time, time.Time) (int64, error) 53 | 54 | // hostnames 55 | HostnameID(name string) (int64, error) 56 | PathnameID(name string) (int64, error) 57 | 58 | // misc 59 | Health() error 60 | Close() error 61 | } 62 | 63 | // New instantiates a new datastore from the given configuration struct 64 | func New(c *sqlstore.Config) Datastore { 65 | return sqlstore.New(c) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/config.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | mysql "github.com/go-sql-driver/mysql" 8 | ) 9 | 10 | type Config struct { 11 | Driver string `default:"sqlite3"` 12 | URL string `default:""` 13 | Host string `default:""` 14 | User string `default:""` 15 | Password string `default:""` 16 | Name string `default:"fathom.db"` 17 | SSLMode string `default:""` 18 | } 19 | 20 | func (c *Config) DSN() string { 21 | var dsn string 22 | 23 | // if FATHOM_DATABASE_URL was set, use that 24 | // this relies on the user to set the appropriate parameters, eg ?parseTime=true when using MySQL 25 | if c.URL != "" { 26 | return c.URL 27 | } 28 | 29 | // otherwise, generate from individual fields 30 | switch c.Driver { 31 | case POSTGRES: 32 | if c.Host != "" { 33 | dsn += " host=" + c.Host 34 | } 35 | if c.Name != "" { 36 | dsn += " dbname=" + c.Name 37 | } 38 | if c.User != "" { 39 | dsn += " user=" + c.User 40 | } 41 | if c.Password != "" { 42 | dsn += " password=" + c.Password 43 | } 44 | if c.SSLMode != "" { 45 | dsn += " sslmode=" + c.SSLMode 46 | } 47 | 48 | dsn = strings.TrimSpace(dsn) 49 | case MYSQL: 50 | mc := mysql.NewConfig() 51 | mc.User = c.User 52 | mc.Passwd = c.Password 53 | mc.Addr = c.Host 54 | mc.Net = "tcp" 55 | mc.DBName = c.Name 56 | mc.Params = map[string]string{ 57 | "parseTime": "true", 58 | } 59 | if c.SSLMode != "" { 60 | mc.Params["tls"] = c.SSLMode 61 | } 62 | dsn = mc.FormatDSN() 63 | case SQLITE: 64 | dsn = c.Name + "?_busy_timeout=10000" 65 | } 66 | 67 | return dsn 68 | } 69 | 70 | // Dbname returns the database name, either from config values or from the connection URL 71 | func (c *Config) Dbname() string { 72 | if c.Name != "" { 73 | return c.Name 74 | } 75 | 76 | re := regexp.MustCompile(`(?:dbname=|[^\/]?\/)(\w+)`) 77 | m := re.FindStringSubmatch(c.URL) 78 | if len(m) > 1 { 79 | return m[1] 80 | } 81 | 82 | return "" 83 | } 84 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/config_test.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestConfigDSN(t *testing.T) { 9 | c := Config{ 10 | Driver: "postgres", 11 | User: "john", 12 | Password: "foo", 13 | } 14 | e := fmt.Sprintf("user=%s password=%s", c.User, c.Password) 15 | if v := c.DSN(); v != e { 16 | t.Errorf("Invalid DSN. Expected %s, got %s", e, v) 17 | } 18 | 19 | c = Config{ 20 | Driver: "postgres", 21 | User: "john", 22 | Password: "foo", 23 | SSLMode: "disable", 24 | } 25 | e = fmt.Sprintf("user=%s password=%s sslmode=%s", c.User, c.Password, c.SSLMode) 26 | if v := c.DSN(); v != e { 27 | t.Errorf("Invalid DSN. Expected %s, got %s", e, v) 28 | } 29 | } 30 | 31 | func TestConfigDbname(t *testing.T) { 32 | var c Config 33 | 34 | c = Config{ 35 | URL: "postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full", 36 | } 37 | if e, v := "pqgotest", c.Dbname(); v != e { 38 | t.Errorf("Expected %q, got %q", e, v) 39 | } 40 | 41 | c = Config{ 42 | URL: "root@tcp(host.myhost)/mysqltest?loc=Local", 43 | } 44 | if e, v := "mysqltest", c.Dbname(); v != e { 45 | t.Errorf("Expected %q, got %q", e, v) 46 | } 47 | 48 | c = Config{ 49 | URL: "/mysqltest?loc=Local&parseTime=true", 50 | } 51 | if e, v := "mysqltest", c.Dbname(); v != e { 52 | t.Errorf("Expected %q, got %q", e, v) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/hostnames.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func (db *sqlstore) HostnameID(name string) (int64, error) { 8 | var id int64 9 | query := db.Rebind("SELECT id FROM hostnames WHERE name = ? LIMIT 1") 10 | err := db.Get(&id, query, name) 11 | 12 | if err == sql.ErrNoRows { 13 | // Postgres does not support LastInsertID, so use a "... RETURNING" select query 14 | query := db.Rebind(`INSERT INTO hostnames(name) VALUES(?)`) 15 | if db.Driver == POSTGRES { 16 | err := db.Get(&id, query+" RETURNING id", name) 17 | return id, err 18 | } 19 | 20 | // MySQL and SQLite do support LastInsertID, so use that 21 | r, err := db.Exec(query, name) 22 | if err != nil { 23 | return 0, err 24 | } 25 | 26 | return r.LastInsertId() 27 | } 28 | 29 | return id, err 30 | } 31 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/10_alter_stats_table_constraints.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP INDEX unique_daily_site_stats ON daily_site_stats; 4 | DROP INDEX unique_daily_page_stats ON daily_page_stats; 5 | DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; 6 | 7 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); 8 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname(100), pathname(100), date); 9 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname(100), pathname(100), date); 10 | 11 | -- +migrate Down 12 | 13 | DROP INDEX unique_daily_site_stats ON daily_site_stats; 14 | DROP INDEX unique_daily_page_stats ON daily_page_stats; 15 | DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; 16 | 17 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 18 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname(100), pathname(100), date); 19 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname(100), pathname(100), date); -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/11_add_pageview_finished_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE pageviews ADD COLUMN is_finished TINYINT(1) NOT NULL DEFAULT 0; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE pageviews DROP COLUMN is_finished; 8 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/12_create_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE hostnames( 3 | id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, 4 | name VARCHAR(255) NOT NULL 5 | ) CHARACTER SET=utf8 ENGINE=INNODB; 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS hostnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/13_create_unique_hostname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name(100)); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_hostnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/14_create_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE pathnames( 3 | id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, 4 | name VARCHAR(255) NOT NULL 5 | ) CHARACTER SET=utf8 ENGINE=INNODB; 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS pathnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/15_create_unique_pathname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name(100)); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_pathnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/16_fill_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/17_fill_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/18_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_page_stats_old; 3 | RENAME TABLE daily_page_stats TO daily_page_stats_old; 4 | CREATE TABLE daily_page_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | pageviews INTEGER NOT NULL, 9 | visitors INTEGER NOT NULL, 10 | entries INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ) CHARACTER SET=utf8; 16 | INSERT INTO daily_page_stats 17 | SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_page_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_page_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/19_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_referrer_stats_old; 3 | RENAME TABLE daily_referrer_stats TO daily_referrer_stats_old; 4 | CREATE TABLE daily_referrer_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | groupname VARCHAR(255) NULL, 9 | pageviews INTEGER NOT NULL, 10 | visitors INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ) CHARACTER SET=utf8; 16 | INSERT INTO daily_referrer_stats 17 | SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_referrer_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_referrer_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/1_initial_tables.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE users ( 4 | id INT AUTO_INCREMENT PRIMARY KEY NOT NULL, 5 | email VARCHAR(100) NOT NULL, 6 | password VARCHAR(255) NOT NULL 7 | ) CHARACTER SET=utf8 ENGINE=INNODB; 8 | 9 | CREATE TABLE pageviews( 10 | id INT AUTO_INCREMENT PRIMARY KEY NOT NULL, 11 | hostname VARCHAR(255) NOT NULL, 12 | pathname VARCHAR(255) NOT NULL, 13 | session_id VARCHAR(16) NOT NULL, 14 | is_new_visitor TINYINT(1) NOT NULL, 15 | is_new_session TINYINT(1) NOT NULL, 16 | is_unique TINYINT(1) NOT NULL, 17 | is_bounce TINYINT(1) NULL, 18 | referrer VARCHAR(255) NULL, 19 | duration INT(4) NULL, 20 | timestamp DATETIME NOT NULL 21 | ) CHARACTER SET=utf8 ENGINE=INNODB; 22 | 23 | CREATE TABLE daily_page_stats( 24 | hostname VARCHAR(255) NOT NULL, 25 | pathname VARCHAR(255) NOT NULL, 26 | pageviews INT NOT NULL, 27 | visitors INT NOT NULL, 28 | entries INT NOT NULL, 29 | bounce_rate FLOAT NOT NULL, 30 | avg_duration FLOAT NOT NULL, 31 | date DATE NOT NULL 32 | ) CHARACTER SET=utf8 ENGINE=INNODB; 33 | 34 | CREATE TABLE daily_site_stats( 35 | pageviews INT NOT NULL, 36 | visitors INT NOT NULL, 37 | sessions INT NOT NULL, 38 | bounce_rate FLOAT NOT NULL, 39 | avg_duration FLOAT NOT NULL, 40 | date DATE NOT NULL 41 | ) CHARACTER SET=utf8 ENGINE=INNODB; 42 | 43 | CREATE TABLE daily_referrer_stats( 44 | url VARCHAR(255) NOT NULL, 45 | pageviews INT NOT NULL, 46 | visitors INT NOT NULL, 47 | bounce_rate FLOAT NOT NULL, 48 | avg_duration FLOAT NOT NULL, 49 | date DATE NOT NULL 50 | ) CHARACTER SET=utf8 ENGINE=INNODB; 51 | 52 | CREATE UNIQUE INDEX unique_user_email ON users(email); 53 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 54 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname(100), pathname(100), date); 55 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url(100), date); 56 | 57 | 58 | -- +migrate Down 59 | 60 | DROP TABLE IF EXISTS users; 61 | DROP TABLE IF EXISTS pageviews; 62 | DROP TABLE IF EXISTS daily_page_stats; 63 | DROP TABLE IF EXISTS daily_site_stats; 64 | DROP TABLE IF EXISTS daily_referrer_stats; 65 | 66 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/20_recreate_stats_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); 3 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); 4 | 5 | -- +migrate Down 6 | DROP INDEX unique_daily_page_stats ON daily_page_stats; 7 | DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/21_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE page_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | pageviews INTEGER NOT NULL, 7 | visitors INTEGER NOT NULL, 8 | entries INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts DATETIME NOT NULL 13 | ) CHARACTER SET=utf8; 14 | INSERT INTO page_stats 15 | SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') 16 | FROM daily_page_stats s ; 17 | DROP TABLE daily_page_stats; 18 | 19 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/22_alter_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE site_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | pageviews INTEGER NOT NULL, 5 | visitors INTEGER NOT NULL, 6 | sessions INTEGER NOT NULL, 7 | bounce_rate FLOAT NOT NULL, 8 | known_durations INTEGER NOT NULL DEFAULT 0, 9 | avg_duration FLOAT NOT NULL, 10 | ts DATETIME NOT NULL 11 | ) CHARACTER SET=utf8; 12 | INSERT INTO site_stats 13 | SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') 14 | FROM daily_site_stats s ; 15 | DROP TABLE daily_site_stats; 16 | 17 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/23_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE referrer_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | groupname VARCHAR(255) NULL, 7 | pageviews INTEGER NOT NULL, 8 | visitors INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts DATETIME NOT NULL 13 | ) CHARACTER SET=utf8 ENGINE=INNODB; 14 | INSERT INTO referrer_stats 15 | SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') 16 | FROM daily_referrer_stats s; 17 | DROP TABLE daily_referrer_stats; 18 | 19 | -- +migrate Down 20 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/24_recreate_stat_table_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); 3 | CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); 4 | CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); 5 | 6 | -- +migrate Down 7 | DROP INDEX unique_page_stats ON page_stats; 8 | DROP INDEX unique_referrer_stats ON referrer_stats; 9 | DROP INDEX unique_site_stats ON site_stats; 10 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/2_known_durations_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 4 | ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 5 | ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 6 | 7 | -- +migrate Down 8 | 9 | ALTER TABLE daily_site_stats DROP COLUMN known_durations; 10 | ALTER TABLE daily_page_stats DROP COLUMN known_durations; 11 | ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; 12 | 13 | 14 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/3_referrer_group_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; 4 | 5 | ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); 6 | ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); 7 | ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); 8 | 9 | 10 | UPDATE daily_referrer_stats SET hostname = SUBSTRING_INDEX( url, "/", 3) WHERE url != "" AND ( hostname = "" OR hostname IS NULL); 11 | UPDATE daily_referrer_stats SET pathname = REPLACE(url, hostname, "") WHERE url != "" AND (pathname = '' OR pathname IS NULL); 12 | 13 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname(100), pathname(100), date); 14 | ALTER TABLE daily_referrer_stats DROP COLUMN url; 15 | 16 | -- +migrate Down 17 | 18 | ALTER TABLE daily_referrer_stats DROP COLUMN groupname; 19 | ALTER TABLE daily_referrer_stats DROP COLUMN hostname; 20 | ALTER TABLE daily_referrer_stats DROP COLUMN pathname; 21 | 22 | ALTER TABLE daily_referrer_stats ADD COLUMN url VARCHAR(255) NOT NULL; 23 | 24 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/4_pageview_id_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE pageviews DROP COLUMN session_id; 4 | ALTER TABLE pageviews DROP COLUMN id; 5 | ALTER TABLE pageviews ADD COLUMN id VARCHAR(31) NOT NULL FIRST; 6 | 7 | -- +migrate Down 8 | 9 | ALTER TABLE pageviews DROP COLUMN id; 10 | ALTER TABLE pageviews ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY NOT NULL FIRST; 11 | ALTER TABLE pageviews ADD COLUMN session_id VARCHAR(16) NOT NULL AFTER id; 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/5_create_sites_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE sites ( 3 | id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, 4 | tracking_id VARCHAR(8) UNIQUE, 5 | name VARCHAR(100) NOT NULL 6 | ) CHARACTER SET=utf8 ENGINE=INNODB; 7 | 8 | -- +migrate Down 9 | DROP TABLE IF EXISTS sites; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/6_add_site_tracking_id_column_to_pageviews_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | TRUNCATE pageviews; 4 | ALTER TABLE pageviews ADD COLUMN site_tracking_id VARCHAR(8) NOT NULL; 5 | 6 | -- +migrate Down 7 | 8 | ALTER TABLE pageviews DROP COLUMN site_tracking_id; 9 | 10 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/7_add_site_id_to_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_site_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/8_add_site_id_to_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_page_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/mysql/9_add_site_id_to_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_referrer_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/10_alter_numeric_column_precision.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ALTER COLUMN bounce_rate TYPE NUMERIC; 4 | ALTER TABLE daily_page_stats ALTER COLUMN bounce_rate TYPE NUMERIC; 5 | ALTER TABLE daily_referrer_stats ALTER COLUMN bounce_rate TYPE NUMERIC; 6 | ALTER TABLE daily_site_stats ALTER COLUMN avg_duration TYPE NUMERIC; 7 | ALTER TABLE daily_page_stats ALTER COLUMN avg_duration TYPE NUMERIC; 8 | ALTER TABLE daily_referrer_stats ALTER COLUMN avg_duration TYPE NUMERIC; 9 | 10 | -- +migrate Down 11 | 12 | 13 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/11_alter_stats_table_constraints.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP INDEX IF EXISTS unique_daily_site_stats; 4 | DROP INDEX IF EXISTS unique_daily_page_stats; 5 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 6 | 7 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); 8 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname, pathname, date); 9 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname, pathname, date); 10 | 11 | -- +migrate Down 12 | 13 | DROP INDEX IF EXISTS unique_daily_site_stats; 14 | DROP INDEX IF EXISTS unique_daily_page_stats; 15 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 16 | 17 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 18 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); 19 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/12_add_pageview_finished_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE pageviews ADD COLUMN is_finished BOOLEAN NOT NULL DEFAULT FALSE; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE pageviews DROP COLUMN is_finished; 8 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/13_create_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE hostnames( 3 | id SERIAL PRIMARY KEY NOT NULL, 4 | name VARCHAR(255) NOT NULL 5 | ); 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS hostnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/14_create_unique_hostname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_hostnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/15_create_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE pathnames( 3 | id SERIAL PRIMARY KEY NOT NULL, 4 | name VARCHAR(255) NOT NULL 5 | ); 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS pathnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/16_create_unique_pathname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_pathnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/17_fill_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/18_fill_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/19_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_page_stats_old; 3 | ALTER TABLE daily_page_stats RENAME TO daily_page_stats_old; 4 | CREATE TABLE daily_page_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | pageviews INTEGER NOT NULL, 9 | visitors INTEGER NOT NULL, 10 | entries INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ); 16 | INSERT INTO daily_page_stats 17 | SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_page_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_page_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/1_initial_tables.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE users( 4 | id SERIAL PRIMARY KEY NOT NULL, 5 | email VARCHAR(255) NOT NULL, 6 | password VARCHAR(255) NOT NULL 7 | ); 8 | 9 | CREATE TABLE pageviews( 10 | id SERIAL PRIMARY KEY NOT NULL, 11 | hostname VARCHAR(255) NOT NULL, 12 | pathname VARCHAR(255) NOT NULL, 13 | session_id VARCHAR(16) NOT NULL, 14 | is_new_visitor BOOLEAN NOT NULL, 15 | is_new_session BOOLEAN NOT NULL, 16 | is_unique BOOLEAN NOT NULL, 17 | is_bounce BOOLEAN NULL, 18 | referrer VARCHAR(255) NULL, 19 | duration INTEGER NULL, 20 | timestamp TIMESTAMP WITH TIME ZONE NOT NULL 21 | ); 22 | 23 | CREATE TABLE daily_page_stats( 24 | hostname VARCHAR(255) NOT NULL, 25 | pathname VARCHAR(255) NOT NULL, 26 | pageviews INTEGER NOT NULL, 27 | visitors INTEGER NOT NULL, 28 | entries INTEGER NOT NULL, 29 | bounce_rate NUMERIC(4) NOT NULL, 30 | avg_duration NUMERIC(4) NOT NULL, 31 | date DATE NOT NULL 32 | ); 33 | 34 | CREATE TABLE daily_site_stats( 35 | pageviews INTEGER NOT NULL, 36 | visitors INTEGER NOT NULL, 37 | sessions INTEGER NOT NULL, 38 | bounce_rate NUMERIC(4) NOT NULL, 39 | avg_duration NUMERIC(4) NOT NULL, 40 | date DATE NOT NULL 41 | ); 42 | 43 | CREATE TABLE daily_referrer_stats( 44 | url VARCHAR(255) NOT NULL, 45 | pageviews INTEGER NOT NULL, 46 | visitors INTEGER NOT NULL, 47 | bounce_rate NUMERIC(4) NOT NULL, 48 | avg_duration NUMERIC(4) NOT NULL, 49 | date DATE NOT NULL 50 | ); 51 | 52 | CREATE UNIQUE INDEX unique_user_email ON users(email); 53 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 54 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); 55 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url, date); 56 | 57 | -- +migrate Down 58 | 59 | DROP TABLE IF EXISTS users; 60 | DROP TABLE IF EXISTS pageviews; 61 | DROP TABLE IF EXISTS daily_page_stats; 62 | DROP TABLE IF EXISTS daily_site_stats; 63 | DROP TABLE IF EXISTS daily_referrer_stats; 64 | 65 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/20_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_referrer_stats_old; 3 | ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; 4 | CREATE TABLE daily_referrer_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | groupname VARCHAR(255) NULL, 9 | pageviews INTEGER NOT NULL, 10 | visitors INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ); 16 | INSERT INTO daily_referrer_stats 17 | SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_referrer_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_referrer_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/21_recreate_stats_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); 3 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); 4 | 5 | -- +migrate Down 6 | DROP INDEX unique_daily_page_stats; 7 | DROP INDEX unique_daily_referrer_stats; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/22_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE page_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | pageviews INTEGER NOT NULL, 7 | visitors INTEGER NOT NULL, 8 | entries INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts TIMESTAMP WITHOUT TIME ZONE NOT NULL 13 | ); 14 | INSERT INTO page_stats 15 | SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp 16 | FROM daily_page_stats s; 17 | DROP TABLE daily_page_stats; 18 | 19 | -- +migrate Down 20 | 21 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/23_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE referrer_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | groupname VARCHAR(255) NULL, 7 | pageviews INTEGER NOT NULL, 8 | visitors INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts TIMESTAMP WITHOUT TIME ZONE NOT NULL 13 | ); 14 | INSERT INTO referrer_stats 15 | SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp 16 | FROM daily_referrer_stats s; 17 | DROP TABLE daily_referrer_stats; 18 | 19 | -- +migrate Down 20 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/24_alter_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE site_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | pageviews INTEGER NOT NULL, 5 | visitors INTEGER NOT NULL, 6 | sessions INTEGER NOT NULL, 7 | bounce_rate FLOAT NOT NULL, 8 | known_durations INTEGER NOT NULL DEFAULT 0, 9 | avg_duration FLOAT NOT NULL, 10 | ts TIMESTAMP WITHOUT TIME ZONE NOT NULL 11 | ); 12 | INSERT INTO site_stats 13 | SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp 14 | FROM daily_site_stats s; 15 | DROP TABLE daily_site_stats; 16 | 17 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/25_recreate_stat_table_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP INDEX IF EXISTS unique_daily_page_stats; 3 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 4 | CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); 5 | CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); 6 | CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); 7 | 8 | -- +migrate Down 9 | DROP INDEX IF EXISTS unique_page_stats; 10 | DROP INDEX IF EXISTS unique_referrer_stats; 11 | DROP INDEX IF EXISTS unique_site_stats; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/26_alter_pageviews_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE pageviews ALTER COLUMN timestamp TYPE TIMESTAMP WITHOUT TIME ZONE; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE pageviews ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/2_known_durations_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 4 | ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 5 | ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 6 | 7 | -- +migrate Down 8 | 9 | ALTER TABLE daily_site_stats DROP COLUMN known_durations; 10 | ALTER TABLE daily_page_stats DROP COLUMN known_durations; 11 | ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; 12 | 13 | 14 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/3_referrer_group_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); 4 | ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); 5 | ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); 6 | 7 | UPDATE daily_referrer_stats SET hostname = CONCAT( SPLIT_PART(url, '://', 1), '://', SPLIT_PART(SPLIT_PART(url, '://', 2), '/', 1) ) WHERE url != '' AND ( hostname = '' OR hostname IS NULL); 8 | UPDATE daily_referrer_stats SET pathname = SPLIT_PART( url, hostname, 2 ) WHERE url != '' AND (pathname = '' OR pathname IS NULL); 9 | 10 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 11 | ALTER TABLE daily_referrer_stats DROP COLUMN url; 12 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); 13 | 14 | -- +migrate Down 15 | 16 | ALTER TABLE daily_referrer_stats DROP COLUMN groupname; 17 | ALTER TABLE daily_referrer_stats DROP COLUMN hostname; 18 | ALTER TABLE daily_referrer_stats DROP COLUMN pathname; 19 | 20 | ALTER TABLE daily_referrer_stats ADD COLUMN url VARCHAR(255) NOT NULL; 21 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/4_pageview_id_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | TRUNCATE pageviews; -- postgres will fail because of NULL values otherwise 4 | ALTER TABLE pageviews DROP COLUMN session_id; 5 | ALTER TABLE pageviews DROP COLUMN id; 6 | ALTER TABLE pageviews ADD COLUMN id VARCHAR(31) NOT NULL; 7 | 8 | -- +migrate Down 9 | 10 | ALTER TABLE pageviews DROP COLUMN id; 11 | ALTER TABLE pageviews ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY NOT NULL; 12 | ALTER TABLE pageviews ADD COLUMN session_id VARCHAR(16) NOT NULL; 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/5_create_sites_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE sites ( 3 | id SERIAL PRIMARY KEY NOT NULL, 4 | tracking_id VARCHAR(8) UNIQUE, 5 | name VARCHAR(100) NOT NULL 6 | ); 7 | 8 | -- +migrate Down 9 | DROP TABLE IF EXISTS sites; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/6_add_site_tracking_id_column_to_pageviews_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | TRUNCATE pageviews; -- postgres will fail because of NULL values otherwise 4 | ALTER TABLE pageviews ADD COLUMN site_tracking_id VARCHAR(8) NOT NULL; 5 | 6 | -- +migrate Down 7 | 8 | ALTER TABLE pageviews DROP COLUMN site_tracking_id; 9 | 10 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/7_add_site_id_to_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_site_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/8_add_site_id_to_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_page_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/postgres/9_add_site_id_to_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | ALTER TABLE daily_referrer_stats DROP COLUMN site_id; 8 | 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/10_alter_stats_table_constraints.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP INDEX IF EXISTS unique_daily_site_stats; 4 | DROP INDEX IF EXISTS unique_daily_page_stats; 5 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 6 | 7 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); 8 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname, pathname, date); 9 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname, pathname, date); 10 | 11 | -- +migrate Down 12 | 13 | DROP INDEX IF EXISTS unique_daily_site_stats; 14 | DROP INDEX IF EXISTS unique_daily_page_stats; 15 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 16 | 17 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 18 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); 19 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/11_add_pageview_finished_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP TABLE IF EXISTS pageviews; 4 | CREATE TABLE pageviews( 5 | id VARCHAR(31) NOT NULL, 6 | site_tracking_id VARCHAR(8) NOT NULL, 7 | hostname VARCHAR(255) NOT NULL, 8 | pathname VARCHAR(255) NOT NULL, 9 | is_new_visitor TINYINT(1) NOT NULL, 10 | is_new_session TINYINT(1) NOT NULL, 11 | is_unique TINYINT(1) NOT NULL, 12 | is_bounce TINYINT(1) NULL, 13 | is_finished TINYINT(1) NOT NULL DEFAULT 0, 14 | referrer VARCHAR(255) NULL, 15 | duration INTEGER(4) NULL, 16 | timestamp DATETIME NOT NULL 17 | ); 18 | 19 | -- +migrate Down 20 | 21 | DROP TABLE IF EXISTS pageviews; 22 | CREATE TABLE pageviews( 23 | id VARCHAR(31) NOT NULL, 24 | site_tracking_id VARCHAR(8) NOT NULL, 25 | hostname VARCHAR(255) NOT NULL, 26 | pathname VARCHAR(255) NOT NULL, 27 | is_new_visitor TINYINT(1) NOT NULL, 28 | is_new_session TINYINT(1) NOT NULL, 29 | is_unique TINYINT(1) NOT NULL, 30 | is_bounce TINYINT(1) NULL, 31 | referrer VARCHAR(255) NULL, 32 | duration INTEGER(4) NULL, 33 | timestamp DATETIME NOT NULL 34 | ); -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/12_create_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE hostnames( 3 | id INTEGER PRIMARY KEY, 4 | name VARCHAR(255) NOT NULL 5 | ); 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS hostnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/13_create_unique_hostname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_hostnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/14_create_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE pathnames( 3 | id INTEGER PRIMARY KEY, 4 | name VARCHAR(255) NOT NULL 5 | ); 6 | 7 | -- +migrate Down 8 | DROP TABLE IF EXISTS pathnames; 9 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/15_create_unique_pathname_index.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name); 3 | 4 | -- +migrate Down 5 | DROP INDEX IF EXISTS unique_pathnames_name; 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/15_vacuum.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up notransaction 2 | VACUUM; 3 | 4 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/16_fill_hostnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/17_fill_pathnames_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; 3 | 4 | -- +migrate Down 5 | 6 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/18_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_page_stats_old; 3 | ALTER TABLE daily_page_stats RENAME TO daily_page_stats_old; 4 | CREATE TABLE daily_page_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | pageviews INTEGER NOT NULL, 9 | visitors INTEGER NOT NULL, 10 | entries INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ); 16 | INSERT INTO daily_page_stats 17 | SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_page_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_page_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/19_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS daily_referrer_stats_old; 3 | ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; 4 | CREATE TABLE daily_referrer_stats( 5 | site_id INTEGER NOT NULL DEFAULT 1, 6 | hostname_id INTEGER NOT NULL, 7 | pathname_id INTEGER NOT NULL, 8 | groupname VARCHAR(255) NULL, 9 | pageviews INTEGER NOT NULL, 10 | visitors INTEGER NOT NULL, 11 | bounce_rate FLOAT NOT NULL, 12 | known_durations INTEGER NOT NULL DEFAULT 0, 13 | avg_duration FLOAT NOT NULL, 14 | date DATE NOT NULL 15 | ); 16 | INSERT INTO daily_referrer_stats 17 | SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date 18 | FROM daily_referrer_stats_old s 19 | LEFT JOIN hostnames h ON h.name = s.hostname 20 | LEFT JOIN pathnames p ON p.name = s.pathname; 21 | DROP TABLE daily_referrer_stats_old; 22 | 23 | -- +migrate Down 24 | 25 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/1_initial_tables.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE users ( 4 | id INTEGER PRIMARY KEY, 5 | email VARCHAR(255) NOT NULL, 6 | password VARCHAR(255) NOT NULL 7 | ); 8 | 9 | CREATE TABLE pageviews( 10 | id INTEGER PRIMARY KEY, 11 | hostname VARCHAR(255) NOT NULL, 12 | pathname VARCHAR(255) NOT NULL, 13 | session_id VARCHAR(16) NOT NULL, 14 | is_new_visitor TINYINT(1) NOT NULL, 15 | is_new_session TINYINT(1) NOT NULL, 16 | is_unique TINYINT(1) NOT NULL, 17 | is_bounce TINYINT(1) NULL, 18 | referrer VARCHAR(255) NULL, 19 | duration INTEGER(4) NULL, 20 | timestamp DATETIME NOT NULL 21 | ); 22 | 23 | CREATE TABLE daily_page_stats( 24 | hostname VARCHAR(255) NOT NULL, 25 | pathname VARCHAR(255) NOT NULL, 26 | pageviews INTEGER NOT NULL, 27 | visitors INTEGER NOT NULL, 28 | entries INTEGER NOT NULL, 29 | bounce_rate FLOAT NOT NULL, 30 | avg_duration FLOAT NOT NULL, 31 | date DATE NOT NULL 32 | ); 33 | 34 | CREATE TABLE daily_site_stats( 35 | pageviews INTEGER NOT NULL, 36 | visitors INTEGER NOT NULL, 37 | sessions INTEGER NOT NULL, 38 | bounce_rate FLOAT NOT NULL, 39 | avg_duration FLOAT NOT NULL, 40 | date DATE NOT NULL 41 | ); 42 | 43 | CREATE TABLE daily_referrer_stats( 44 | url VARCHAR(255) NOT NULL, 45 | pageviews INTEGER NOT NULL, 46 | visitors INTEGER NOT NULL, 47 | bounce_rate FLOAT NOT NULL, 48 | avg_duration FLOAT NOT NULL, 49 | date DATE NOT NULL 50 | ); 51 | 52 | CREATE UNIQUE INDEX unique_user_email ON users(email); 53 | CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); 54 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); 55 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url, date); 56 | 57 | -- +migrate Down 58 | 59 | DROP TABLE IF EXISTS users; 60 | DROP TABLE IF EXISTS pageviews; 61 | DROP TABLE IF EXISTS daily_page_stats; 62 | DROP TABLE IF EXISTS daily_site_stats; 63 | DROP TABLE IF EXISTS daily_referrer_stats; 64 | 65 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/20_recreate_stats_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP INDEX IF EXISTS unique_daily_page_stats; 3 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 4 | CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); 5 | CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); 6 | 7 | -- +migrate Down 8 | DROP INDEX IF EXISTS unique_daily_page_stats; 9 | DROP INDEX IF EXISTS unique_daily_referrer_stats; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/21_alter_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE page_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | pageviews INTEGER NOT NULL, 7 | visitors INTEGER NOT NULL, 8 | entries INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts DATETIME NOT NULL 13 | ); 14 | INSERT INTO page_stats 15 | SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' 16 | FROM daily_page_stats s ; 17 | DROP TABLE daily_page_stats; 18 | 19 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/22_alter_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE site_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | pageviews INTEGER NOT NULL, 5 | visitors INTEGER NOT NULL, 6 | sessions INTEGER NOT NULL, 7 | bounce_rate FLOAT NOT NULL, 8 | known_durations INTEGER NOT NULL DEFAULT 0, 9 | avg_duration FLOAT NOT NULL, 10 | ts DATETIME NOT NULL 11 | ); 12 | INSERT INTO site_stats 13 | SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' 14 | FROM daily_site_stats s ; 15 | DROP TABLE daily_site_stats; 16 | 17 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/23_alter_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE referrer_stats( 3 | site_id INTEGER NOT NULL DEFAULT 1, 4 | hostname_id INTEGER NOT NULL, 5 | pathname_id INTEGER NOT NULL, 6 | groupname VARCHAR(255) NULL, 7 | pageviews INTEGER NOT NULL, 8 | visitors INTEGER NOT NULL, 9 | bounce_rate FLOAT NOT NULL, 10 | known_durations INTEGER NOT NULL DEFAULT 0, 11 | avg_duration FLOAT NOT NULL, 12 | ts DATETIME NOT NULL 13 | ); 14 | INSERT INTO referrer_stats 15 | SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' 16 | FROM daily_referrer_stats s; 17 | DROP TABLE daily_referrer_stats; 18 | 19 | -- +migrate Down 20 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/24_recreate_stat_table_indices.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP INDEX IF EXISTS unique_daily_page_stats; 3 | DROP INDEX IF EXISTS unique_daily_referrer_stats; 4 | CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); 5 | CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); 6 | CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); 7 | 8 | -- +migrate Down 9 | DROP INDEX IF EXISTS unique_page_stats; 10 | DROP INDEX IF EXISTS unique_referrer_stats; 11 | DROP INDEX IF EXISTS unique_site_stats; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/25_vacuum.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up notransaction 2 | VACUUM; 3 | 4 | -- +migrate Down -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/26_sites_id_autoinc.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | DROP TABLE IF EXISTS sites_old; 3 | ALTER TABLE sites RENAME TO sites_old; 4 | CREATE TABLE sites ( 5 | `id` INTEGER PRIMARY KEY AUTOINCREMENT, 6 | `tracking_id` VARCHAR(8) UNIQUE, 7 | `name` VARCHAR(100) NOT NULL 8 | ); 9 | INSERT INTO sites SELECT `id`, `tracking_id`, `name` FROM sites_old; 10 | 11 | -- +migrate Down 12 | DROP TABLE IF EXISTS sites_old; 13 | ALTER TABLE sites RENAME TO sites_old; 14 | CREATE TABLE sites ( 15 | `id` INTEGER PRIMARY KEY, 16 | `tracking_id` VARCHAR(8) UNIQUE, 17 | `name` VARCHAR(100) NOT NULL 18 | ); 19 | INSERT INTO sites SELECT `id`, `tracking_id`, `name` FROM sites_old; 20 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/2_known_durations_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 4 | ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 5 | ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; 6 | 7 | -- +migrate Down 8 | 9 | ALTER TABLE daily_site_stats DROP COLUMN known_durations; 10 | ALTER TABLE daily_page_stats DROP COLUMN known_durations; 11 | ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; 12 | 13 | 14 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/3_referrer_group_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); 4 | ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); 5 | ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); 6 | 7 | UPDATE daily_referrer_stats SET hostname = SUBSTR(url, 0, (INSTR(url, '://')+3+INSTR(SUBSTR(url, INSTR(url, '://')+3), '/')-1)) WHERE url != '' AND (hostname = '' OR hostname IS NULL); 8 | UPDATE daily_referrer_stats SET pathname = SUBSTR(url, LENGTH(hostname)+1) WHERE url != '' AND (pathname = '' OR pathname IS NULL); 9 | 10 | -- drop `url` column... oh sqlite 11 | ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; 12 | CREATE TABLE daily_referrer_stats( 13 | hostname VARCHAR(255) NOT NULL, 14 | pathname VARCHAR(255) NOT NULL, 15 | groupname VARCHAR(255) NULL, 16 | pageviews INTEGER NOT NULL, 17 | visitors INTEGER NOT NULL, 18 | bounce_rate FLOAT NOT NULL, 19 | avg_duration FLOAT NOT NULL, 20 | known_durations INTEGER NOT NULL DEFAULT 0, 21 | date DATE NOT NULL 22 | ); 23 | INSERT INTO daily_referrer_stats SELECT hostname, pathname, groupname, pageviews, visitors, bounce_rate, avg_duration, known_durations, date FROM daily_referrer_stats_old; 24 | 25 | -- +migrate Down 26 | 27 | -- TODO.... 28 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/4_pageview_id_column.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP TABLE pageviews; 4 | CREATE TABLE pageviews( 5 | id VARCHAR(31) NOT NULL, 6 | hostname VARCHAR(255) NOT NULL, 7 | pathname VARCHAR(255) NOT NULL, 8 | is_new_visitor TINYINT(1) NOT NULL, 9 | is_new_session TINYINT(1) NOT NULL, 10 | is_unique TINYINT(1) NOT NULL, 11 | is_bounce TINYINT(1) NULL, 12 | referrer VARCHAR(255) NULL, 13 | duration INTEGER(4) NULL, 14 | timestamp DATETIME NOT NULL 15 | ); 16 | 17 | -- +migrate Down 18 | 19 | DROP TABLE pageviews; 20 | CREATE TABLE pageviews( 21 | id INTEGER PRIMARY KEY, 22 | hostname VARCHAR(255) NOT NULL, 23 | pathname VARCHAR(255) NOT NULL, 24 | session_id VARCHAR(16) NOT NULL, 25 | is_new_visitor TINYINT(1) NOT NULL, 26 | is_new_session TINYINT(1) NOT NULL, 27 | is_unique TINYINT(1) NOT NULL, 28 | is_bounce TINYINT(1) NULL, 29 | referrer VARCHAR(255) NULL, 30 | duration INTEGER(4) NULL, 31 | timestamp DATETIME NOT NULL 32 | ); 33 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/5_create_sites_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | CREATE TABLE sites ( 3 | id INTEGER PRIMARY KEY, 4 | tracking_id VARCHAR(8) UNIQUE, 5 | name VARCHAR(100) NOT NULL 6 | ); 7 | 8 | -- +migrate Down 9 | DROP TABLE IF EXISTS sites; -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/6_add_site_tracking_id_column_to_pageviews_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | DROP TABLE IF EXISTS pageviews; 4 | CREATE TABLE pageviews( 5 | id VARCHAR(31) NOT NULL, 6 | site_tracking_id VARCHAR(8) NOT NULL, 7 | hostname VARCHAR(255) NOT NULL, 8 | pathname VARCHAR(255) NOT NULL, 9 | is_new_visitor TINYINT(1) NOT NULL, 10 | is_new_session TINYINT(1) NOT NULL, 11 | is_unique TINYINT(1) NOT NULL, 12 | is_bounce TINYINT(1) NULL, 13 | referrer VARCHAR(255) NULL, 14 | duration INTEGER(4) NULL, 15 | timestamp DATETIME NOT NULL 16 | ); 17 | 18 | 19 | -- +migrate Down 20 | 21 | DROP TABLE IF EXISTS pageviews; 22 | CREATE TABLE pageviews( 23 | id VARCHAR(31) NOT NULL, 24 | hostname VARCHAR(255) NOT NULL, 25 | pathname VARCHAR(255) NOT NULL, 26 | is_new_visitor TINYINT(1) NOT NULL, 27 | is_new_session TINYINT(1) NOT NULL, 28 | is_unique TINYINT(1) NOT NULL, 29 | is_bounce TINYINT(1) NULL, 30 | referrer VARCHAR(255) NULL, 31 | duration INTEGER(4) NULL, 32 | timestamp DATETIME NOT NULL 33 | ); 34 | 35 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/7_add_site_id_to_site_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | 8 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/8_add_site_id_to_page_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | 8 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/migrations/sqlite3/9_add_site_id_to_referrer_stats_table.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; 4 | 5 | -- +migrate Down 6 | 7 | 8 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/page_stats.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/usefathom/fathom/pkg/models" 8 | ) 9 | 10 | func (db *sqlstore) GetPageStats(siteID int64, date time.Time, hostnameID int64, pathnameID int64) (*models.PageStats, error) { 11 | stats := &models.PageStats{New: false} 12 | query := db.Rebind(`SELECT * FROM page_stats WHERE site_id = ? AND hostname_id = ? AND pathname_id = ? AND ts = ? LIMIT 1`) 13 | err := db.Get(stats, query, siteID, hostnameID, pathnameID, date.Format(DATE_FORMAT)) 14 | if err == sql.ErrNoRows { 15 | return nil, ErrNoResults 16 | } 17 | 18 | return stats, mapError(err) 19 | } 20 | 21 | func (db *sqlstore) SavePageStats(s *models.PageStats) error { 22 | if s.New { 23 | return db.insertPageStats(s) 24 | } 25 | 26 | return db.updatePageStats(s) 27 | } 28 | 29 | func (db *sqlstore) insertPageStats(s *models.PageStats) error { 30 | query := db.Rebind(`INSERT INTO page_stats(pageviews, visitors, entries, bounce_rate, avg_duration, known_durations, site_id, hostname_id, pathname_id, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) 31 | _, err := db.Exec(query, s.Pageviews, s.Visitors, s.Entries, s.BounceRate, s.AvgDuration, s.KnownDurations, s.SiteID, s.HostnameID, s.PathnameID, s.Date.Format(DATE_FORMAT)) 32 | return err 33 | } 34 | 35 | func (db *sqlstore) updatePageStats(s *models.PageStats) error { 36 | query := db.Rebind(`UPDATE page_stats SET pageviews = ?, visitors = ?, entries = ?, bounce_rate = ?, avg_duration = ?, known_durations = ? WHERE site_id = ? AND hostname_id = ? AND pathname_id = ? AND ts = ?`) 37 | _, err := db.Exec(query, s.Pageviews, s.Visitors, s.Entries, s.BounceRate, s.AvgDuration, s.KnownDurations, s.SiteID, s.HostnameID, s.PathnameID, s.Date.Format(DATE_FORMAT)) 38 | return err 39 | } 40 | 41 | func (db *sqlstore) SelectAggregatedPageStats(siteID int64, startDate time.Time, endDate time.Time, offset int, limit int) ([]*models.PageStats, error) { 42 | var result []*models.PageStats 43 | query := db.Rebind(`SELECT 44 | h.name AS hostname, 45 | p.name AS pathname, 46 | SUM(pageviews) AS pageviews, 47 | SUM(visitors) AS visitors, 48 | SUM(entries) AS entries, 49 | COALESCE(SUM(entries*bounce_rate) / NULLIF(SUM(entries), 0), 0.00) AS bounce_rate, 50 | COALESCE(SUM(pageviews*avg_duration) / SUM(pageviews), 0.00) AS avg_duration 51 | FROM page_stats s 52 | LEFT JOIN hostnames h ON h.id = s.hostname_id 53 | LEFT JOIN pathnames p ON p.id = s.pathname_id 54 | WHERE site_id = ? AND ts >= ? AND ts <= ? 55 | GROUP BY hostname, pathname 56 | ORDER BY pageviews DESC LIMIT ? OFFSET ?`) 57 | err := db.Select(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT), limit, offset) 58 | return result, err 59 | } 60 | 61 | func (db *sqlstore) GetAggregatedPageStatsPageviews(siteID int64, startDate time.Time, endDate time.Time) (int64, error) { 62 | var result int64 63 | query := db.Rebind(`SELECT COALESCE(SUM(pageviews), 0) FROM page_stats WHERE site_id = ? AND ts >= ? AND ts <= ?`) 64 | err := db.Get(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) 65 | return result, err 66 | } 67 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/pageviews.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/usefathom/fathom/pkg/models" 9 | ) 10 | 11 | // GetPageview selects a single pageview by its string ID 12 | func (db *sqlstore) GetPageview(id string) (*models.Pageview, error) { 13 | result := &models.Pageview{} 14 | query := db.Rebind(`SELECT * FROM pageviews WHERE id = ? LIMIT 1`) 15 | err := db.Get(result, query, id) 16 | 17 | if err != nil { 18 | return nil, mapError(err) 19 | } 20 | 21 | return result, nil 22 | } 23 | 24 | // InsertPageviews bulks-insert multiple pageviews using a single INSERT statement 25 | // IMPORTANT: This does not insert the actual IsBounce, Duration and IsFinished values 26 | func (db *sqlstore) InsertPageviews(pageviews []*models.Pageview) error { 27 | n := len(pageviews) 28 | if n == 0 { 29 | return nil 30 | } 31 | 32 | // generate placeholders string 33 | placeholderTemplate := "(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, FALSE, 0)," 34 | placeholders := strings.Repeat(placeholderTemplate, n) 35 | placeholders = placeholders[:len(placeholders)-1] 36 | nPlaceholders := strings.Count(placeholderTemplate, "?") 37 | 38 | // init values slice with correct length 39 | nValues := n * nPlaceholders 40 | values := make([]interface{}, nValues) 41 | 42 | // overwrite nil values in slice 43 | j := 0 44 | for i := range pageviews { 45 | 46 | // test for columns with ignored values 47 | if pageviews[i].IsBounce != true || pageviews[i].Duration > 0 || pageviews[i].IsFinished != false { 48 | log.Warnf("inserting pageview with invalid column values for bulk-insert") 49 | } 50 | 51 | j = i * nPlaceholders 52 | values[j] = pageviews[i].ID 53 | values[j+1] = pageviews[i].SiteTrackingID 54 | values[j+2] = pageviews[i].Hostname 55 | values[j+3] = pageviews[i].Pathname 56 | values[j+4] = pageviews[i].IsNewVisitor 57 | values[j+5] = pageviews[i].IsNewSession 58 | values[j+6] = pageviews[i].IsUnique 59 | values[j+7] = pageviews[i].Referrer 60 | values[j+8] = pageviews[i].Timestamp 61 | } 62 | 63 | // string together query & execute with values 64 | query := `INSERT INTO pageviews(id, site_tracking_id, hostname, pathname, is_new_visitor, is_new_session, is_unique, referrer, timestamp, is_bounce, is_finished, duration) VALUES ` + placeholders 65 | query = db.Rebind(query) 66 | _, err := db.Exec(query, values...) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // UpdatePageviews updates multiple pageviews using a single transaction 75 | // IMPORTANT: this function only updates the IsFinished, IsBounce and Duration values 76 | func (db *sqlstore) UpdatePageviews(pageviews []*models.Pageview) error { 77 | if len(pageviews) == 0 { 78 | return nil 79 | } 80 | 81 | tx, err := db.Beginx() 82 | if err != nil { 83 | return err 84 | } 85 | 86 | query := tx.Rebind(`UPDATE pageviews SET is_bounce = ?, duration = ?, is_finished = ? WHERE id = ?`) 87 | stmt, err := tx.Preparex(query) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | for i := range pageviews { 93 | _, err := stmt.Exec(pageviews[i].IsBounce, pageviews[i].Duration, pageviews[i].IsFinished, pageviews[i].ID) 94 | 95 | if err != nil { 96 | tx.Rollback() 97 | return err 98 | } 99 | } 100 | 101 | err = tx.Commit() 102 | return err 103 | } 104 | 105 | // GetProcessablePageviews selects all pageviews which are "done" (ie not still waiting for bounce flag or duration) 106 | func (db *sqlstore) GetProcessablePageviews(limit int) ([]*models.Pageview, error) { 107 | var results []*models.Pageview 108 | thirtyMinsAgo := time.Now().Add(-30 * time.Minute) 109 | query := db.Rebind(`SELECT * FROM pageviews WHERE is_finished = TRUE OR timestamp < ? LIMIT ?`) 110 | err := db.Select(&results, query, thirtyMinsAgo, limit) 111 | return results, err 112 | } 113 | 114 | func (db *sqlstore) DeletePageviews(pageviews []*models.Pageview) error { 115 | ids := []string{} 116 | for _, p := range pageviews { 117 | ids = append(ids, "'"+p.ID+"'") 118 | } 119 | query := db.Rebind(`DELETE FROM pageviews WHERE id IN(` + strings.Join(ids, ",") + `)`) 120 | _, err := db.Exec(query) 121 | return err 122 | } 123 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/pathnames.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func (db *sqlstore) PathnameID(name string) (int64, error) { 8 | var id int64 9 | query := db.Rebind("SELECT id FROM pathnames WHERE name = ? LIMIT 1") 10 | err := db.Get(&id, query, name) 11 | 12 | if err == sql.ErrNoRows { 13 | // Postgres does not support LastInsertID, so use a "... RETURNING" select query 14 | query := db.Rebind(`INSERT INTO pathnames(name) VALUES(?)`) 15 | if db.Driver == POSTGRES { 16 | err := db.Get(&id, query+" RETURNING id", name) 17 | return id, err 18 | } 19 | 20 | // MySQL and SQLite do support LastInsertID, so use that 21 | r, err := db.Exec(query, name) 22 | if err != nil { 23 | return 0, err 24 | } 25 | 26 | return r.LastInsertId() 27 | } 28 | 29 | return id, err 30 | } 31 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/referrer_stats.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/usefathom/fathom/pkg/models" 8 | ) 9 | 10 | func (db *sqlstore) GetReferrerStats(siteID int64, date time.Time, hostnameID int64, pathnameID int64) (*models.ReferrerStats, error) { 11 | stats := &models.ReferrerStats{New: false} 12 | query := db.Rebind(`SELECT * FROM referrer_stats WHERE site_id = ? AND ts = ? AND hostname_id = ? AND pathname_id = ? LIMIT 1`) 13 | err := db.Get(stats, query, siteID, date.Format(DATE_FORMAT), hostnameID, pathnameID) 14 | if err == sql.ErrNoRows { 15 | return nil, ErrNoResults 16 | } 17 | 18 | return stats, mapError(err) 19 | } 20 | 21 | func (db *sqlstore) SaveReferrerStats(s *models.ReferrerStats) error { 22 | if s.New { 23 | return db.insertReferrerStats(s) 24 | } 25 | 26 | return db.updateReferrerStats(s) 27 | } 28 | 29 | func (db *sqlstore) insertReferrerStats(s *models.ReferrerStats) error { 30 | query := db.Rebind(`INSERT INTO referrer_stats(visitors, pageviews, bounce_rate, avg_duration, known_durations, groupname, site_id, hostname_id, pathname_id, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) 31 | _, err := db.Exec(query, s.Visitors, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.Group, s.SiteID, s.HostnameID, s.PathnameID, s.Date.Format(DATE_FORMAT)) 32 | return err 33 | } 34 | 35 | func (db *sqlstore) updateReferrerStats(s *models.ReferrerStats) error { 36 | query := db.Rebind(`UPDATE referrer_stats SET visitors = ?, pageviews = ?, bounce_rate = ?, avg_duration = ?, known_durations = ?, groupname = ? WHERE site_id = ? AND hostname_id = ? AND pathname_id = ? AND ts = ?`) 37 | _, err := db.Exec(query, s.Visitors, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.Group, s.SiteID, s.HostnameID, s.PathnameID, s.Date.Format(DATE_FORMAT)) 38 | return err 39 | } 40 | 41 | func (db *sqlstore) SelectAggregatedReferrerStats(siteID int64, startDate time.Time, endDate time.Time, offset int, limit int) ([]*models.ReferrerStats, error) { 42 | var result []*models.ReferrerStats 43 | 44 | sql := `SELECT 45 | MIN(h.name) AS hostname, 46 | MIN(p.name) AS pathname, 47 | COALESCE(MIN(groupname), '') AS groupname, 48 | SUM(visitors) AS visitors, 49 | SUM(pageviews) AS pageviews, 50 | SUM(pageviews*bounce_rate) / SUM(pageviews) AS bounce_rate, 51 | SUM(pageviews*avg_duration) / SUM(pageviews) AS avg_duration 52 | FROM referrer_stats s 53 | LEFT JOIN hostnames h ON h.id = s.hostname_id 54 | LEFT JOIN pathnames p ON p.id = s.pathname_id 55 | WHERE site_id = ? AND ts >= ? AND ts <= ? ` 56 | 57 | if db.Config.Driver == "sqlite3" { 58 | sql = sql + `GROUP BY COALESCE(NULLIF(groupname, ''), hostname_id || pathname_id ) ` 59 | } else { 60 | sql = sql + `GROUP BY COALESCE(NULLIF(groupname, ''), CONCAT(hostname_id, pathname_id) ) ` 61 | } 62 | sql = sql + ` ORDER BY pageviews DESC LIMIT ? OFFSET ?` 63 | 64 | query := db.Rebind(sql) 65 | 66 | err := db.Select(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT), limit, offset) 67 | return result, mapError(err) 68 | } 69 | 70 | func (db *sqlstore) GetAggregatedReferrerStatsPageviews(siteID int64, startDate time.Time, endDate time.Time) (int64, error) { 71 | var result int64 72 | query := db.Rebind(`SELECT COALESCE(SUM(pageviews), 0) FROM referrer_stats WHERE site_id = ? AND ts >= ? AND ts <= ?`) 73 | err := db.Get(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) 74 | return result, mapError(err) 75 | } 76 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/seed/pageviews.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO pageviews 2 | ( session_id, pathname, is_new_visitor, is_unique, is_bounce, referrer, duration, timestamp) VALUES 3 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-03 15:00:00"), 4 | ( LEFT(UUID(), 8), "/", 1, 1, 1, "", 14, "2018-05-03 15:00:00"), 5 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 13, "2018-05-04 15:00:00"), 6 | ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 16, "2018-05-04 15:00:00"), 7 | ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 16, "2018-05-05 15:00:00"), 8 | ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 17, "2018-05-05 15:00:00"), 9 | ( LEFT(UUID(), 8), "/", 0, 1, 1, "", 18, "2018-05-05 15:00:00"), 10 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 11 | ( LEFT(UUID(), 8), "/", 1, 1, 1, "https://duckduckgo.com/", 15, "2018-05-05 15:00:00"), 12 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://duckduckgo.com/", 15, "2018-05-05 15:00:00"), 13 | ( LEFT(UUID(), 8), "/", 0, 1, 0, "https://duckduckgo.com/", 150, "2018-05-05 15:00:00"), 14 | ( LEFT(UUID(), 8), "/", 0, 1, 0, "https://mozilla.org/", 15, "2018-05-05 15:00:00"), 15 | ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 16 | ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 10, "2018-05-05 15:00:00"), 17 | ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 11, "2018-05-05 15:00:00"), 18 | ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 21, "2018-05-05 15:00:00"), 19 | ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 20 | ( LEFT(UUID(), 8), "/contact", 0, 1, 0, "", 15, "2018-05-05 15:00:00"), 21 | ( LEFT(UUID(), 8), "/contact", 0, 1, 1, "", 8, "2018-05-05 15:00:00"), 22 | ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 23 | ( LEFT(UUID(), 8), "/contact", 1, 1, 1, "https://wikipedia.com/", 15, "2018-05-05 15:00:00"), 24 | ( LEFT(UUID(), 8), "/pricing", 1, 0, 0, "", 15, "2018-05-05 15:00:00"), 25 | ( LEFT(UUID(), 8), "/pricing", 1, 0, 1, "", 15, "2018-05-05 15:00:00"), 26 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), 27 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), 28 | ( LEFT(UUID(), 8), "/pricing", 1, 0, 0, "", 8, "2018-05-05 15:00:00"), 29 | ( LEFT(UUID(), 8), "/pricing", 1, 0, 1, "", 24, "2018-05-05 15:00:00"), 30 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 31 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), 32 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 14, "2018-05-05 15:00:00"), 33 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 34 | ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 35 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), 36 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), 37 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), 38 | ( LEFT(UUID(), 8), "/", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), 39 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://pjrvs.com", 8, "2018-05-05 15:00:00"), 40 | ( LEFT(UUID(), 8), "/", 1, 1, 1, "https://pjrvs.com", 24, "2018-05-05 15:00:00"), 41 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://pjrvs.com", 15, "2018-05-05 15:00:00"), 42 | ( LEFT(UUID(), 8), "/", 1, 0, 1, "", 19, "2018-05-05 15:00:00"), 43 | ( LEFT(UUID(), 8), "/", 1, 0, 0, "", 15, "2018-05-05 15:00:00"), 44 | ( LEFT(UUID(), 8), "/", 1, 0, 0, "", 19, "2018-05-05 15:00:00"), 45 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 19, "2018-05-05 15:00:00"), 46 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 15, "2018-05-05 15:00:00"), 47 | ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 14, "2018-05-05 15:00:00"); 48 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/site_stats.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/usefathom/fathom/pkg/models" 9 | ) 10 | 11 | func (db *sqlstore) GetSiteStats(siteID int64, date time.Time) (*models.SiteStats, error) { 12 | stats := &models.SiteStats{New: false} 13 | query := db.Rebind(`SELECT * FROM site_stats WHERE site_id = ? AND ts = ? LIMIT 1`) 14 | 15 | err := db.Get(stats, query, siteID, date.Format(DATE_FORMAT)) 16 | if err == sql.ErrNoRows { 17 | return nil, ErrNoResults 18 | } 19 | 20 | return stats, mapError(err) 21 | } 22 | 23 | func (db *sqlstore) SaveSiteStats(s *models.SiteStats) error { 24 | if s.New { 25 | return db.insertSiteStats(s) 26 | } 27 | 28 | return db.updateSiteStats(s) 29 | } 30 | 31 | func (db *sqlstore) insertSiteStats(s *models.SiteStats) error { 32 | query := db.Rebind(`INSERT INTO site_stats(site_id, visitors, sessions, pageviews, bounce_rate, avg_duration, known_durations, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`) 33 | _, err := db.Exec(query, s.SiteID, s.Visitors, s.Sessions, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.Date.Format(DATE_FORMAT)) 34 | return err 35 | } 36 | 37 | func (db *sqlstore) updateSiteStats(s *models.SiteStats) error { 38 | query := db.Rebind(`UPDATE site_stats SET visitors = ?, sessions = ?, pageviews = ?, bounce_rate = ?, avg_duration = ?, known_durations = ? WHERE site_id = ? AND ts = ?`) 39 | _, err := db.Exec(query, s.Visitors, s.Sessions, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.SiteID, s.Date.Format(DATE_FORMAT)) 40 | return err 41 | } 42 | 43 | func (db *sqlstore) SelectSiteStats(siteID int64, startDate time.Time, endDate time.Time) ([]*models.SiteStats, error) { 44 | results := []*models.SiteStats{} 45 | query := db.Rebind(`SELECT * 46 | FROM site_stats 47 | WHERE site_id = ? AND ts >= ? AND ts <= ? 48 | ORDER BY ts DESC`) 49 | err := db.Select(&results, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) 50 | return results, err 51 | } 52 | 53 | func (db *sqlstore) GetAggregatedSiteStats(siteID int64, startDate time.Time, endDate time.Time) (*models.SiteStats, error) { 54 | stats := &models.SiteStats{} 55 | query := db.Rebind(`SELECT 56 | SUM(pageviews) AS pageviews, 57 | SUM(visitors) AS visitors, 58 | SUM(sessions) AS sessions, 59 | SUM(pageviews*avg_duration) / SUM(pageviews) AS avg_duration, 60 | COALESCE(SUM(sessions*bounce_rate) / SUM(sessions), 0.00) AS bounce_rate 61 | FROM site_stats 62 | WHERE site_id = ? AND ts >= ? AND ts <= ? LIMIT 1`) 63 | err := db.Get(stats, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) 64 | return stats, mapError(err) 65 | } 66 | 67 | func (db *sqlstore) GetRealtimeVisitorCount(siteID int64) (int64, error) { 68 | var siteTrackingID string 69 | if err := db.Get(&siteTrackingID, db.Rebind(`SELECT tracking_id FROM sites WHERE id = ? LIMIT 1`), siteID); err != nil && err != sql.ErrNoRows { 70 | log.Error(err) 71 | return 0, mapError(err) 72 | } 73 | 74 | var sql string 75 | var total int64 76 | 77 | // for backwards compatibility with tracking snippets without an explicit site tracking ID (< 1.1.0) 78 | if siteID == 1 { 79 | sql = `SELECT COUNT(*) FROM pageviews p WHERE ( site_tracking_id = ? OR site_tracking_id = '' ) AND is_finished = FALSE AND timestamp > ?` 80 | } else { 81 | sql = `SELECT COUNT(*) FROM pageviews p WHERE site_tracking_id = ? AND is_finished = FALSE AND timestamp > ?` 82 | } 83 | 84 | query := db.Rebind(sql) 85 | if err := db.Get(&total, query, siteTrackingID, time.Now().Add(-5*time.Minute)); err != nil { 86 | return 0, mapError(err) 87 | } 88 | 89 | return total, nil 90 | } 91 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/sites.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/usefathom/fathom/pkg/models" 7 | ) 8 | 9 | // GetSites gets all sites in the database 10 | func (db *sqlstore) GetSites() ([]*models.Site, error) { 11 | results := []*models.Site{} 12 | query := db.Rebind(`SELECT * FROM sites`) 13 | err := db.Select(&results, query) 14 | 15 | // don't err on no rows 16 | if err == sql.ErrNoRows { 17 | return results, nil 18 | } 19 | 20 | return results, err 21 | } 22 | 23 | func (db *sqlstore) GetSite(id int64) (*models.Site, error) { 24 | s := &models.Site{} 25 | query := db.Rebind("SELECT * FROM sites WHERE id = ?") 26 | err := db.Get(s, query, id) 27 | return s, mapError(err) 28 | } 29 | 30 | // SaveSite saves the website in the database (inserts or updates) 31 | func (db *sqlstore) SaveSite(s *models.Site) error { 32 | if s.ID > 0 { 33 | return db.updateSite(s) 34 | } 35 | 36 | return db.insertSite(s) 37 | } 38 | 39 | // InsertSite saves a new site in the database 40 | func (db *sqlstore) insertSite(s *models.Site) error { 41 | 42 | // Postgres does not support LastInsertID, so use a "... RETURNING" select query 43 | query := db.Rebind(`INSERT INTO sites(tracking_id, name) VALUES(?, ?)`) 44 | if db.Driver == POSTGRES { 45 | err := db.Get(&s.ID, query+" RETURNING id", s.TrackingID, s.Name) 46 | return err 47 | } 48 | 49 | // MySQL and SQLite do support LastInsertID, so use that 50 | r, err := db.Exec(query, s.TrackingID, s.Name) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | s.ID, err = r.LastInsertId() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // UpdateSite updates an existing site in the database 64 | func (db *sqlstore) updateSite(s *models.Site) error { 65 | query := db.Rebind(`UPDATE sites SET name = ? WHERE id = ?`) 66 | _, err := db.Exec(query, s.Name, s.ID) 67 | return err 68 | } 69 | 70 | // DeleteSite deletes the given site in the database 71 | func (db *sqlstore) DeleteSite(s *models.Site) error { 72 | query := db.Rebind(`DELETE FROM sites WHERE id = ?`) 73 | _, err := db.Exec(query, s.ID) 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/sqlstore.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "time" 8 | 9 | _ "github.com/go-sql-driver/mysql" // mysql driver 10 | "github.com/gobuffalo/packr/v2" 11 | "github.com/jmoiron/sqlx" 12 | _ "github.com/lib/pq" // postgresql driver 13 | _ "github.com/mattn/go-sqlite3" //sqlite3 driver 14 | migrate "github.com/rubenv/sql-migrate" 15 | log "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const ( 19 | MYSQL = "mysql" 20 | POSTGRES = "postgres" 21 | SQLITE = "sqlite3" 22 | 23 | DATE_FORMAT = "2006-01-02 15:00:00" 24 | ) 25 | 26 | type sqlstore struct { 27 | *sqlx.DB 28 | 29 | Driver string 30 | Config *Config 31 | } 32 | 33 | // ErrNoResults is returned when a query yielded 0 results 34 | var ErrNoResults = errors.New("datastore: query returned 0 results") 35 | 36 | // New creates a new database pool 37 | func New(c *Config) *sqlstore { 38 | dsn := c.DSN() 39 | dbx, err := sqlx.Connect(c.Driver, dsn) 40 | if err != nil { 41 | log.Fatalf("Error connecting to database: %s", err) 42 | } 43 | db := &sqlstore{dbx, c.Driver, c} 44 | 45 | if c.Host == "" || c.Driver == SQLITE { 46 | log.Printf("Connected to %s database: %s", c.Driver, c.Dbname()) 47 | } else { 48 | log.Printf("Connected to %s database: %s on %s", c.Driver, c.Dbname(), c.Host) 49 | } 50 | 51 | // apply database migrations (if any) 52 | db.Migrate() 53 | 54 | return db 55 | } 56 | 57 | func (db *sqlstore) Migrate() { 58 | migrationSource := &migrate.PackrMigrationSource{ 59 | Box: packr.NewBox("./migrations"), 60 | Dir: db.Config.Driver, 61 | } 62 | migrate.SetTable("migrations") 63 | 64 | migrations, err := migrationSource.FindMigrations() 65 | if err != nil { 66 | log.Errorf("Error loading database migrations: %s", err) 67 | } 68 | 69 | if len(migrations) == 0 { 70 | log.Fatalf("Missing database migrations") 71 | } 72 | 73 | n, err := migrate.Exec(db.DB.DB, db.Config.Driver, migrationSource, migrate.Up) 74 | if err != nil { 75 | log.Errorf("Error applying database migrations: %s", err) 76 | } 77 | 78 | if n > 0 { 79 | log.Infof("Applied %d database migrations!", n) 80 | } 81 | } 82 | 83 | // Health check health of database 84 | func (db *sqlstore) Health() error { 85 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 86 | defer cancel() 87 | 88 | return db.PingContext(ctx) 89 | } 90 | 91 | // Closes the db pool 92 | func (db *sqlstore) Close() error { 93 | return db.DB.Close() 94 | } 95 | 96 | func mapError(err error) error { 97 | if err == sql.ErrNoRows { 98 | return ErrNoResults 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/datastore/sqlstore/users.go: -------------------------------------------------------------------------------- 1 | package sqlstore 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/usefathom/fathom/pkg/models" 7 | ) 8 | 9 | // GetUser retrieves user from datastore by its ID 10 | func (db *sqlstore) GetUser(ID int64) (*models.User, error) { 11 | u := &models.User{} 12 | query := db.Rebind("SELECT * FROM users WHERE id = ? LIMIT 1") 13 | err := db.Get(u, query, ID) 14 | 15 | if err != nil { 16 | if err == sql.ErrNoRows { 17 | return nil, ErrNoResults 18 | } 19 | 20 | return nil, err 21 | } 22 | 23 | return u, err 24 | } 25 | 26 | // GetUserByEmail retrieves user from datastore by its email 27 | func (db *sqlstore) GetUserByEmail(email string) (*models.User, error) { 28 | u := &models.User{} 29 | query := db.Rebind("SELECT * FROM users WHERE email = ? LIMIT 1") 30 | err := db.Get(u, query, email) 31 | return u, mapError(err) 32 | } 33 | 34 | // SaveUser inserts the user model in the connected database 35 | func (db *sqlstore) SaveUser(u *models.User) error { 36 | if u.ID > 0 { 37 | return db.updateUser(u) 38 | } 39 | 40 | return db.insertUser(u) 41 | } 42 | 43 | // insertUser saves a new user in the database 44 | func (db *sqlstore) insertUser(u *models.User) error { 45 | var query = db.Rebind("INSERT INTO users(email, password) VALUES(?, ?)") 46 | 47 | // Postgres does not support LastInsertID, so use a "... RETURNING" select query 48 | if db.Driver == POSTGRES { 49 | err := db.Get(&u.ID, query+" RETURNING id", u.Email, u.Password) 50 | return err 51 | } 52 | 53 | // MySQL and SQLite don't support RETURNING, but do support LastInsertId 54 | result, err := db.Exec(query, u.Email, u.Password) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | u.ID, err = result.LastInsertId() 60 | return err 61 | } 62 | 63 | // updateUser updates an existing user in the database 64 | func (db *sqlstore) updateUser(u *models.User) error { 65 | var query = db.Rebind("UPDATE users SET email = ?, password = ? WHERE id = ?") 66 | _, err := db.Exec(query, u.Email, u.Password, u.ID) 67 | return err 68 | } 69 | 70 | // DeleteUser deletes the user in the datastore 71 | func (db *sqlstore) DeleteUser(u *models.User) error { 72 | query := db.Rebind("DELETE FROM users WHERE id = ?") 73 | _, err := db.Exec(query, u.ID) 74 | return err 75 | } 76 | 77 | // CountUsers returns the number of users 78 | func (db *sqlstore) CountUsers() (int64, error) { 79 | var c int64 80 | var sql = `SELECT COUNT(*) FROM users` 81 | query := db.Rebind(sql) 82 | err := db.Get(&c, query) 83 | return c, err 84 | } 85 | -------------------------------------------------------------------------------- /pkg/models/page_stats.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type PageStats struct { 8 | New bool `db:"-" json:"-"` 9 | SiteID int64 `db:"site_id" json:"-"` 10 | HostnameID int64 `db:"hostname_id" json:"-"` 11 | PathnameID int64 `db:"pathname_id" json:"-"` 12 | Hostname string `db:"hostname"` 13 | Pathname string `db:"pathname"` 14 | Pageviews int64 `db:"pageviews"` 15 | Visitors int64 `db:"visitors"` 16 | Entries int64 `db:"entries"` 17 | BounceRate float64 `db:"bounce_rate"` 18 | AvgDuration float64 `db:"avg_duration"` 19 | KnownDurations int64 `db:"known_durations"` 20 | Date time.Time `db:"ts" json:",omitempty"` 21 | } 22 | 23 | func (s *PageStats) HandlePageview(p *Pageview) { 24 | 25 | s.Pageviews += 1 26 | if p.IsUnique { 27 | s.Visitors += 1 28 | } 29 | 30 | if p.Duration > 0.00 { 31 | s.KnownDurations += 1 32 | s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) 33 | } 34 | 35 | if p.IsNewSession { 36 | s.Entries += 1 37 | 38 | if p.IsBounce { 39 | s.BounceRate = ((float64(s.Entries-1) * s.BounceRate) + 1.00) / (float64(s.Entries)) 40 | } else { 41 | s.BounceRate = ((float64(s.Entries-1) * s.BounceRate) + 0.00) / (float64(s.Entries)) 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /pkg/models/page_stats_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "testing" 4 | 5 | func TestPageStatsHandlePageview(t *testing.T) { 6 | s := PageStats{} 7 | 8 | p1 := &Pageview{ 9 | Duration: 100, 10 | IsBounce: false, 11 | IsUnique: true, 12 | IsNewSession: true, 13 | } 14 | p2 := &Pageview{ 15 | Duration: 60, 16 | IsUnique: false, 17 | IsNewSession: false, 18 | IsBounce: true, // should have no effect because only new sessions can bounce 19 | } 20 | p3 := &Pageview{ 21 | IsUnique: true, 22 | IsNewSession: true, 23 | IsBounce: true, 24 | } 25 | 26 | // add first pageview & test 27 | s.HandlePageview(p1) 28 | if s.Pageviews != 1 { 29 | t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) 30 | } 31 | if s.Visitors != 1 { 32 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 33 | } 34 | if s.AvgDuration != 100 { 35 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) 36 | } 37 | if s.BounceRate != 0.00 { 38 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) 39 | } 40 | 41 | // add second pageview 42 | s.HandlePageview(p2) 43 | if s.Pageviews != 2 { 44 | t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) 45 | } 46 | if s.Visitors != 1 { 47 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 48 | } 49 | if s.AvgDuration != 80 { 50 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) 51 | } 52 | // should still be 0.00 because p2 was not a new session 53 | if s.BounceRate != 0.00 { 54 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) 55 | } 56 | 57 | // add third pageview 58 | s.HandlePageview(p3) 59 | if s.Visitors != 2 { 60 | t.Errorf("Visitors: expected %d, got %d", 2, s.Visitors) 61 | } 62 | 63 | if s.BounceRate != 0.50 { 64 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /pkg/models/pageview.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Pageview struct { 8 | ID string `db:"id"` 9 | SiteTrackingID string `db:"site_tracking_id"` 10 | Hostname string `db:"hostname"` 11 | Pathname string `db:"pathname"` 12 | IsNewVisitor bool `db:"is_new_visitor"` 13 | IsNewSession bool `db:"is_new_session"` 14 | IsUnique bool `db:"is_unique"` 15 | IsBounce bool `db:"is_bounce"` 16 | IsFinished bool `db:"is_finished"` 17 | Referrer string `db:"referrer"` 18 | Duration int64 `db:"duration"` 19 | Timestamp time.Time `db:"timestamp"` 20 | } 21 | -------------------------------------------------------------------------------- /pkg/models/referrer_stats.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ReferrerStats struct { 8 | New bool `db:"-" json:"-"` 9 | SiteID int64 `db:"site_id" json:"-"` 10 | HostnameID int64 `db:"hostname_id" json:"-"` 11 | PathnameID int64 `db:"pathname_id" json:"-"` 12 | Hostname string `db:"hostname"` 13 | Pathname string `db:"pathname"` 14 | Group string `db:"groupname"` 15 | Visitors int64 `db:"visitors"` 16 | Pageviews int64 `db:"pageviews"` 17 | BounceRate float64 `db:"bounce_rate"` 18 | AvgDuration float64 `db:"avg_duration"` 19 | KnownDurations int64 `db:"known_durations"` 20 | Date time.Time `db:"ts" json:",omitempty"` 21 | } 22 | 23 | func (s *ReferrerStats) HandlePageview(p *Pageview) { 24 | s.Pageviews += 1 25 | 26 | if p.IsNewVisitor { 27 | s.Visitors += 1 28 | } 29 | 30 | if p.IsBounce { 31 | s.BounceRate = ((float64(s.Pageviews-1) * s.BounceRate) + 1.00) / (float64(s.Pageviews)) 32 | } else { 33 | s.BounceRate = ((float64(s.Pageviews-1) * s.BounceRate) + 0.00) / (float64(s.Pageviews)) 34 | } 35 | 36 | if p.Duration > 0.00 { 37 | s.KnownDurations += 1 38 | s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/models/referrer_stats_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "testing" 4 | 5 | func TestReferrerStatsHandlePageview(t *testing.T) { 6 | s := ReferrerStats{} 7 | p1 := &Pageview{ 8 | Duration: 100, 9 | IsBounce: false, 10 | IsNewVisitor: true, 11 | } 12 | p2 := &Pageview{ 13 | Duration: 60, 14 | IsNewVisitor: false, 15 | IsBounce: true, 16 | } 17 | p3 := &Pageview{ 18 | IsNewSession: true, 19 | IsBounce: true, 20 | } 21 | 22 | // add first pageview & test 23 | s.HandlePageview(p1) 24 | if s.Pageviews != 1 { 25 | t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) 26 | } 27 | if s.Visitors != 1 { 28 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 29 | } 30 | if s.AvgDuration != 100 { 31 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) 32 | } 33 | if s.BounceRate != 0.00 { 34 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) 35 | } 36 | 37 | // add second pageview 38 | s.HandlePageview(p2) 39 | if s.Pageviews != 2 { 40 | t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) 41 | } 42 | if s.Visitors != 1 { 43 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 44 | } 45 | if s.AvgDuration != 80 { 46 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) 47 | } 48 | if s.BounceRate != 0.50 { 49 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) 50 | } 51 | 52 | // add third pageview 53 | s.HandlePageview(p3) 54 | if int64(100.00*s.BounceRate) != 66 { 55 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.67, s.BounceRate) 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /pkg/models/site.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // Site represents a group for tracking data 4 | type Site struct { 5 | ID int64 `db:"id" json:"id"` 6 | TrackingID string `db:"tracking_id" json:"trackingId"` 7 | Name string `db:"name" json:"name"` 8 | } 9 | -------------------------------------------------------------------------------- /pkg/models/site_stats.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type SiteStats struct { 9 | New bool `db:"-" json:"-" ` 10 | SiteID int64 `db:"site_id" json:"-"` 11 | Visitors int64 `db:"visitors"` 12 | Pageviews int64 `db:"pageviews"` 13 | Sessions int64 `db:"sessions"` 14 | BounceRate float64 `db:"bounce_rate"` 15 | AvgDuration float64 `db:"avg_duration"` 16 | KnownDurations int64 `db:"known_durations" json:",omitempty"` 17 | Date time.Time `db:"ts" json:",omitempty"` 18 | } 19 | 20 | func (s *SiteStats) FormattedDuration() string { 21 | return fmt.Sprintf("%d:%d", int(s.AvgDuration/60.00), (int(s.AvgDuration) % 60)) 22 | } 23 | 24 | func (s *SiteStats) HandlePageview(p *Pageview) { 25 | s.Pageviews += 1 26 | 27 | if p.Duration > 0.00 { 28 | s.KnownDurations += 1 29 | s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) 30 | } 31 | 32 | if p.IsNewVisitor { 33 | s.Visitors += 1 34 | } 35 | 36 | if p.IsNewSession { 37 | s.Sessions += 1 38 | 39 | if p.IsBounce { 40 | s.BounceRate = ((float64(s.Sessions-1) * s.BounceRate) + 1) / (float64(s.Sessions)) 41 | } else { 42 | s.BounceRate = ((float64(s.Sessions-1) * s.BounceRate) + 0) / (float64(s.Sessions)) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/models/site_stats_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSiteStatsFormattedDuration(t *testing.T) { 8 | s := SiteStats{ 9 | AvgDuration: 100.00, 10 | } 11 | e := "1:40" 12 | if v := s.FormattedDuration(); v != e { 13 | t.Errorf("FormattedDuration: expected %s, got %s", e, v) 14 | } 15 | 16 | s.AvgDuration = 1040.22 17 | e = "17:20" 18 | if v := s.FormattedDuration(); v != e { 19 | t.Errorf("FormattedDuration: expected %s, got %s", e, v) 20 | } 21 | } 22 | 23 | func TestSiteStatsHandlePageview(t *testing.T) { 24 | s := SiteStats{} 25 | p1 := &Pageview{ 26 | Duration: 100, 27 | IsBounce: false, 28 | IsNewVisitor: true, 29 | IsNewSession: true, 30 | } 31 | p2 := &Pageview{ 32 | Duration: 60, 33 | IsNewVisitor: false, 34 | IsNewSession: false, 35 | IsBounce: true, // should have no effect because only new sessions can bounce 36 | } 37 | p3 := &Pageview{ 38 | IsNewSession: true, 39 | IsBounce: true, 40 | } 41 | 42 | // add first pageview & test 43 | s.HandlePageview(p1) 44 | if s.Pageviews != 1 { 45 | t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) 46 | } 47 | if s.Visitors != 1 { 48 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 49 | } 50 | if s.AvgDuration != 100 { 51 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) 52 | } 53 | if s.BounceRate != 0.00 { 54 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) 55 | } 56 | 57 | // add second pageview 58 | s.HandlePageview(p2) 59 | if s.Pageviews != 2 { 60 | t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) 61 | } 62 | if s.Visitors != 1 { 63 | t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) 64 | } 65 | if s.AvgDuration != 80 { 66 | t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) 67 | } 68 | // should still be 0.00 because p2 was not a new session 69 | if s.BounceRate != 0.00 { 70 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) 71 | } 72 | 73 | // add third pageview 74 | s.HandlePageview(p3) 75 | if s.BounceRate != 0.50 { 76 | t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /pkg/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | type User struct { 10 | ID int64 11 | Email string 12 | Password string `json:"-"` 13 | } 14 | 15 | // NewUser creates a new User with the given email and password 16 | func NewUser(e string, pwd string) User { 17 | u := User{ 18 | Email: strings.ToLower(strings.TrimSpace(e)), 19 | } 20 | u.SetPassword(pwd) 21 | return u 22 | } 23 | 24 | // SetPassword sets a brcrypt encrypted password from the given plaintext pwd 25 | func (u *User) SetPassword(pwd string) { 26 | hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) 27 | u.Password = string(hash) 28 | } 29 | 30 | // ComparePassword returns true when the given plaintext password matches the encrypted pwd 31 | func (u *User) ComparePassword(pwd string) error { 32 | return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pwd)) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/models/user_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewUser(t *testing.T) { 8 | email := "foo@bar.com" 9 | pwd := "passw0rd01" 10 | u := NewUser(email, pwd) 11 | 12 | if u.Email != email { 13 | t.Errorf("Email: expected %s, got %s", email, u.Email) 14 | } 15 | 16 | if u.ComparePassword(pwd) != nil { 17 | t.Error("Password not set correctly") 18 | } 19 | } 20 | 21 | func TestUserPassword(t *testing.T) { 22 | u := &User{} 23 | u.SetPassword("password") 24 | if u.ComparePassword("password") != nil { 25 | t.Errorf("Password should match, but does not") 26 | } 27 | } 28 | --------------------------------------------------------------------------------