├── .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 |
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 |
--------------------------------------------------------------------------------