Repository: usefathom/fathom Branch: master Commit: 2d895d8299d3 Files: 181 Total size: 342.0 KB Directory structure: gitextract_bylwszv8/ ├── .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 │ ├── 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.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .env.example ================================================ FATHOM_GZIP=true FATHOM_DEBUG=true FATHOM_DATABASE_DRIVER="sqlite3" FATHOM_DATABASE_NAME="./fathom.db" FATHOM_DATABASE_USER="" FATHOM_DATABASE_PASSWORD="" FATHOM_DATABASE_HOST="" FATHOM_SECRET="abcdefghijklmnopqrstuvwxyz1234567890" ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=LJ5WZVA9ER9GJ'] ================================================ FILE: .github/workflows/goimports.yml ================================================ name: Check imports on: [ push, pull_request ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/setup-go@v2 with: go-version: '1.19' - uses: actions/checkout@master - name: Check imports shell: bash run: | export PATH=$(go env GOPATH)/bin:$PATH go get golang.org/x/tools/cmd/goimports diff -u <(echo -n) <(goimports -d .) ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: test: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v3 with: go-version: '1.19' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v3 with: # either 'goreleaser' (default) or 'goreleaser-pro' distribution: goreleaser version: latest args: release --rm-dist -p 1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ --- name: Run tests on: [ push, pull_request ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/setup-go@v2 with: go-version: '1.19' - uses: actions/checkout@master - name: Run tests run: | make test ================================================ FILE: .gitignore ================================================ node_modules .env* !.env.example coverage.out build dist *.db fathom !cmd/fathom assets/build assets/dist bin/ ================================================ FILE: .goreleaser.yml ================================================ # Documentation http://goreleaser.com before: hooks: - make assets/dist - go install github.com/gobuffalo/packr/v2/packr2@latest builds: - main: main.go goos: - linux goarch: - amd64 - 386 - arm64 ldflags: - -extldflags "-static" -s -w -X "main.version={{.Version}}" -X "main.commit={{.Commit}}" -X "main.date={{.Date}}" hooks: pre: packr2 post: packr2 clean checksum: name_template: 'checksums.txt' snapshot: name_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' release: draft: true mode: append ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at team@usefathom.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org ================================================ FILE: Dockerfile ================================================ FROM node:alpine AS assetbuilder WORKDIR /app COPY package*.json ./ COPY gulpfile.js ./ COPY assets/ ./assets/ RUN npm install && NODE_ENV=production ./node_modules/gulp/bin/gulp.js FROM golang:latest AS binarybuilder RUN go install github.com/gobuffalo/packr/v2/packr2@latest WORKDIR /go/src/github.com/usefathom/fathom COPY . /go/src/github.com/usefathom/fathom COPY --from=assetbuilder /app/assets/build ./assets/build ARG GOARCH=amd64 ARG GOOS=linux RUN make ARCH=${GOARCH} OS=${GOOS} docker FROM alpine:latest EXPOSE 8080 HEALTHCHECK --retries=10 CMD ["wget", "-qO-", "http://localhost:8080/health"] RUN apk add --update --no-cache bash ca-certificates WORKDIR /app COPY --from=binarybuilder /go/src/github.com/usefathom/fathom/fathom . CMD ["./fathom", "server"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) Conva Ventures Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ EXECUTABLE := fathom 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')" MAIN_PKG := ./main.go PACKAGES ?= $(shell go list ./... | grep -v /vendor/) ASSET_SOURCES ?= $(shell find assets/src/. -type f) GO_SOURCES ?= $(shell find . -name "*.go" -type f) GOPATH=$(shell go env GOPATH) ARCH := amd64 OS := linux .PHONY: all all: build .PHONY: build build: $(EXECUTABLE) $(EXECUTABLE): $(GO_SOURCES) assets/build go build -o $@ $(MAIN_PKG) .PHONY: docker docker: $(GO_SOURCES) $(GOPATH)/bin/packr2 GOOS=$(OS) GOARCH=$(ARCH) $(GOPATH)/bin/packr2 build -v -ldflags '-w $(LDFLAGS)' -o $(EXECUTABLE) $(MAIN_PKG) $(GOPATH)/bin/packr2: GOBIN=$(GOPATH)/bin go install github.com/gobuffalo/packr/v2/packr2@latest .PHONY: npm npm: if [ ! -d "node_modules" ]; then npm install; fi assets/build: $(ASSET_SOURCES) npm ./node_modules/gulp/bin/gulp.js assets/dist: $(ASSET_SOURCES) npm NODE_ENV=production ./node_modules/gulp/bin/gulp.js .PHONY: clean clean: go clean -i ./... $(GOPATH)/bin/packr clean rm -rf $(EXECUTABLE) .PHONY: fmt fmt: go fmt $(PACKAGES) .PHONY: vet vet: go vet $(PACKAGES) .PHONY: errcheck errcheck: @which errcheck > /dev/null; if [ $$? -ne 0 ]; then \ go install github.com/kisielk/errcheck@latest; \ fi errcheck $(PACKAGES) .PHONY: lint lint: @which golint > /dev/null; if [ $$? -ne 0 ]; then \ go install github.com/golang/lint/golint@latest; \ fi for PKG in $(PACKAGES); do golint -set_exit_status $$PKG || exit 1; done; .PHONY: test test: for PKG in $(PACKAGES); do go test $$PKG || exit 1; done; .PHONY: referrer-spam-blacklist referrer-spam-blacklist: wget https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -O pkg/aggregator/data/blacklist.txt go-bindata -prefix "pkg/aggregator/data/" -o pkg/aggregator/bindata.go -pkg aggregator pkg/aggregator/data/ ================================================ FILE: README.md ================================================ Fathom Lite - simple website analytics ============================== [![Go Report Card](https://goreportcard.com/badge/github.com/usefathom/fathom)](https://goreportcard.com/report/github.com/usefathom/fathom) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/usefathom/fathom/master/LICENSE) Fathom Lite is a previous and open-source version of [Fathom Analytics](https://usefathom.com) (a paid, hosted [Google Analytics alternative](https://usefathom.com/google-analytics-alternative)). It was the very first version of our software, and has been downloaded millions of times! While we are no longer adding features to this Lite version, we will be continuing to maintain it long-term and fix any bugs that come up. ![Screenshot of the Fathom dashboard](https://github.com/usefathom/fathom/raw/master/assets/src/img/fathom.jpg?v=7) ## Fathom Lite vs Fathom (hosted) Today’s [Fathom Analytics](https://usefathom.com) is a hosted product with [simple pricing](https://usefathom.com/pricing) based on monthly pageviews. The same core capabilities are included on every plan: for example [API access](https://usefathom.com/api), up to 50 sites, custom events and ecommerce tracking, unlimited email reports and CSV exports, and [forever data retention](https://usefathom.com/features)—with [privacy-law compliance](https://usefathom.com/compliance) and no cookie banner required for analytics. If you’d rather not run servers or maintenance yourself, try a [30-day free trial](https://usefathom.com/ref/GITHUB) (that link applies a **$10 credit** on your first invoice). Browse [all features](https://usefathom.com/features) or the [live demo](https://app.usefathom.com/demo). ![Screenshot of the Fathom Analytics Dashboard](https://usefathom.com/assets/images/fathom-screenshot.png) | Feature | Fathom Lite | Fathom (hosted) | |---------|-------------|-----------------| | Fully managed | ✗ Self-hosted; you run servers and updates | ✓ Managed for you; [pay per pageviews](https://usefathom.com/pricing) | | Cookie-free (no analytics banner) | ✗ Uses cookies | ✓ [Cookie-free](https://usefathom.com/features) tracking | | Current dashboard (real-time, live visitors, filters, details) | ✗ Older Lite UI | ✓ Full dashboard — see [features](https://usefathom.com/features) | | API | ✗ | ✓ [API](https://usefathom.com/api) on all plans | | Custom events, ecommerce, UTMs | ✗ | ✓ Events, revenue, campaigns | | [EU isolation](https://usefathom.com/features/eu-isolation) & [custom domains](https://usefathom.com/features/custom-domains) | ✗ | ✓ EU routing, first-party script domains | | [GA import](https://usefathom.com/features/ga-importer), email reports, CSV export, [shared dashboards](https://usefathom.com/features) | ✗ | ✓ Unlimited reports & exports | | Many sites per account | ✗ Typical single install | ✓ Up to 50 sites (more available) | | Email support & SLA | ✗ Community / as-is | ✓ Support on every plan | | Global CDN, scaling, backups | ✗ Your responsibility | ✓ Included | | Active feature development | ✗ Bugfixes / maintenance | ✓ Ongoing — [trial](https://usefathom.com/ref/GITHUB) · [sign up](https://app.usefathom.com/register) | ## Installation ### Production You can install Fathom on your server by following [our simple instructions](docs/Installation%20instructions.md). ### Development For getting a development version of Fathom up & running, go through the following steps. 1. Ensure you have [Go](https://golang.org/doc/install#install) and [NPM](https://www.npmjs.com) installed 1. Download the code: `git clone https://github.com/usefathom/fathom.git $GOPATH/src/github.com/usefathom/fathom` 1. Compile the project into an executable: `make build` 1. (Optional) Set [custom configuration values](docs/Configuration.md) 1. (Required) Register a user account: `./fathom user add --email= --password=` 1. Start the webserver: `./fathom server` and then visit **http://localhost:8080** to access your analytics dashboard ## Docker ### Building Ensure you have Docker installed and run `docker build -t fathom .`. Run the container with `docker run -d -p 8080:8080 fathom`. ### Running To run [our pre-built Docker image](https://hub.docker.com/r/usefathom/fathom/), run `docker run -d -p 8080:8080 usefathom/fathom:latest` ## Tracking snippet To start tracking, create a site in your Fathom dashboard and copy the tracking snippet to the website(s) you want to track. ### Content Security Policy If you use a [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to specify security policies for your website, Fathom requires the following CSP directives (replace `yourfathom.com` with the URL to your Fathom instance): ``` script-src: yourfathom.com; img-src: yourfathom.com; ``` ## Copyright and license MIT licensed. Fathom and Fathom logo are trademarks of Fathom Analytics. ================================================ FILE: assets/src/404.html ================================================ Not found - Fathom ================================================ FILE: assets/src/css/chart.css ================================================ .box-graph { background: white; margin-left: 0 !important; } #chart * { box-sizing: content-box; } #chart .muted { fill: #98a0a6; } #chart { height: 240px; } .bar-pageviews { fill: #88ffc6; } .bar-visitors { fill: #533feb; } .axis .domain{ stroke: none; } .axis line { stroke: rgba(218, 218, 218, 0.5); } .axis text { font-size: 12px; fill: #98a0a6; } .d3-tip { font-size: 12px; color: #959da5; text-align: left; background: rgba(0,0,0,.8); border-radius: 3px; } .tip-heading { font-weight: 600; padding: 10px; line-height: 1; } .tip-content { display: flex; } .tip-content > div { padding: 5px 10px; width: 50%; display: block; flex: 1; min-width: 90px; } .tip-pageviews { border-top: 3px solid rgb(136, 255, 198); } .tip-visitors { border-top: 3px solid rgb(83, 63, 235); } .tip-number { color: #dfe2e5; font-weight: 600; } ================================================ FILE: assets/src/css/fonts-overpass.css ================================================ @font-face { font-family: 'overpass'; src: url('../fonts/overpass-thin.eot'); /* IE9 Compat Modes */ src: url('../fonts/overpass-thin.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */ url('../fonts/overpass-thin.woff2') format('woff2'), /* Super Modern Browsers */ url('../fonts/overpass-thin.woff') format('woff'), /* Pretty Modern Browsers */ url('../fonts/overpass-thin.ttf') format('truetype'); /* Safari, Android, iOS */ font-weight: 200; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-thin-italic.eot'); src: url('../fonts/overpass-thin-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-thin-italic.woff2') format('woff2'), url('../fonts/overpass-thin-italic.woff') format('woff'), url('../fonts/overpass-thin-italic.ttf') format('truetype'); font-weight: 200; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-extralight.eot'); src: url('../fonts/overpass-extralight.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-extralight.woff2') format('woff2'), url('../fonts/overpass-extralight.woff') format('woff'), url('../fonts/overpass-extralight.ttf') format('truetype'); font-weight: 300; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-extralight-italic.eot'); src: url('../fonts/overpass-extralight-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-extralight-italic.woff2') format('woff2'), url('../fonts/overpass-extralight-italic.woff') format('woff'), url('../fonts/overpass-extralight-italic.ttf') format('truetype'); font-weight: 300; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-light.eot'); src: url('../fonts/overpass-light.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-light.woff2') format('woff2'), url('../fonts/overpass-light.woff') format('woff'), url('../fonts/overpass-light.ttf') format('truetype'); font-weight: 400; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-light-italic.eot'); src: url('../fonts/overpass-light-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-light-italic.woff2') format('woff2'), url('../fonts/overpass-light-italic.woff') format('woff'), url('../fonts/overpass-light-italic.ttf') format('truetype'); font-weight: 400; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-regular.eot'); src: url('../fonts/overpass-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-regular.woff2') format('woff2'), url('../fonts/overpass-regular.woff') format('woff'), url('../fonts/overpass-regular.ttf') format('truetype'); font-weight: 500; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-italic.eot'); src: url('../fonts/overpass-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-italic.woff2') format('woff2'), url('../fonts/overpass-italic.woff') format('woff'), url('../fonts/overpass-italic.ttf') format('truetype'); font-weight: 500; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-semibold.eot'); src: url('../fonts/overpass-semibold.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-semibold.woff2') format('woff2'), url('../fonts/overpass-semibold.woff') format('woff'), url('../fonts/overpass-semibold.ttf') format('truetype'); font-weight: 600; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-semibold-italic.eot'); src: url('../fonts/overpass-semibold-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-semibold-italic.woff2') format('woff2'), url('../fonts/overpass-semibold-italic.woff') format('woff'), url('../fonts/overpass-semibold-italic.ttf') format('truetype'); font-weight: 600; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-bold.eot'); src: url('../fonts/overpass-bold.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-bold.woff2') format('woff2'), url('../fonts/overpass-bold.woff') format('woff'), url('../fonts/overpass-bold.ttf') format('truetype'); font-weight: 700; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-bold-italic.eot'); src: url('../fonts/overpass-bold-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-bold-italic.woff2') format('woff2'), url('../fonts/overpass-bold-italic.woff') format('woff'), url('../fonts/overpass-bold-italic.ttf') format('truetype'); font-weight: 700; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-extrabold.eot'); src: url('../fonts/overpass-extrabold.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-extrabold.woff2') format('woff2'), url('../fonts/overpass-extrabold.woff') format('woff'), url('../fonts/overpass-extrabold.ttf') format('truetype'); font-weight: 800; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-extrabold-italic.eot'); src: url('../fonts/overpass-extrabold-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-extrabold-italic.woff2') format('woff2'), url('../fonts/overpass-extrabold-italic.woff') format('woff'), url('../fonts/overpass-extrabold-italic.ttf') format('truetype'); font-weight: 800; font-style: italic; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-heavy.eot'); src: url('../fonts/overpass-heavy.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-heavy.woff2') format('woff2'), url('../fonts/overpass-heavy.woff') format('woff'), url('../fonts/overpass-heavy.ttf') format('truetype'); font-weight: 900; font-style: normal; } @font-face { font-family: 'overpass'; src: url('../fonts/overpass-heavy-italic.eot'); src: url('../fonts/overpass-heavy-italic.eot?#iefix') format('embedded-opentype'), url('../fonts/overpass-heavy-italic.woff2') format('woff2'), url('../fonts/overpass-heavy-italic.woff') format('woff'), url('../fonts/overpass-heavy-italic.ttf') format('truetype'); font-weight: 900; font-style: italic; } ================================================ FILE: assets/src/css/pikaday.css ================================================ @charset "UTF-8"; /*! * Pikaday * Copyright © 2014 David Bushell | BSD & MIT license | http://dbushell.com/ */ .pika-single { z-index: 9999; display: block; position: relative; background: #fff; border-radius: 4px; box-shadow: 0 2px 8px 0 rgba(70,73,77,.16); } .pika-single:before, .pika-single:after { content: " "; display: table; } .pika-single:after { clear: both } .pika-single { *zoom: 1 } .pika-single.is-hidden { display: none; } .pika-single.is-bound { position: absolute; } .pika-lendar { float: left; width: 240px; margin: 8px; } .pika-title { position: relative; text-align: center; } .pika-label { display: inline-block; *display: inline; position: relative; z-index: 9999; overflow: hidden; margin: 0; padding: 5px 3px; font-size: 14px; line-height: 20px; font-weight: 500; } .pika-title select { cursor: pointer; position: absolute; z-index: 9998; margin: 0; left: 0; top: 5px; filter: alpha(opacity=0); opacity: 0; } .pika-prev, .pika-next { display: block; cursor: pointer; position: relative; outline: none; border: 0; padding: 0; width: 20px; height: 30px; text-indent: 20px; white-space: nowrap; overflow: hidden; background-color: transparent; background-position: center center; background-repeat: no-repeat; background-size: 75% 75%; opacity: .5; *position: absolute; *top: 0; } .pika-prev:hover, .pika-next:hover { opacity: 1; } .pika-prev, .is-rtl .pika-next { float: left; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg=='); *left: 0; } .pika-next, .is-rtl .pika-prev { float: right; background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII='); *right: 0; } .pika-prev.is-disabled, .pika-next.is-disabled { cursor: default; opacity: .2; } .pika-select { display: inline-block; *display: inline; } .pika-table { width: 100%; border-collapse: collapse; border-spacing: 0; border: 0; } .pika-table th, .pika-table td { width: 14.285714285714286%; padding: 0; } .pika-table th { color: #98a0a6; font-size: 12px; line-height: 25px; font-weight: 500; text-align: center; } .pika-button { cursor: pointer; display: block; box-sizing: border-box; -moz-box-sizing: border-box; outline: none; border: 0; margin: 0; width: 100%; padding: 4px; color: #666; font-size: 12px; line-height: 16px; text-align: right; background: #f5f7fa; } .pika-week { font-size: 11px; color: #98a0a6; } .is-today .pika-button { color: #533feb; font-weight: 500; } .is-selected .pika-button, .has-event .pika-button { color: #fff; font-weight: 500; background: #46494d; } .has-event .pika-button { background: #005da9; } .is-disabled .pika-button, .is-inrange .pika-button { background: #88ffc6; } .is-startrange .pika-button { background: #88ffc6; box-shadow: none; } .is-endrange .pika-button { color: #fff; background: #33aaff; box-shadow: none; } .is-disabled .pika-button { pointer-events: none; cursor: default; } .is-outside-current-month .pika-button { } .is-selection-disabled { pointer-events: none; cursor: default; } .pika-button:hover, .pika-row.pick-whole-week:hover .pika-button { color: #46494d; background: #88ffc6; box-shadow: none; } .pika-table abbr { border: none; cursor: normal; text-decoration: none; font-size: 12px; } ================================================ FILE: assets/src/css/styles.css ================================================ /* overpass 200, 500, 600 purple #533feb green #88ffc6 dark #46494d medium #98a0a6 light #f5f7fa padding 8, 16, 20, 32, 64, 128, 256, 512, 1024 font size 12, 16, 64 */ ::selection { background: #a0ffd1; } ::-moz-selection { background: #a0ffd1; } * { margin: 0; padding: 0; border: none; outline: none; list-style: none; box-sizing: border-box; text-decoration: none; font-size: 100%; vertical-align: baseline; line-height: 1.2; } html, body, #root { height: 100%; background: #f5f7fa; } body { overflow-y: scroll; font: 400 16px "overpass", sans-serif; color: #222; text-align: center; padding: 8px; } .rapper { text-align: left; margin: 0 auto; max-width: 1124px; } header, footer { margin: 16px 0; } section {} footer { font-size: 12px; color: #aaa; } a { transition: ease color .2s; } nav { position: relative; font-size: 12px; } nav a { color: #222; } footer nav a { color: #aaa; padding: 4px 0; display: inline-block; } footer nav a:hover { color: #222; } header li { padding: 8px 0; } nav li.logo a { color: #533feb; font-size: 16px; } nav li.logo a:hover { color: #222; } nav li.sites { background: #fff url("data:image/svg+xml;charset=utf8,%3Csvg aria-hidden='true' role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 512'%3E%3Cpath fill='%23533feb' d='M151.5 347.8L3.5 201c-4.7-4.7-4.7-12.3 0-17l19.8-19.8c4.7-4.7 12.3-4.7 17 0L160 282.7l119.7-118.5c4.7-4.7 12.3-4.7 17 0l19.8 19.8c4.7 4.7 4.7 12.3 0 17l-148 146.8c-4.7 4.7-12.3 4.7-17 0z'%3E%3C/path%3E%3C/svg%3E") 97% 8px no-repeat; border-radius: 4px; padding: 8px; background-size: 10px auto; } nav li.sites.expanded { background: #533feb url("data:image/svg+xml;charset=utf8,%3Csvg aria-hidden='true' role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 512'%3E%3Cpath fill='%23fff' d='M168.5 164.2l148 146.8c4.7 4.7 4.7 12.3 0 17l-19.8 19.8c-4.7 4.7-12.3 4.7-17 0L160 229.3 40.3 347.8c-4.7 4.7-12.3 4.7-17 0L3.5 328c-4.7-4.7-4.7-12.3 0-17l148-146.8c4.7-4.7 12.3-4.7 17 0z'%3E%3C/path%3E%3C/svg%3E") 97% 8px no-repeat; background-size: 10px auto; } nav li ul { display: none; position: absolute; z-index: 1001; width: 100%; background: #533feb; border-radius: 4px; padding: 8px 0; margin: 0 0 0 -8px; } nav li.expanded ul { display: block; box-shadow: 0 2px 8px 0 rgba(34,34,34,.10); } nav li.sites.expanded a { color: #fff; } nav li ul li { padding: 0 4px; width: 100%; } nav li ul a { color: #ddd; display: inline-block; width: 100%; font-size: 13px; padding: 4px; } nav li ul a:hover { background: rgba(255,255,255,.2); border-radius: 2px; } nav li ul li.add-new a { color: #88ffc6 !important; } nav .settings svg { width: 16px; display: inline-block; transition: ease all .2s; } nav .settings svg path { fill: #533feb; } nav .settings svg:hover { transform: rotate(45deg); } nav.date-nav { margin: 32px 0 8px; } nav.date-nav ul { margin: 0 8px 0 0; border-radius: 4px; background: #e8ecee; padding: 8px 8px 8px 16px; display: inline-block; } nav.date-nav li { display: inline-block; padding: 0; } nav.date-nav li a, nav.date-nav li input { margin-right: 8px; color: #98a0a6; } nav.date-nav li input { background: transparent; border: 0; font-size: inherit; padding: 0; cursor: pointer; display: inline-block; width: 9.8ch; min-width: 60px; text-align: center; } nav.date-nav li.current a { color: #533feb; } nav.date-nav li a:hover { color: #2b2d2f; } nav.date-nav li span { margin-right: 8px; } /* .date-nav { margin-bottom: 12px; } .date-nav li a { position: relative; font-size: 12px; text-transform: uppercase; padding-right: 8px; } .date-nav li.custom { color: #aaa; float: right; margin: 0; } .date-nav li.custom input { display: inline-block; width: 75px; border: 0; font-size: inherit; background: transparent; padding: 0; cursor: pointer; } .date-nav li a:hover { color: #aaa; } .date-nav li.active a { padding-right: 8px; z-index: 1; color: #222; } .date-nav li.active a:after { content:""; background: #88ffc6; display: block; width: 100%; height: 3px; position: absolute; top: 6px; z-index: -1; margin: 0 0 0 -4px; } */ .box { background: #fff; border-radius: 4px; margin-bottom: 16px; padding: 16px; box-shadow: 0 2px 8px 0 rgba(34,34,34,.10); } .box-totals { background: #222; color: #ddd; } .totals-detail { display: grid; grid-template-columns: 1fr 1.6fr; grid-gap: 12px; } .total-numbers { text-align: right; } .current-detail div { color: #88ffc6; } .table-row { display: grid; grid-template-columns: 4fr 1fr 1fr; grid-gap: 12px; padding: 8px 0; position: relative; } .table-row.header { font-size: 12px; text-transform: uppercase; color: #aaa; } .table-row a { color: #222; } .table-row a:hover { color: #533feb; } .cell { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: right; z-index: 1; position: relative; } .cell.main-col { text-align: left; } .table-row:after { content: ""; background: #88ffc6; position: absolute; height: 34px; top: 0; left: -16px; opacity: .2; border-right: 2px solid #45ce8c; z-index: 0; } .table-row.header:after { background: none; border: none; } .row.pag { margin-top: 16px; grid-template-columns: 1fr 1fr; grid-gap: 4px; } .row.pag a { color: #98a0a6; font-size: 19px; } .modal-wrap { position: fixed; height: 100%; width: 100%; top: 0; left: 0; z-index: 1977; background: rgba(20,20,20,.8); display: grid; grid-template-columns: 1fr; align-items: center; } .modal { max-width: 480px; width: 100%; margin: 0 auto; text-align: left; background: #fff; z-index: 1978; height: auto; border-radius: 4px; box-shadow: 0 2px 8px 0 rgba(34,34,34,.10); overflow: hidden; } .modal p { padding: 16px; font-size: 12px; color: #aaa; } small { font-size: 12px; color: #aaa; } small a { color: #aaa; } .modal form { padding: 0 16px; } label { padding: 4px 0; display: block; } fieldset { display: block; width: 100%; margin-bottom: 32px; } fieldset:last-child { margin-bottom: 16px; } input, textarea { background: #f5f7fa; padding: 4px; width: 100%; display: block; font: 400 16px "overpass", sans-serif; color: #222; border-bottom: 2px solid #e6e8eb; } textarea { font-size: 12px; min-height: 120px; resize: none; overflow-y: scroll; } button { background: #88ffc6; color: #222; padding: 4px 16px; font: 400 16px "overpass", sans-serif; cursor: pointer; border-radius: 4px; } div.delete a { color: red; } .w100:after{width:99%}.w99:after{width:99%}.w98:after{width:98%}.w97:after{width:97%}.w96:after{width:96%}.w95:after{width:95%}.w94:after{width:94%}.w93:after{width:93%}.w92:after{width:92%}.w91:after{width:91%}.w90:after{width:90%}.w89:after{width:89%}.w88:after{width:88%}.w87:after{width:87%}.w86:after{width:86%}.w85:after{width:85%}.w84:after{width:84%}.w83:after{width:83%}.w82:after{width:82%}.w81:after{width:81%}.w80:after{width:80%}.w79:after{width:79%}.w78:after{width:78%}.w77:after{width:77%}.w76:after{width:76%}.w75:after{width:75%}.w74:after{width:74%}.w73:after{width:73%}.w72:after{width:72%}.w71:after{width:71%}.w70:after{width:70%}.w69:after{width:69%}.w68:after{width:68%}.w67:after{width:67%}.w66:after{width:66%}.w65:after{width:65%}.w64:after{width:64%}.w63:after{width:63%}.w62:after{width:62%}.w61:after{width:61%}.w60:after{width:60%}.w59:after{width:59%}.w58:after{width:58%}.w57:after{width:57%}.w56:after{width:56%}.w55:after{width:55%}.w54:after{width:54%}.w53:after{width:53%}.w52:after{width:52%}.w51:after{width:51%}.w50:after{width:50%}.w49:after{width:49%}.w48:after{width:48%}.w47:after{width:47%}.w46:after{width:46%}.w45:after{width:45%}.w44:after{width:44%}.w43:after{width:43%}.w42:after{width:42%}.w41:after{width:41%}.w40:after{width:40%}.w39:after{width:39%}.w38:after{width:38%}.w37:after{width:37%}.w36:after{width:36%}.w35:after{width:35%}.w34:after{width:34%}.w33:after{width:33%}.w32:after{width:32%}.w31:after{width:31%}.w30:after{width:30%}.w29:after{width:29%}.w28:after{width:28%}.w27:after{width:27%}.w26:after{width:26%}.w25:after{width:25%}.w24:after{width:24%}.w23:after{width:23%}.w22:after{width:22%}.w21:after{width:21%}.w20:after{width:20%}.w19:after{width:19%}.w18:after{width:18%}.w17:after{width:17%}.w16:after{width:16%}.w15:after{width:15%}.w14:after{width:14%}.w13:after{width:13%}.w12:after{width:12%}.w11:after{width:11%}.w10:after{width:10%}.w09:after{width:9%}.w08:after{width:8%}.w07:after{width:7%}.w06:after{width:6%}.w05:after{width:5%}.w04:after{width:4%}.w03:after{width:3%}.w02:after{width:2%}.w01:after{width:1%}.w00:after{width:0} @media ( min-width: 1220px ) { body { padding: 0; } header, footer { margin: 32px 0; } .main-nav li, footer li { display: inline-block; margin-right: 16px; } nav li.sites { display: inline-block; width: 204px; margin-right: 0; } nav li.sites, nav li.settings { float: right; } nav li.sites, nav li.sites.expanded { background-position: 184px 8px; } nav .date-nav li { margin-right: 8px; } nav li ul { width: 204px; right: 0; margin: 0; } .box { margin: 0; padding: 32px 16px; } .boxes { display: grid; grid-template-columns: 276px 420px 420px; grid-gap: 4px; } .box-totals { grid-column: 1; grid-row: 1/3; } .box-graph { grid-column: 2/4; grid-row: 1; margin: 0 0 4px 0; } .box-pages { grid-column: 2; grid-row: 2 ; } .box-referrers { grid-column: 3; grid-row: 2; } .half { display: grid; grid-template-columns: 1fr 1fr; grid-gap: 12px; align-items: center; } .half div { text-align: right; } .half div.submit { text-align: left; } .totals-detail { display: block; margin-bottom: 32px; } .total-heading { font-size: 12px; text-transform: uppercase; color: #888; line-height: .8; } .total-numbers { font-size: 68px; font-weight: 200; letter-spacing: -.06em; text-align: left; } } .login-page.flex-rapper { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } .login-rapper { text-align: left; width: 320px; } .login-page label { position: relative; } .login-page label svg { width: 16px; height: auto; fill: #533feb; position: absolute; left: -28px; top: 12px; } .login-page input { border-radius: 0; border: none; outline: none; background: #fff; box-sizing: border-box; width: 100%; padding: 8px; margin-bottom: 4px; display: block; } .login-page input{ line-height: 1; font: 400 16px/1 'overpass', sans-serif; } .login-page button { width: 100%; box-sizing: border-box; padding: 8px; background: #533feb; color: #fff; border: 0; font: 400 16px/1 'overpass', sans-serif; } .login-page small { color: #98a0a6; font-size: 14px; margin: 48px auto 0 auto; text-align: center; display: block; } .login-page small a { color: #98a0a6; font-size: 14px; display: inline-block; padding: 0 8px; } .notice { background: #ecedf4; border-radius: 4px; padding: 16px; text-align: left; color: color: #222; font-size: 14px; display: grid; grid-template-columns: 1fr min-content; margin: 32px 0; gap: 32px; align-items: center; } .notice a { color: #aaa; } .notice .notice-text a { color: #533feb; text-decoration: underline; } ================================================ FILE: assets/src/css/util.css ================================================ .small-margin { margin-top: 20px; margin-bottom: 20px; } .cf:after { content: ""; display: table; clear: both; } .ac { text-transform: uppercase; } .sm { font-size: 11px; font-weight: 500; color: #98a0a6; } @media(max-width: 600px) { .hide-on-mobile { display: none !important; } } .right { float: right; } .left { float: left; } .notification { position: fixed; top: 20px; left: 0; right: 0; text-align: center; width: 100%; } .notification .notification-error { padding: 4px; display: inline-block; background-color: #f2dede; border: 1px solid #ebccd1; } @keyframes fadeInUp { 0% { opacity: 0; transform: translateY(20px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes fadeInDown { 0% { opacity: 0; transform: translateY(-20px); } 100% { opacity: 1; transform: translateY(0); } } .animated { animation-duration: .4s; animation-fill-mode: both; } .delayed_02s { animation-delay: .2s; } .delayed_03s { animation-delay: .3s; } .delayed_04s { animation-delay: .4s; } .delayed_05s { animation-delay: .5s; } .delayed_06s { animation-delay: .6s; } .fadeInUp { animation-name: fadeInUp; } .fadeInDown { animation-name: fadeInDown; } .loading { opacity: 0.6; } ================================================ FILE: assets/src/index.html ================================================ Fathom - simple website analytics
================================================ FILE: assets/src/js/components/Chart.js ================================================ 'use strict'; import { h, Component } from 'preact'; import Client from '../lib/client.js'; import { bind } from 'decko'; import * as numbers from '../lib/numbers.js'; import * as d3 from 'd3'; import 'd3-transition'; d3.tip = require('d3-tip'); const formatMonth = d3.timeFormat("%b"), formatMonthDay = d3.timeFormat("%b %e"); const t = d3.transition().duration(600).ease(d3.easeQuadOut); function xTickFormat(tickStep, n) { let formatters = { hour: (d, i) => { if(i === 0 || i === n-1) { return formatMonthDay(d); } if(n <= 24 && d.getHours() === 0 || d.getHours() === 12) { return d.getHours() + ":00"; } return ''; }, day: (d, i, o) => { if( i === 0 || i === n-1) { return formatMonthDay(d); } return ''; }, month: (d, i) => { if(n>28) { return d.getFullYear(); } return d.getMonth() === 0 ? d.getFullYear() : formatMonth(d); } } return formatters[tickStep]; } const incrementers = { 'hour': d => d.setHours(d.getHours() + 1), 'day': d => d.setDate(d.getDate() + 1), 'month': d => d.setMonth(d.getMonth() + 1) } function incrementDate(date, incr) { return incrementers[incr](date); } class Chart extends Component { constructor(props) { super(props) this.state = { loading: false, data: [], chartData: [], diffInDays: 1, } } componentWillReceiveProps(newProps) { let daysDiff = Math.round((newProps.dateRange[1]-newProps.dateRange[0])/1000/24/60/60); this.setState({ diffInDays: daysDiff, tickStep: newProps.tickStep, }) if( newProps.siteId != this.props.siteId || newProps.dateRange[0] != this.props.dateRange[0] || newProps.dateRange[1] != this.props.dateRange[1] ) { this.fetchData(newProps) } else if (newProps.tickStep != this.props.tickStep) { this.chartData() this.redrawChart() } } @bind chartData() { let startDate = new Date(this.props.dateRange[0]); let endDate = this.props.dateRange[1]; let newData = []; // if grouping by month, fix date to 1st of month if(this.state.tickStep === 'month') { startDate.setDate(1); } // instantiate JS Date objects let data = this.state.data.map(d => { d.Date = new Date(d.Date); return d }) // make sure we have values for each date (so 0 value for gaps) let currentDate = startDate, nextDate, tick, offset = 0; while(currentDate < endDate) { tick = { "Pageviews": 0, "Visitors": 0, "Date": new Date(currentDate), }; nextDate = new Date(currentDate) nextDate = incrementDate(nextDate, this.state.tickStep) // grab data that falls between currentDate & nextDate for(let i=data.length-offset-1; i>=0; i--) { // Because 9AM should be included in 9AM-10AM range, check for equality here if( data[i].Date >= nextDate) { break; } // increment offset so subsequent dates can skip first X items in array offset += 1; // continue to next item in array if we're still below our target date if( data[i].Date < currentDate) { continue; } // add to tick data tick.Pageviews += data[i].Pageviews; tick.Visitors += data[i].Visitors; } newData.push(tick); currentDate = nextDate; } this.setState({ chartData: newData, }) } @bind prepareChart() { let padding = { top: 12, right: 12, bottom: 24, left: 40 }; let height = 240; let width = this.base.clientWidth; this.innerWidth = width - padding.left - padding.right; this.innerHeight = height - padding.top - padding.bottom; this.ctx = d3.select(this.base) .append('svg') .attr('width', width) .attr('height', height) .append('g') .attr('transform', 'translate(' + padding.left + ', '+padding.top+')') this.x = d3.scaleBand().range([0, this.innerWidth]).padding(0.1) this.y = d3.scaleLinear().range([this.innerHeight, 0]) // tooltip this.tip = d3.tip().attr('class', 'd3-tip').html((d) => { let title; if(this.state.tickStep === 'hour') { title = `${d.Date.toLocaleDateString()} ${d.Date.getHours()}:00 - ${d.Date.getHours() + 1}:00` } else if(this.state.tickStep === 'day' ) { title = `${d.Date.toLocaleDateString()} (${d3.timeFormat("%a")(d.Date)})` } else { title = d3.timeFormat("%B %Y")(d.Date) } return (`
${title}
${numbers.formatPretty(d.Pageviews)}
Pageviews
${numbers.formatPretty(d.Visitors)}
Visitors
` )}); this.ctx.call(this.tip) } @bind redrawChart() { let data = this.state.chartData; if( ! this.ctx ) { this.prepareChart() } let graph = this.ctx; let innerWidth = this.innerWidth let innerHeight = this.innerHeight const max = d3.max(data, d => d.Pageviews); let x = this.x.domain(data.map(d => d.Date)) let y = this.y.domain([0, max*1.1]) let yAxis = d3.axisLeft().scale(y).ticks(3).tickSize(-innerWidth).tickFormat(v => numbers.formatPretty(v)) let xAxis = d3.axisBottom().scale(x).tickFormat(xTickFormat(this.state.tickStep, data.length)) // only show first and last tick if we have more than 28 ticks to show if(data.length > 28) { let tickValues = data.map(d => d.Date).filter((d, i) => i === 0 || i === data.length-1); xAxis.tickValues(tickValues) xAxis.tickFormat(xTickFormat(this.state.tickStep, tickValues.length)) } // empty previous graph graph.selectAll('*').remove() // add text indicating there's no data yet if( max === 0 ) { graph.append('text') .attr('class', 'muted') .attr("text-anchor", "middle") .attr('x', innerWidth / 2 - 30) .attr('y', innerHeight / 2) .text('Nothing here, yet.') } // add axes let yTicks = graph.append("g") .attr("class", "y axis") .call(yAxis); let xTicks = graph.append("g") .attr("class", "x axis") .attr('transform', 'translate(0,' + innerHeight + ')') .call(xAxis) // add data for each tick that we have something to show for let barWidth = x.bandwidth() let ticks = graph.selectAll('.item') .data(data.filter(d => d.Pageviews > 0 || d.Visitors > 0)).enter() .append('g') .attr('class', 'item') let pageviews = ticks.append('rect') .attr('class', 'bar-pageviews') .attr('x', d => x(d.Date)) .attr('width', barWidth) .attr("y", innerHeight) .attr("height", 0) let visitors = ticks.append('rect') .attr('class', 'bar-visitors') .attr('x', d => x(d.Date) ) .attr('width', barWidth) .attr("y", innerHeight) .attr("height", 0) pageviews.transition(t) .attr('y', d => y(d.Pageviews)) .attr('height', (d) => innerHeight - y(d.Pageviews)) visitors.transition(t) .attr('height', (d) => (innerHeight - y(d.Visitors)) ) .attr('y', (d) => y(d.Visitors)) // add event listeners for tooltips ticks.on('mouseover', this.tip.show).on('mouseout', this.tip.hide) } @bind fetchData(props) { this.setState({ loading: true }) let before = props.dateRange[1]/1000; let after = props.dateRange[0]/1000; Client.request(`/sites/${props.siteId}/stats/site?before=${before}&after=${after}`) .then(data => { this.setState({ loading: false, data: data, }) this.chartData() this.redrawChart() }) } render(props, state) { return (
) } } export default Chart ================================================ FILE: assets/src/js/components/CountWidget.js ================================================ 'use strict'; import { h, Component } from 'preact'; import * as numbers from '../lib/numbers.js'; import { bind } from 'decko'; import classNames from 'classnames'; const duration = 600; const easeOutQuint = function (t) { return 1+(--t)*t*t*t*t }; class CountWidget extends Component { componentWillReceiveProps(newProps, newState) { if(newProps.value == this.props.value) { return; } this.countUp(this.props.value || 0, newProps.value); } // TODO: Move to component of its own @bind countUp(fromValue, toValue) { const format = this.formatValue.bind(this); const startValue = isFinite(fromValue) ? fromValue : 0; const numberEl = this.numberEl; const diff = toValue - startValue; let startTime = performance.now(); const tick = function(t) { let progress = Math.min(( t - startTime ) / duration, 1); let newValue = startValue + (easeOutQuint(progress) * diff); numberEl.textContent = format(newValue) if(progress < 1) { window.requestAnimationFrame(tick); } } window.requestAnimationFrame(tick); } @bind formatValue(value) { let formattedValue = "-"; if(isFinite(value)) { switch(this.props.format) { case "percentage": formattedValue = numbers.formatPercentage(value) break; default: case "number": formattedValue = numbers.formatPretty(Math.round(value)) break; case "duration": formattedValue = numbers.formatDuration(value) break; } } return formattedValue; } render(props, state) { return (
{props.title}
{ this.numberEl = e; }}>{this.formatValue(props.value)}
) } } export default CountWidget ================================================ FILE: assets/src/js/components/DatePicker.js ================================================ 'use strict'; import { h, Component } from 'preact'; import { bind } from 'decko'; import Pikadayer from './Pikadayer.js'; import classNames from 'classnames'; import {hashParams} from "../lib/util"; const padZero = (n) => n < 10 ? '0'+n : ''+n; let now = new Date(); window.setInterval(() => { now = new Date(); }, 60000 ); const availablePeriods = { '1d': { label: '1d', start: function() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }, end: function() { return this.start(); }, }, '1w': { label: '1w', start: function() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()-6); }, end: function() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }, }, '4w': { label: '4w', start: function() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()-4*7+1); }, end: function() { return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }, }, 'mtd': { label: 'Mtd', start: function() { return new Date(now.getFullYear(), now.getMonth(), 1); }, end: function() { return new Date(now.getFullYear(), now.getMonth()+1, 0); }, }, 'qtd': { label: 'Qtd', start: function() { let qs = Math.ceil((now.getMonth()+1) / 3) * 3 - 3; return new Date(now.getFullYear(), qs, 1); }, end: function() { let start = this.start(); return new Date(start.getFullYear(), start.getMonth() + 3, 0); }, }, 'ytd': { label: 'Ytd', start: function() { return new Date(now.getFullYear(), 0, 1); }, end: function() { return new Date(now.getFullYear()+1, 0, 0); }, }, 'all': { label: 'All', start: function() { return new Date(2018, 6, 1); }, end: function() { return new Date(); }, } } class DatePicker extends Component { constructor(props) { super(props) let params = hashParams(); this.state = { period: params.p || window.localStorage.getItem('period') || '1w', startDate: new Date(params.s || 'now'), endDate: new Date(params.e || 'now'), groupBy: params.g || 'day', site: params.site || 1 } this.state.diff = this.calculateDiff(this.state.startDate, this.state.endDate) if(this.state.period !== 'custom') { this.updateDatesFromPeriod(this.state.period, params.g) } else { this.props.onChange(this.state); } } componentDidMount() { window.addEventListener('keydown', this.handleKeyPress); } componentWillUnmount() { window.removeEventListener('keydown', this.handleKeyPress) } @bind updateDatesFromPeriod(period, groupBy) { if(typeof(availablePeriods[period]) !== "object") { period = "1w"; } let p = availablePeriods[period]; this.setDateRange(p.start(), p.end(), period, groupBy); } @bind setDateRange(start, end, period, groupBy) { // don't update state if start > end. user may be busy picking dates. if(start > end) { return; } // include start & end day by forcing time start.setHours(0, 0, 0); end.setHours(23, 59, 59); let diff = this.calculateDiff(start, end) if(!groupBy) { groupBy = 'day'; if(diff >= 31) { groupBy = 'month'; } else if( diff < 2) { groupBy = 'hour'; } } this.setState({ period: period, startDate: start, endDate: end, diff: diff, groupBy: groupBy, }); // use slight delay for updating rest of application to allow this function to be called again if(!this.timeout) { this.timeout = window.setTimeout(() => { this.props.onChange(this.state); this.updateURL() this.timeout = null; }, 5) } } calculateDiff(start, end) { return Math.round((end - start) / 1000 / 60 / 60 / 24) } updateURL() { if(this.state.period !== 'custom') { window.history.replaceState(this.state, null, `#!p=${this.state.period}&g=${this.state.groupBy}&site=${this.state.site}`) } else { window.history.replaceState(this.state, null, `#!p=custom&s=${encodeURIComponent(this.state.startDate.toISOString())}&e=${encodeURIComponent(this.state.endDate.toISOString())}&g=${this.state.groupBy}&site=${this.state.site}`) } } @bind setPeriod(e) { e.preventDefault(); let newPeriod = e.target.getAttribute('data-value'); if( newPeriod === this.state.period) { return; } window.localStorage.setItem('period', this.state.period) this.updateDatesFromPeriod(newPeriod); } dateValue(date) { return date.getFullYear() + '-' + padZero(date.getMonth() + 1) + '-' + padZero(date.getDate()); } @bind setStartDate(date) { this.setDateRange(date, this.state.endDate, 'custom') } @bind setEndDate(date) { this.setDateRange(this.state.startDate, date, 'custom') } @bind handleKeyPress(evt) { // Don't handle input when the user is in a text field or text area. let tag = evt.target.tagName; if(tag === "INPUT" || tag === "TEXTAREA") { return; } // TODO: Account for leap years let diff = this.state.endDate - this.state.startDate + 1000; let newStartDate, newEndDate; switch(evt.which) { // left-arrow case 37: newStartDate = new Date(+this.state.startDate - diff) newEndDate = new Date(+this.state.endDate - diff) this.setDateRange(newStartDate, newEndDate) break; //right-arrow case 39: newStartDate = new Date(+this.state.startDate + diff) newEndDate = new Date(+this.state.endDate + diff) this.setDateRange(newStartDate, newEndDate) break; } } @bind setGroupBy(e) { this.setState({ groupBy: e.target.getAttribute('data-value') }) this.props.onChange(this.state); this.updateURL() } render(props, state) { const presets = Object.keys(availablePeriods).map((id) => { let p = availablePeriods[id]; return (
  • {p.label}
  • ); }); return ( ) /* */ } } export default DatePicker ================================================ FILE: assets/src/js/components/Gearwheel.js ================================================ 'use strict'; import { h, Component } from 'preact'; class Gearwheel extends Component { render(props, state) { // don't show if visible prop is false if(!props.visible) { return ''; } return (
  • ) } } export default Gearwheel ================================================ FILE: assets/src/js/components/LoginForm.js ================================================ 'use strict'; import { h, render, Component } from 'preact'; import Client from '../lib/client.js'; import Notification from '../components/Notification.js'; import { bind } from 'decko'; class LoginForm extends Component { constructor(props) { super(props) this.state = { email: '', password: '', message: '' } } @bind handleSubmit(e) { e.preventDefault(); this.setState({ message: '' }); Client.request('session', { method: "POST", data: { email: this.state.email, password: this.state.password, } }).then((r) => { this.props.onSuccess() }).catch((e) => { this.setState({ message: e.code === 'invalid_credentials' ? "Invalid username or password" : e.message, password: '' }); }); } @bind updatePassword(e) { this.setState({ password: e.target.value }); } @bind updateEmail(e) { this.setState({ email: e.target.value }); } @bind clearMessage() { this.setState({ message: '' }); } render(props, state) { return (
    ) } } export default LoginForm ================================================ FILE: assets/src/js/components/LogoutButton.js ================================================ 'use strict'; import { h, Component } from 'preact'; import Client from '../lib/client.js'; import { bind } from 'decko'; class LogoutButton extends Component { @bind handleSubmit(e) { e.preventDefault(); Client.request('session', { method: "DELETE", }).then((r) => { this.props.onSuccess() }) } render() { if(document.cookie.indexOf('auth') < 0) { return '' } return ( Sign out ) } } export default LogoutButton ================================================ FILE: assets/src/js/components/Notification.js ================================================ 'use strict' import { h, Component } from 'preact'; import { bind } from 'decko'; class Notification extends Component { constructor(props) { super(props) this.state = { message: props.message, kind: props.kind || 'error' } this.timeout = 0 } componentWillReceiveProps(newProps) { if(newProps.message === this.state.message) { return; } this.setState({ message: newProps.message, kind: newProps.kind || 'error' }) window.clearTimeout(this.timeout) this.timeout = window.setTimeout(this.props.onDismiss, 5000) } render(props, state) { if(state.message === '') { return '' } return (
    {state.message}
    )} } export default Notification ================================================ FILE: assets/src/js/components/Pikadayer.js ================================================ 'use strict'; import Pikaday from 'pikaday'; import { h, Component } from 'preact'; class Pikadayer extends Component { componentDidMount() { this.pikaday = new Pikaday({ field: this.base, onSelect: this.props.onSelect, position: 'bottom right', }) } componentWillReceiveProps(newProps) { // make sure pikaday updates if we set a date using one of our presets if(this.pikaday && newProps.value !== this.props.value) { this.pikaday.setDate(newProps.value, true) } } componentWillUnmount() { this.pikaday.destroy() } render(props) { return } } export default Pikadayer ================================================ FILE: assets/src/js/components/Realtime.js ================================================ 'use strict'; import { h, Component } from 'preact'; import Client from '../lib/client.js'; import { bind } from 'decko'; import * as numbers from '../lib/numbers.js'; class Realtime extends Component { constructor(props) { super(props) this.state = { count: 0 } } componentDidMount() { this.fetchData(this.props.siteId); this.interval = window.setInterval(this.handleIntervalEvent, 15000); } componentWillUnmount() { window.clearInterval(this.interval); } componentWillReceiveProps(newProps, newState) { if(!this.paramsChanged(this.props, newProps)) { return; } this.fetchData(newProps.siteId) } paramsChanged(o, n) { return o.siteId != n.siteId; } @bind setDocumentTitle() { // update document title let visitorText = this.state.count == 1 ? 'visitor' : 'visitors'; document.title = ( this.state.count > 0 ? `${numbers.formatPretty(this.state.count)} current ${visitorText} — Fathom` : 'Fathom' ); } @bind handleIntervalEvent() { this.fetchData(this.props.siteId) } @bind fetchData(siteId) { let url = `/sites/${siteId}/stats/site/realtime` Client.request(url) .then((d) => { this.setState({ count: d }) this.setDocumentTitle(); }) } render(props, state) { let visitorText = state.count == 1 ? 'visitor' : 'visitors'; return ( {numbers.formatPretty(state.count)} current {visitorText} ) } } export default Realtime ================================================ FILE: assets/src/js/components/Sidebar.js ================================================ 'use strict'; import { h, Component } from 'preact'; import Client from '../lib/client.js'; import { bind } from 'decko'; import CountWidget from './CountWidget.js'; class Sidebar extends Component { constructor(props) { super(props) this.state = { data: {}, loading: false, } } componentWillReceiveProps(newProps, newState) { if(!this.paramsChanged(this.props, newProps)) { return; } this.fetchData(newProps); } paramsChanged(o, n) { return o.siteId != n.siteId || o.dateRange[0] != n.dateRange[0] || o.dateRange[1] != n.dateRange[1]; } @bind fetchData(props) { this.setState({ loading: true }) let before = props.dateRange[1]/1000; let after = props.dateRange[0]/1000; Client.request(`/sites/${props.siteId}/stats/site/agg?before=${before}&after=${after}`) .then((data) => { // request finished; check if timestamp range is still the one user wants to see if(this.paramsChanged(props, this.props)) { return; } // Make sure we always show at least 1 visitor when there are pageviews if ( data.Visitors == 0 && data.Pageviews > 0 ) { data.Visitors = 1 } this.setState({ loading: false, data: data }) }) } render(props, state) { return (
    ) } } export default Sidebar ================================================ FILE: assets/src/js/components/SiteSettings.js ================================================ 'use strict'; import { h, Component } from 'preact'; import Client from '../lib/client.js'; import { bind } from 'decko'; class SiteSettings extends Component { constructor(props) { super(props) this.state = { copied: false, updated: false, } } componentDidMount() { document.addEventListener('keydown', this.handleKeydownEvent); } componentWillUnmount() { document.removeEventListener('keydown', this.handleKeydownEvent) } @bind revertTemporaryState() { this.setState({ copied: false, updated: false }) } @bind copyToClipboard(evt) { this.textarea.select() document.execCommand('copy') this.setState({ copied: true }) window.setTimeout(this.revertTemporaryState, 2400) } @bind deleteSite(evt) { if(!confirm("Are you sure you want to delete this site? This action is irreversible - you will lose all the site's data.")) { return; } let site = this.props.site; Client.request(`/sites/${site.id}`, { method: "DELETE", }).then((d) => { this.props.onDelete(site) }) } @bind onSubmit(evt) { evt.preventDefault(); let site = this.props.site; let url = site.unsaved ? `/sites` : `/sites/${site.id}` Client.request(url, { method: "POST", data: { name: site.name, }, }).then((site) => { this.setState({ updated: true}) window.setTimeout(this.revertTemporaryState, 2400) site.unsaved = false this.props.onUpdate(site) }) } @bind handleTextareaClickEvent(evt) { evt.target.select() } @bind handleClickEvent(evt) { // don't close if click was inside the modal if ( evt.target.matches('.modal *, .modal')) { return; } this.props.onClose() } @bind handleKeydownEvent(evt) { // close modal when pressing ESC if(evt.which == 27) { this.props.onClose() } } @bind setTextarea(el) { this.textarea = el } @bind updateSiteName(evt) { this.props.site.name = evt.target.value; } render(props, state) { return ( ) } } export default SiteSettings ================================================ FILE: assets/src/js/components/SiteSwitcher.js ================================================ 'use strict'; import { h, Component } from 'preact'; import { bind } from 'decko'; import { hashParams } from "../lib/util"; function arrayToQueryString(array_in){ var out = new Array(); for(var key in array_in){ out.push(key + '=' + encodeURIComponent(array_in[key])); } return out.join('&'); } class SiteSwitcher extends Component { constructor() { super(); this.state = { isExpanded: false }; } @bind selectSite(evt) { let itemId = evt.target.getAttribute("data-id") this.props.sites.some((s) => { if (s.id != itemId) { return false; } let params = hashParams() params["site"] = s.id window.history.replaceState(this.state, null, `#!${arrayToQueryString(params)}`) this.props.onChange(s) return true; }) } @bind addSite() { this.props.onAdd({ id: 1, name: "New site", unsaved: true }) } @bind expand() { this.setState({ isExpanded: true }); } @bind collapse() { this.setState({ isExpanded: false }); } @bind toggleExpanded() { this.setState({ isExpanded: !this.state.isExpanded }); } render(props, state) { // show nothing if there is only 1 site and no option to add additional sites if(!props.showAdd && props.sites.length == 1) { return ''; } // otherwise, render list of sites + add button return (
  • {props.selectedSite.name}
  • ) } } export default SiteSwitcher ================================================ FILE: assets/src/js/components/Table.js ================================================ 'use strict'; import { h, Component } from 'preact'; import * as numbers from '../lib/numbers.js'; import Client from '../lib/client.js'; import { bind } from 'decko'; import classNames from 'classnames'; import { runInNewContext } from 'vm'; const dayInSeconds = 60 * 60 * 24; class Table extends Component { constructor(props) { super(props) this.state = { records: [], offset: 0, limit: 15, loading: true, total: 0, } } componentWillReceiveProps(newProps, newState) { if(!this.paramsChanged(this.props, newProps)) { return; } this.fetchData(newProps) } paramsChanged(o, n) { return o.siteId !== n.siteId || o.dateRange[0] != n.dateRange[0] || o.dateRange[1] != n.dateRange[1]; } @bind fetchData(props) { this.setState({ loading: true }); let before = props.dateRange[1]/1000; let after = props.dateRange[0]/1000; Client.request(`/sites/${props.siteId}/stats/${props.endpoint}/agg?before=${before}&after=${after}&offset=${this.state.offset}&limit=${this.state.limit}`) .then((d) => { // request finished; check if timestamp range is still the one user wants to see if( this.paramsChanged(props, this.props) ) { return; } this.setState({ loading: false, records: d, }); }); // fetch totals too Client.request(`/sites/${props.siteId}/stats/${props.endpoint}/agg/pageviews?before=${before}&after=${after}`) .then((d) => { this.setState({ total: d }); }); } @bind paginateNext() { this.setState({ offset: this.state.offset + this.state.limit }) this.fetchData(this.props) } @bind paginatePrev() { if(this.state.offset == 0) { return; } this.setState({ offset: Math.max(0, this.state.offset - this.state.limit) }) this.fetchData(this.props) } render(props, state) { const tableRows = state.records !== null && state.records.length > 0 ? state.records.map((p, i) => { let href = (p.Hostname + p.Pathname) || p.URL; let widthClass = ""; if(state.total > 0) { widthClass = "w" + ("" + Math.min(98, Math.round(p.Pageviews / state.total * 100 * 2.5))).padStart(2, '0'); } let label = p.Pathname if( props.showHostname ) { if( p.Group) { label = p.Group } else { label = p.Hostname.replace('www.', '').replace('https://', '').replace('http://', '') + (p.Pathname.length > 1 ? p.Pathname : '') } } return(
    {numbers.formatPretty(p.Pageviews)}
    {numbers.formatPretty(p.Visitors)||"-"}
    )}) :
    Nothing here, yet.
    ; // pagination row: only show when total # of results doesn't fit in one table page const pagination = tableRows.length == state.limit || state.offset >= state.limit ? ( ) : ''; return (
    {props.headers.map((header, i) => { return
    {header}
    })}
    {tableRows} {pagination}
    ) } } export default Table ================================================ FILE: assets/src/js/lib/client.js ================================================ 'use strict'; var Client = {}; Client.request = function(url, args) { args = args || {}; args.credentials = 'same-origin' args.headers = args.headers || {}; args.headers['Accept'] = 'application/json'; if( args.method && args.method === 'POST') { args.headers['Content-Type'] = 'application/json'; if(args.data) { if( typeof(args.data) !== "string") { args.data = JSON.stringify(args.data) } args.body = args.data delete args.data } } // trim leading slash from URL url = (url[0] === '/') ? url.substring(1) : url; return window.fetch(`api/${url}`, args) .then(handleRequestErrors) .then(parseJSON) .then(parseData) } function handleRequestErrors(r) { // if response is not JSON (eg timeout), throw a generic error if (! r.ok && r.headers.get("Content-Type") !== "application/json") { throw { code: "request_error", message: "An error occurred" } } return r } function parseJSON(r) { return r.json() } function parseData(d) { // if JSON response contains an Error property, use that as error code // Message is generic here, so that individual components can set their own specific messages based on the error code if(d.Error) { throw { code: d.Error, message: "An error occurred" } } return d.Data } export default Client ================================================ FILE: assets/src/js/lib/numbers.js ================================================ 'use strict'; const M = 1000000 const K = 1000 const rx = new RegExp('\\.0$'); const commaRx = new RegExp('(\\d+)(\\d{3})'); function formatPretty(num) { let decimals = 0; if (num >= M) { num /= M decimals = 3 - ((Math.round(num) + "").length) || 0; return (num.toFixed(decimals > -1 ? decimals : 0).replace(rx, '') + 'M').replace('.00', ''); } if (num >= (K * 10)) { num /= K decimals = 3 - ((Math.round(num) + "").length) || 0; return num.toFixed(decimals).replace(rx, '') + 'K'; } return formatWithComma(num); } function formatWithComma(nStr) { nStr += ''; if(nStr.length < 4 ) { return nStr; } var x = nStr.split('.'); var x1 = x[0]; var x2 = x.length > 1 ? '.' + x[1] : ''; while (commaRx.test(x1)) { x1 = x1.replace(commaRx, '$1' + ',' + '$2'); } return x1 + x2; } function formatDuration(seconds) { seconds = Math.round(seconds); var date = new Date(null); date.setSeconds(seconds); // specify value for SECONDS here return date.toISOString().substr(14, 5); } function formatPercentage(p) { return Math.round(p*100) + "%"; } export { formatPretty, formatWithComma, formatDuration, formatPercentage } ================================================ FILE: assets/src/js/lib/util.js ================================================ 'use strict'; // convert object to query string function stringifyObject(json) { var keys = Object.keys(json); return '?' + keys.map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(json[k]); }).join('&'); } function randomString(n) { var s = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return Array(n).join().split(',').map(() => s.charAt(Math.floor(Math.random() * s.length))).join(''); } function hashParams() { var params = {}, match, matches = window.location.hash.substring(2).split("&"); for (var i = 0; i < matches.length; i++) { match = matches[i].split('=') params[match[0]] = decodeURIComponent(match[1]); } return params; } export { randomString, stringifyObject, hashParams } ================================================ FILE: assets/src/js/pages/dashboard.js ================================================ 'use strict' import { h, Component } from 'preact'; import LogoutButton from '../components/LogoutButton.js'; import Realtime from '../components/Realtime.js'; import DatePicker from '../components/DatePicker.js'; import Sidebar from '../components/Sidebar.js'; import SiteSwitcher from '../components/SiteSwitcher.js'; import SiteSettings from '../components/SiteSettings.js'; import Gearwheel from '../components/Gearwheel.js'; import Table from '../components/Table.js'; import Chart from '../components/Chart.js'; import { bind } from 'decko'; import Client from '../lib/client.js'; import classNames from 'classnames'; import {hashParams} from "../lib/util"; let defaultSite = {} class Dashboard extends Component { constructor(props) { super(props) let params = hashParams() defaultSite = { id: params.site || window.localStorage.getItem('site_id') || 1, name: "", unsaved: true, }; this.state = { dateRange: [], groupBy: 'day', isPublic: document.cookie.indexOf('auth') < 0, site: defaultSite, sites: [], settingsOpen: false, addingNewSite: false, } } componentDidMount() { this.fetchSites() } @bind fetchSites() { Client.request(`sites`) .then((sites) => { // open site settings when there are no sites yet if(sites.length == 0) { this.showSiteSettings({ id: 1, name: "yoursite.com", unsaved: true }) return; } // if there are sites, use remembered site as selected site let site = sites[0]; let s = sites.find(s => s.id == defaultSite.id); site = s ? s : site; this.setState({ sites: sites, site: site, }) }).catch((e) => { if(e.code === 'unauthorized') { this.props.onLogout() } }) } @bind changeDateRange(s) { this.setState({ dateRange: [ s.startDate, s.endDate ], period: s.period, groupBy: s.groupBy, }) } @bind showSiteSettings(site) { site = site && site.unsaved ? site : this.state.site; this.setState({ settingsOpen: true, site: site, previousSite: this.state.site, }) } @bind closeSiteSettings() { this.setState({ settingsOpen: false, // switch back to previous site if we were showing site settings to add a new site site: this.state.site.unsaved && this.state.previousSite ? this.state.previousSite : this.state.site, }) } @bind changeSelectedSite(site) { let newState = { site: site, } if(!this.state.site.unsaved) { newState.previousSite = this.state.site } this.setState(newState) window.localStorage.setItem('site_id', site.id) } @bind updateSite(site) { let updated = false; let newSites = this.state.sites.map((s) => { if(s.id != site.id) { return s; } updated = true; // replace site in sites array with parameter return site; }) if(!updated) { newSites.push(site); } this.setState({sites: newSites, site: site}) } @bind deleteSite(site) { let newSites = this.state.sites.filter((s) => (s.id != site.id)) let newSelectedSite = newSites.length > 0 ? newSites[0] : defaultSite; this.setState({ sites: newSites, site: newSelectedSite }) } render(props, state) { // only show logout link if this dashboard is not public let logoutMenuItem = state.isPublic ? '' : (
  • ·
  • ); return (
    Want more features and less maintenance? Check out the current version of Fathom Analytics and start your free trial today.
    )} } export default Dashboard ================================================ FILE: assets/src/js/pages/login.js ================================================ 'use strict'; import { h, render, Component } from 'preact'; import LoginForm from '../components/LoginForm.js'; class Login extends Component { render(props, state) { return ( ) } } export default Login ================================================ FILE: assets/src/js/script.js ================================================ 'use strict'; import { h, render, Component } from 'preact' import Login from './pages/login.js' import Dashboard from './pages/dashboard.js' import { bind } from 'decko'; import Client from './lib/client.js'; class App extends Component { constructor(props) { super(props) this.state = { authenticated: document.cookie.indexOf('auth') > -1 } this.fetchAuthStatus() } @bind fetchAuthStatus() { Client.request(`session`) .then((d) => { this.setState({ authenticated: d }) }) } @bind toggleAuth() { this.setState({ authenticated: !this.state.authenticated }) } render(props, state) { // logged-in if( state.authenticated ) { return } // logged-out return } } render(, document.getElementById('root')); ================================================ FILE: assets/src/js/tracker.js ================================================ (function() { 'use strict'; let queue = window.fathom.q || []; let config = { 'siteId': '', 'trackerUrl': '', }; const commands = { "set": set, "trackPageview": trackPageview, "setTrackerUrl": setTrackerUrl, }; function set(key, value) { config[key] = value; } function setTrackerUrl(value) { return set("trackerUrl", value); } // convert object to query string function stringifyObject(obj) { var keys = Object.keys(obj); return '?' + keys.map(function(k) { return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]); }).join('&'); } function getCookie(name) { var cookies = document.cookie ? document.cookie.split('; ') : []; for (var i = 0; i < cookies.length; i++) { var parts = cookies[i].split('='); if (decodeURIComponent(parts[0]) !== name) { continue; } var cookie = parts.slice(1).join('='); return decodeURIComponent(cookie); } return ''; } function setCookie(name, data, args) { name = encodeURIComponent(name); data = encodeURIComponent(String(data)); var str = name + '=' + data; if(args.path) { str += ';path=' + args.path; } if (args.expires) { str += ';expires='+args.expires.toUTCString(); } document.cookie = str + ';SameSite=None;Secure'; } function newVisitorData() { return { isNewVisitor: true, isNewSession: true, pagesViewed: [], previousPageviewId: '', lastSeen: +new Date(), } } function getData() { let thirtyMinsAgo = new Date(); thirtyMinsAgo.setMinutes(thirtyMinsAgo.getMinutes() - 30); let data = getCookie('_fathom'); if(! data) { return newVisitorData(); } try{ data = JSON.parse(data); } catch(e) { console.error(e); return newVisitorData(); } if(data.lastSeen < (+thirtyMinsAgo)) { data.isNewSession = true; } return data; } function findTrackerUrl() { const el = document.getElementById('fathom-script') return el ? el.src.replace('tracker.js', 'collect') : ''; } function trackPageview(vars) { vars = vars || {}; // Respect "Do Not Track" requests if('doNotTrack' in navigator && navigator.doNotTrack === "1") { return; } // ignore prerendered pages if( 'visibilityState' in document && document.visibilityState === 'prerender' ) { return; } // if did not load yet, try again at dom ready event if( document.body === null ) { document.addEventListener("DOMContentLoaded", () => { trackPageview(vars); }) return; } // parse request, use canonical if there is one let req = window.location; // do not track if not served over HTTP or HTTPS (eg from local filesystem) and we're not in an Electron app if(req.host === '' && navigator.userAgent.indexOf("Electron") < 0) { return; } // find canonical URL let canonical = document.querySelector('link[rel="canonical"][href]'); if(canonical) { let a = document.createElement('a'); a.href = canonical.href; // use parsed canonical as location object req = a; } let path = vars.path || ( req.pathname + req.search ); if(!path) { path = '/'; } // determine hostname let hostname = vars.hostname || ( req.protocol + "//" + req.hostname ); // only set referrer if not internal let referrer = vars.referrer || ''; if(document.referrer.indexOf(hostname) < 0) { referrer = document.referrer; } let data = getData(); const d = { pid: data.previousPageviewId || '', p: path, h: hostname, r: referrer, u: data.pagesViewed.indexOf(path) == -1 ? 1 : 0, nv: data.isNewVisitor ? 1 : 0, ns: data.isNewSession ? 1 : 0, sid: config.siteId, }; let url = config.trackerUrl || findTrackerUrl() let img = document.createElement('img'); img.setAttribute('alt', ''); img.setAttribute('aria-hidden', 'true'); img.setAttribute('style', 'position:absolute'); img.src = url + stringifyObject(d); img.addEventListener('load', function() { let midnight = new Date(); midnight.setHours(24); midnight.setMinutes(0); midnight.setSeconds(0); // update data in cookie if( data.pagesViewed.indexOf(path) == -1 ) { data.pagesViewed.push(path); } data.previousPageviewId = d.id; data.isNewVisitor = false; data.isNewSession = false; data.lastSeen = +new Date(); setCookie('_fathom', JSON.stringify(data), { expires: midnight, path: '/' }); // remove tracking img from DOM document.body.removeChild(img) }); // in case img.onload never fires, remove img after 1s & reset src attribute to cancel request window.setTimeout(() => { if(!img.parentNode) { return; } img.src = ''; document.body.removeChild(img) }, 1000); // add to DOM to fire request document.body.appendChild(img); } // override global fathom object window.fathom = function() { var args = [].slice.call(arguments); var c = args.shift(); commands[c].apply(this, args); }; // process existing queue queue.forEach((i) => fathom.apply(this, i)); })() ================================================ FILE: docker-compose.yml ================================================ version: "3" services: fathom: image: usefathom/fathom:latest ports: - "8080:8080" environment: - "FATHOM_SERVER_ADDR=:8080" - "FATHOM_GZIP=true" - "FATHOM_DEBUG=false" - "FATHOM_DATABASE_DRIVER=mysql" - "FATHOM_DATABASE_NAME=fathom" - "FATHOM_DATABASE_USER=fathom" - "FATHOM_DATABASE_PASSWORD=password01" - "FATHOM_DATABASE_HOST=mysql:3306" - "FATHOM_SECRET=TWEn6GXQDx45PZfmJWvyGpXf5M8b94bszgw8JcJWEd6WxgrnUkLatS34GwjPTvZb" links: - "mysql:mysql" depends_on: - mysql restart: always mysql: image: "mysql:5" volumes: - ./mysql-data:/var/lib/mysql ports: - "127.0.0.1:3306:3306" environment: - "MYSQL_ALLOW_EMPTY_PASSWORD=false" - "MYSQL_DATABASE=fathom" - "MYSQL_PASSWORD=password01" - "MYSQL_ROOT_PASSWORD=password01" - "MYSQL_USER=fathom" restart: always ================================================ FILE: docs/Configuration.md ================================================ # Configuring Fathom 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. 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. 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. ` fathom --config=/home/john/fathom.env server ` The default configuration looks like this: ``` FATHOM_GZIP=true FATHOM_DEBUG=true FATHOM_DATABASE_DRIVER="sqlite3" FATHOM_DATABASE_NAME="./fathom.db" FATHOM_DATABASE_USER="" FATHOM_DATABASE_PASSWORD="" FATHOM_DATABASE_HOST="" FATHOM_DATABASE_SSLMODE="" FATHOM_SECRET="random-secret-string" ``` ### Accepted values & defaults | Name | Default | Description | :---- | :---| :--- | FATHOM_DEBUG | `false` | If `true` will write more log messages. | FATHOM_SERVER_ADDR | `:8080` | The server address to listen on | FATHOM_GZIP | `false` | if `true` will HTTP content gzipped | FATHOM_DATABASE_DRIVER | `sqlite3` | The database driver to use: `mysql`, `postgres` or `sqlite3` | FATHOM_DATABASE_NAME | | The name of the database to connect to (or path to database file if using sqlite3) | FATHOM_DATABASE_USER | | Database connection user | FATHOM_DATABASE_PASSWORD | | Database connection password | FATHOM_DATABASE_HOST | | Database connection host | 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) | FATHOM_DATABASE_URL | | Can be used to specify the connection string for your database, as an alternative to the previous 5 settings. | FATHOM_SECRET | | Random string, used for signing session cookies ### Common issues ##### Fathom panics when trying to connect to Postgres: `pq: SSL is not enabled on the server` This usually means that you're running Postgres without SSL enabled. Set the `FATHOM_DATABASE_SSLMODE` config option to remedy this. ``` FATHOM_DATABASE_SSLMODE=disable ``` ##### Using `FATHOM_DATABASE_URL` When using `FATHOM_DATABASE_URL` to manually specify your database connection string, there are a few important things to consider. - When using MySQL, include `?parseTime=true&loc=Local` in your DSN. - When using SQLite, include `?_loc=auto` in your DSN. Examples of valid values: ``` FATHOM_DATABASE_DRIVER=mysql FATHOM_DATABASE_URL=root:@tcp/fathom1?loc=Local&parseTime=true ``` ================================================ FILE: docs/FAQ.md ================================================ # Frequently Asked Questions ### How do I install Fathom on my server? Have a look at the [installation instructions](Installation%20instructions.md). --- ### How do I upgrade Fathom to the latest version? 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). --- ### What databases can I use with Fathom? You can use Fathom with either Postgres, MySQL or SQLite. --- ### How to configure Fathom? 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). --- ### How to start tracking pageviews? 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. --- ### What data does Fathom track? Fathom tracks no personally identifiable information on your visitors. 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). If "Do Not Track" is enabled in the browser settings, Fathom respects that. --- ### Fathom is not tracking my pageviews 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. ================================================ FILE: docs/Installation instructions.md ================================================ # Installation instructions for Fathom To install Fathom on your server: 1. [Download the latest Fathom release](https://github.com/usefathom/fathom/releases) suitable for your platform. 2. Extract the archive to `/usr/local/bin` ```sh tar -C /usr/local/bin -xzf fathom_$VERSION_$OS_$ARCH.tar.gz chmod +x /usr/local/bin/fathom ``` Confirm that Fathom is installed properly by running `fathom --version` ```sh $ fathom --version Fathom version 1.0.0 ``` ## Configuring Fathom > This step is optional. By default, Fathom will use a SQLite database file in the current working directory. To run the Fathom web server we will need to [configure Fathom](Configuration.md) so that it can connect with your database of choice. Let's create a new directory where we can store our configuration file & SQLite database. ``` mkdir ~/my-fathom-site cd ~/my-fathom-site ``` Then, create a file named `.env` with the following contents. ``` FATHOM_SERVER_ADDR=9000 FATHOM_GZIP=true FATHOM_DEBUG=true FATHOM_DATABASE_DRIVER="sqlite3" FATHOM_DATABASE_NAME="fathom.db" FATHOM_SECRET="random-secret-string" ``` If you now run `fathom server` then Fathom will start serving up a website on port 9000 using a SQLite database file named `fathom.db`. If that port is exposed then you should now see your Fathom instance running by browsing to `http://server-ip-address-here:9000`. Check out the [configuration file documentation](Configuration.md) for all possible configuration values, eg if you want to use MySQL or Postgres instead. ## Register your admin user > This step is required. To register a user in the Fathom instance we just created, run the following command from the directory where your `.env` file is. ``` fathom user add --email="john@email.com" --password="strong-password" ``` **Note:** if you're running Fathom v1.0.1 or older, the command is `fathom register --email="john@email.com" --password="strong-password"` ## Using NGINX with Fathom We recommend using NGINX with Fathom, as it simplifies running multiple sites from the same server and handling SSL certificates with LetsEncrypt. Create a new file in `/etc/nginx/sites-enabled/my-fathom-site` with the following contents. Replace `my-fathom-site.com` with the domain you would like to use for accessing your Fathom installation. ```sh server { server_name my-fathom-site.com; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $host; proxy_pass http://127.0.0.1:9000; } } ``` Test your NGINX configuration and reload NGINX. ``` nginx -t service nginx reload ``` If you now run `fathom server` again, you should be able to access your Fathom installation by browsing to `http://my-fathom-site.com`. ## Automatically starting Fathom on boot To ensure the Fathom web server keeps running whenever the system reboots, we should use a process manager. Ubuntu 16.04 and later ship with Systemd. Create a new file called `/etc/systemd/system/my-fathom-site.service` with the following contents. Replace `$USER` with your actual username. ``` [Unit] Description=Starts the fathom server Requires=network.target After=network.target [Service] Type=simple User=$USER Restart=always RestartSec=3 WorkingDirectory=/home/$USER/my-fathom-site ExecStart=/usr/local/bin/fathom server [Install] WantedBy=multi-user.target ``` Reload the Systemd configuration & enable our service so that Fathom is automatically started whenever the system boots. ``` systemctl daemon-reload systemctl enable my-fathom-site ``` You should now be able to manually start your Fathom web server by issuing the following command. ``` systemctl start my-fathom-site ``` ## Tracking snippet To start tracking pageviews, copy the tracking snippet shown in your Fathom dashboard to all pages of the website you want to track. ### SSL certificate With [Certbot](https://certbot.eff.org/docs/) for LetsEncrypt installed, adding an SSL certificate to your Fathom installation is as easy as running the following command. ``` certbot --nginx -d my-fathom-site.com ``` ================================================ FILE: docs/README.md ================================================ Welcome to the fathom wiki! How to: * [Install Fathom with our One-Click DigitalOcean installer](DigitalOcean%20One-Click%20Installation%20Instructions.md) * [Installing and running Fathom](Installation%20instructions.md) * [Upgrading Fathom to the latest version](Updating%20to%20the%20latest%20version.md) * [Configuration](Configuration.md) * [Frequently asked questions](FAQ.md) Misc: * [Using Fathom with Systemd](misc/Systemd.md) * [Running Fathom with NGINX](misc/NGINX.md) * [Running Fathom on Heroku](misc/Heroku.md) ================================================ FILE: docs/Updating to the latest version.md ================================================ # Updating Fathom to the latest version 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. ``` mv /usr/local/bin/fathom /usr/local/bin/fathom-old ``` 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`. ``` tar -C /usr/local/bin -xzf fathom_$VERSION_$OS_$ARCH.tar.gz chmod +x /usr/local/bin/fathom ``` If you now run `fathom --version`, you should see that your system is running the latest version. ``` $ fathom --version Fathom version 1.0.0 ``` ### Restarting your Fathom web server To start serving up the updated Fathom web application, you will have to restart the Fathom process that is running the web server. 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. ``` systemctl restart my-fathom-site ``` Alternatively, kill all running Fathom process by issuing the following command. ``` pkill fathom ``` ================================================ FILE: docs/misc/Heroku.md ================================================ # Running Fathom on Heroku ### Requirements * heroku cli (logged in) * git * curl * wget * tar are required * ~ openssl is required to generate the secret_key, but you're free to use what you want ### Create the app First you need to choose a unique app name, as Heroku generates a subdomain for your app. * create the app via the buildpack ```bash heroku create UNIQUE_APP_NAME --buildpack https://github.com/ph3nx/heroku-binary-buildpack.git ``` * locally clone the newly created app ```bash heroku git:clone -a UNIQUE_APP_NAME cd UNIQUE_APP_NAME ``` * create the folder that will contain fathom ```bash mkdir -p bin ``` * download latest version of fathom for linux 64bit ```bash curl -s https://api.github.com/repos/usefathom/fathom/releases/latest \ | grep browser_download_url \ | grep linux_amd64.tar.gz \ | cut -d '"' -f 4 \ | wget -qi - -O- \ | tar --directory bin -xz - fathom ``` * create the Procfile for Heroku ```bash echo "web: bin/fathom server" > Procfile ``` * create a Postgres database (you can change the type of plan if you want - https://elements.heroku.com/addons/heroku-postgresql#pricing) ```bash heroku addons:create heroku-postgresql:hobby-dev ``` * update the environment variables, generate a secret_key here you can change the way you generate your secret_key. ```bash heroku config:set PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app/bin \ FATHOM_DATABASE_DRIVER=postgres \ FATHOM_DATABASE_URL=$(heroku config:get DATABASE_URL) \ FATHOM_DEBUG=true \ FATHOM_SECRET=$(openssl rand -base64 32) \ FATHOM_GZIP=true ``` * add, commit and push all our files ```bash git add --all git commit -m "First Commit" git push heroku master ``` * the created app runs as a free-tier. A free-tier dyno uses the account-based pool 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 ```bash heroku dyno:resize hobby ``` * check that everything is working ```bash heroku run fathom --version ``` * add the first user ```bash heroku run fathom user add --email="test@test.com" --password="test_password" ``` * open the browser to login and add your first website ```bash heroku open ``` * ENJOY :) ================================================ FILE: docs/misc/NGINX.md ================================================ # Using NGINX with Fathom Let's say you have the Fathom server listening on port 9000 and want to serve it on your domain, `yourfathom.com`. 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. Create the following file in `/etc/nginx/sites-enabled/yourfathom.com` ``` server { server_name yourfathom.com; location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $host; proxy_pass http://127.0.0.1:9000; } } ``` 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). ``` certbot certonly --webroot --webroot-path /var/www/yourfathom.com -d yourfathom.com ``` Your `/etc/nginx/sites-enabled/yourfathom.com` file should be updated accordingly: ``` server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name yourfathom.com; ssl_certificate /path/to/your/fullchain.pem; ssl_certificate_key /path/to/your/privkey.pem; location /.well-known { alias /var/www/yourfathom.com/.well-known; } location / { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $host; proxy_pass http://127.0.0.1:9000; } } ``` The `alias` directive should point to the location where your `--webroot-path` is specified when generating the certificate (with `/.well-known` appended). ### Test NGINX configuration ``` sudo nginx -t ``` ### Reload NGINX configuration ``` sudo service nginx reload ``` ================================================ FILE: docs/misc/Systemd.md ================================================ # Managing the Fathom process with Systemd 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. Then, create a new service config file in the `/etc/systemd/system/` directory. Example file: `/etc/systemd/system/fathom.service` The file should have the following contents, with `$USER` substituted with your actual username. ``` [Unit] Description=Starts the fathom server Requires=network.target After=network.target [Service] Type=simple User=$USER Restart=always RestartSec=6 WorkingDirectory=/etc/fathom # (or where fathom should store its files) ExecStart=fathom server [Install] WantedBy=multi-user.target ``` Save the file and run `sudo systemctl daemon-reload` to load the changes from disk. Then, run `sudo systemctl enable fathom` to start the service whenever the system boots. ### Starting or stopping the Fathom service manually ``` sudo systemctl start fathom sudo systemctl stop fathom ``` ### Using a custom configuration file 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. For example, if you have a configuration file `/home/john/fathom.env` then the line should look like this: ``` ExecStart=fathom --config=/home/john/fathom.env server --addr=:9000 ``` #### Start Fathom automatically at boot ``` sudo systemctl enable fathom ``` #### Stop Fathom from starting at boot ``` sudo systemctl disable fathom ``` ================================================ FILE: go.mod ================================================ module github.com/usefathom/fathom go 1.19 require ( github.com/go-sql-driver/mysql v1.6.0 github.com/gobuffalo/packr/v2 v2.8.3 github.com/google/uuid v1.3.0 github.com/gorilla/context v1.1.1 github.com/gorilla/handlers v1.5.1 github.com/gorilla/mux v1.8.0 github.com/gorilla/sessions v1.2.1 github.com/jmoiron/sqlx v1.3.5 github.com/joho/godotenv v1.4.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/lib/pq v1.10.7 github.com/mattn/go-sqlite3 v1.14.16 github.com/mssola/user_agent v0.5.3 github.com/rubenv/sql-migrate v1.2.0 github.com/sirupsen/logrus v1.9.0 github.com/urfave/cli v1.22.10 golang.org/x/crypto v0.3.0 ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/felixge/httpsnoop v1.0.1 // indirect github.com/go-gorp/gorp/v3 v3.0.2 // indirect github.com/gobuffalo/logger v1.0.6 // indirect github.com/gobuffalo/packd v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2-0.20180608144417-78f3d318a8bf // indirect github.com/karrick/godirwalk v1.16.1 // indirect github.com/markbates/errx v1.1.0 // indirect github.com/markbates/oncer v1.0.0 // indirect github.com/markbates/safe v1.0.1 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/ziutek/mymysql v1.5.5-0.20171217234033-ff6cc86d3d93 // indirect golang.org/x/net v0.2.0 // indirect golang.org/x/sys v0.2.0 // indirect golang.org/x/term v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig/v3 v3.2.0/go.mod h1:tWhwTbUTndesPNeF0C900vKoq283u6zp4APT9vaF3SI= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gorp/gorp/v3 v3.0.2 h1:ULqJXIekoqMx29FI5ekXXFoH1dT2Vc8UhnRzBg+Emz4= github.com/go-gorp/gorp/v3 v3.0.2/go.mod h1:BJ3q1ejpV8cVALtcXvXaXyTOlMmJhWDxTmncaR6rwBY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= github.com/gobuffalo/logger v1.0.6/go.mod h1:J31TBEHR1QLV2683OXTAItYIg8pv2JMHnF/quuAbMjs= github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0= github.com/gobuffalo/packd v1.0.1/go.mod h1:PP2POP3p3RXGz7Jh6eYEf93S7vA2za6xM7QT85L4+VY= github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= github.com/gobuffalo/packr/v2 v2.8.3/go.mod h1:0SahksCVcx4IMnigTjiFuyldmTrdTctXsOdiU5KwbKc= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godror/godror v0.24.2/go.mod h1:wZv/9vPiUib6tkoDl+AZ/QLf5YZgMravZ7jxH2eQWAE= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.2-0.20180608144417-78f3d318a8bf h1:tgJ+TrHb8GE+aKrvU3dWpVEByJtj9/i7QRMVtX1XMmc= github.com/gorilla/securecookie v1.1.2-0.20180608144417-78f3d318a8bf/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kortschak/utter v1.0.1/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-oci8 v0.1.1/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.1.4/go.mod h1:vTLESy5mRhKOs9KDp0/RATawxP1UqBmdrpVRMnpcvKQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mssola/user_agent v0.5.3 h1:lBRPML9mdFuIZgI2cmlQ+atbpJdLdeVl2IDodjBR578= github.com/mssola/user_agent v0.5.3/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1 h1:oL4IBbcqwhhNWh31bjOX8C/OCy0zs9906d/VUru+bqg= github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rubenv/sql-migrate v1.2.0 h1:fOXMPLMd41sK7Tg75SXDec15k3zg5WNV6SjuDRiNfcU= github.com/rubenv/sql-migrate v1.2.0/go.mod h1:Z5uVnq7vrIrPmHbVFfR4YLHRZquxeHpckCnRq0P/K9Y= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/urfave/cli v1.22.10 h1:p8Fspmz3iTctJstry1PYS3HVdllxnEzTEsgIgtxTrCk= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= github.com/ziutek/mymysql v1.5.5-0.20171217234033-ff6cc86d3d93 h1:neEPpeeJDl7Rs0FRhSyk0SloWCRoNdwn4wB6nD4eP7Q= github.com/ziutek/mymysql v1.5.5-0.20171217234033-ff6cc86d3d93/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= ================================================ FILE: gulpfile.js ================================================ 'use strict'; const browserify = require('browserify') const gulp = require('gulp') const source = require('vinyl-source-stream') const buffer = require('vinyl-buffer') const uglify = require('gulp-uglify') const babel = require('gulp-babel'); const cachebust = require('gulp-cache-bust'); const concat = require('gulp-concat'); const gulpif = require('gulp-if') const debug = process.env.NODE_ENV !== 'production'; gulp.task('app-js', function () { return browserify({ entries: './assets/src/js/script.js', debug: debug, ignoreMissing: true, }) .transform("babelify", { presets: ["@babel/preset-env"], plugins: [ ["@babel/plugin-proposal-decorators", { "legacy": true }], ["@babel/plugin-transform-react-jsx", { "pragma":"h" } ] ] }) .bundle() .pipe(source('script.js')) .pipe(buffer()) .pipe(gulpif(!debug, uglify())) .pipe(gulp.dest(`./assets/build/js`)) }); gulp.task('tracker-js', function () { return gulp.src('./assets/src/js/tracker.js') .pipe(babel({ presets: ["@babel/preset-env"], })) .pipe(gulpif(!debug, uglify())) .pipe(gulp.dest('./assets/build/js')); }); gulp.task('fonts', function() { return gulp.src('./assets/src/fonts/**/*') .pipe(gulp.dest(`./assets/build/fonts`)) }); gulp.task('img', function() { return gulp.src('./assets/src/img/**/*') .pipe(gulp.dest(`./assets/build/img`)) }); gulp.task('html', function() { return gulp.src('./assets/src/**/*.html') .pipe(cachebust({ type: 'timestamp' })) .pipe(gulp.dest(`./assets/build/`)) }); gulp.task('css', function () { return gulp.src('./assets/src/css/*.css') .pipe(concat('styles.css')) .pipe(gulp.dest(`./assets/build/css`)) }); gulp.task('default', gulp.series('app-js', 'tracker-js', 'css', 'html', 'img', 'fonts' ) ); gulp.task('watch', gulp.series('default', function() { gulp.watch(['./assets/src/js/**/*.js'], gulp.parallel('app-js', 'tracker-js') ); gulp.watch(['./assets/src/css/**/*.css'], gulp.parallel( 'css') ); gulp.watch(['./assets/src/**/*.html'], gulp.parallel( 'html') ); gulp.watch(['./assets/src/img/**/*'], gulp.parallel( 'img') ); gulp.watch(['./assets/src/fonts/**/*'], gulp.parallel( 'fonts') ); })); ================================================ FILE: main.go ================================================ package main import ( "fmt" "os" "github.com/usefathom/fathom/pkg/cli" ) var ( version = "dev" commit = "none" date = "unknown" ) func main() { err := cli.Run(version, commit, date) if err != nil { fmt.Print(err) os.Exit(1) } os.Exit(0) } ================================================ FILE: package.json ================================================ { "license": "MIT", "repository": { "type": "git", "url": "https://github.com/usefathom/fathom.git" }, "devDependencies": { "@babel/core": "^7.0.1", "@babel/plugin-proposal-decorators": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.0.0", "@babel/preset-env": "^7.0.0", "babelify": "^10.0.0", "browserify": "^16.2.0", "gulp": "^4.0.0", "gulp-babel": "^8.0.0", "gulp-cache-bust": "^1.4.0", "gulp-concat": "^2.6.1", "gulp-if": "^2.0.2", "gulp-uglify": "^3.0.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^2.0.0" }, "dependencies": { "classnames": "^2.2.6", "d3": "^5.7.0", "d3-tip": "^0.9.1", "d3-transition": "^1.1.3", "decko": "^1.2.0", "pikaday": "^1.8.0", "preact": "^8.3.1" } } ================================================ FILE: pkg/aggregator/aggregator.go ================================================ package aggregator import ( "errors" "net/url" "strings" "time" "github.com/usefathom/fathom/pkg/datastore" "github.com/usefathom/fathom/pkg/models" log "github.com/sirupsen/logrus" ) type Aggregator struct { database datastore.Datastore } type Report struct { Processed int PoolEmpty bool Duration time.Duration } type results struct { Sites map[string]*models.SiteStats Pages map[string]*models.PageStats Referrers map[string]*models.ReferrerStats } // New returns a new aggregator instance with the database dependency injected. func New(db datastore.Datastore) *Aggregator { return &Aggregator{ database: db, } } // Run processes the pageviews which are ready to be processed and adds them to daily aggregation func (agg *Aggregator) Run() Report { startTime := time.Now() // Get unprocessed pageviews limit := 10000 pageviews, err := agg.database.GetProcessablePageviews(limit) emptyReport := Report{ Processed: 0, PoolEmpty: true, } if err != nil && err != datastore.ErrNoResults { log.Error(err) return emptyReport } // Do we have anything to process? n := len(pageviews) if n == 0 { return emptyReport } results := &results{ Sites: map[string]*models.SiteStats{}, Pages: map[string]*models.PageStats{}, Referrers: map[string]*models.ReferrerStats{}, } sites, err := agg.database.GetSites() if err != nil { log.Error(err) return emptyReport } // create map of public tracking ID's => site ID trackingIDMap := make(map[string]int64, len(sites)+1) for _, s := range sites { trackingIDMap[s.TrackingID] = s.ID } // if no explicit site ID was given in the tracking request, default to site with ID 1 trackingIDMap[""] = 1 // setup referrer spam blacklist blacklist, err := newBlacklist() if err != nil { log.Error(err) return emptyReport } // add each pageview to the various statistics we gather for _, p := range pageviews { // discard pageview if site tracking ID is unknown siteID, ok := trackingIDMap[p.SiteTrackingID] if !ok { log.Debugf("Skipping pageview because of unrecognized site tracking ID %s", p.SiteTrackingID) continue } // start with referrer because we may want to skip this pageview altogether if it is referrer spam if p.Referrer != "" { ref, err := parseReferrer(p.Referrer) if err != nil { log.Debugf("Skipping pageview from referrer %s because of malformed referrer URL", p.Referrer) continue } // ignore out pageviews from blacklisted referrers // we use Hostname() here to discard port numbers if blacklist.Has(ref.Hostname()) { log.Debugf("Skipping pageview from referrer %s because of blacklist", p.Referrer) continue } hostname := ref.Scheme + "://" + ref.Host referrerStats, err := agg.getReferrerStats(results, siteID, p.Timestamp, hostname, ref.Path) if err != nil { log.Error(err) continue } referrerStats.HandlePageview(p) } // get existing site stats so we can add this pageview to it site, err := agg.getSiteStats(results, siteID, p.Timestamp) if err != nil { log.Error(err) continue } site.HandlePageview(p) pageStats, err := agg.getPageStats(results, siteID, p.Timestamp, p.Hostname, p.Pathname) if err != nil { log.Error(err) continue } pageStats.HandlePageview(p) } // update stats for _, site := range results.Sites { if err := agg.database.SaveSiteStats(site); err != nil { log.Error(err) } } for _, pageStats := range results.Pages { if err := agg.database.SavePageStats(pageStats); err != nil { log.Error(err) } } for _, referrerStats := range results.Referrers { if err := agg.database.SaveReferrerStats(referrerStats); err != nil { log.Error(err) } } // finally, remove pageviews that we just processed if err := agg.database.DeletePageviews(pageviews); err != nil { log.Error(err) } endTime := time.Now() dur := endTime.Sub(startTime) report := Report{ Processed: n, PoolEmpty: n < limit, Duration: dur, } log.Debugf("processed %d pageviews. took: %s, pool empty: %v", report.Processed, report.Duration, report.PoolEmpty) return report } // parseReferrer parses the referrer string & normalizes it func parseReferrer(r string) (*url.URL, error) { u, err := url.Parse(r) if err != nil { return nil, err } // always require a hostname if u.Host == "" { return nil, errors.New("malformed URL, empty host") } // remove AMP & UTM vars if u.RawQuery != "" { q := u.Query() keys := []string{"amp", "utm_campaign", "utm_medium", "utm_source"} for _, k := range keys { q.Del(k) } u.RawQuery = q.Encode() } // remove amp/ suffix (but keep trailing slash) if strings.HasSuffix(u.Path, "/amp/") { u.Path = u.Path[0:(len(u.Path) - 4)] } // re-parse our normalized string into a new URL struct return url.Parse(u.String()) } ================================================ FILE: pkg/aggregator/aggregator_test.go ================================================ package aggregator import ( "net/url" "testing" ) func TestParseReferrer(t *testing.T) { testsValid := map[string]*url.URL{ "https://www.usefathom.com/?utm_source=github": &url.URL{ Scheme: "https", Host: "www.usefathom.com", Path: "/", }, "https://www.usefathom.com/privacy/amp/?utm_source=github": &url.URL{ Scheme: "https", Host: "www.usefathom.com", Path: "/privacy/", }, } testsErr := []string{ "mysite.com", "foobar", "", } for r, e := range testsValid { v, err := parseReferrer(r) if err != nil { t.Error(err) } if v.Host != e.Host { t.Errorf("Invalid Host: expected %s, got %s", e.Host, v.Host) } if v.Scheme != e.Scheme { t.Errorf("Invalid Scheme: expected %s, got %s", e.Scheme, v.Scheme) } if v.Path != e.Path { t.Errorf("Invalid Path: expected %s, got %s", e.Path, v.Path) } } for _, r := range testsErr { v, err := parseReferrer(r) if err == nil { t.Errorf("Expected err, got %#v", v) } } } ================================================ FILE: pkg/aggregator/bindata.go ================================================ // Code generated by go-bindata. // sources: // pkg/aggregator/data/blacklist.txt // DO NOT EDIT! package aggregator import ( "bytes" "compress/gzip" "fmt" "io" "io/ioutil" "os" "path/filepath" "strings" "time" ) func bindataRead(data []byte, name string) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewBuffer(data)) if err != nil { return nil, fmt.Errorf("Read %q: %v", name, err) } var buf bytes.Buffer _, err = io.Copy(&buf, gz) clErr := gz.Close() if err != nil { return nil, fmt.Errorf("Read %q: %v", name, err) } if clErr != nil { return nil, err } return buf.Bytes(), nil } type asset struct { bytes []byte info os.FileInfo } type bindataFileInfo struct { name string size int64 mode os.FileMode modTime time.Time } func (fi bindataFileInfo) Name() string { return fi.name } func (fi bindataFileInfo) Size() int64 { return fi.size } func (fi bindataFileInfo) Mode() os.FileMode { return fi.mode } func (fi bindataFileInfo) ModTime() time.Time { return fi.modTime } func (fi bindataFileInfo) IsDir() bool { return false } func (fi bindataFileInfo) Sys() interface{} { return nil } var _blacklistTxt = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x84\x5b\xfb\x72\xe3\xae\xaf\xff\x3f\xef\x42\xa6\xed\x76\x2f\xdf\xc7\x91\x41\xb6\x55\x2e\x62\xb9\x38\x71\x9e\xfe\x8c\x00\xa7\xe9\xb6\xa7\xbf\x9d\xe9\x2c\xba\x18\x63\x01\xe2\x23\x89\x3c\xfd\xc0\x33\x85\x99\x4f\x4f\x41\x39\x0a\x78\x2e\xdb\xe9\x59\xfd\xf7\x5f\x46\x3e\x6b\xf6\xa7\x67\x35\x27\x44\x95\x57\x48\xa8\xa6\x5a\x0a\x87\xdc\x05\x4f\x4f\x86\x9d\x83\x94\xd5\x5d\xf7\xe9\x29\x23\x24\xbd\x62\x58\x28\xe0\xd0\x7b\xf1\x90\x0b\x26\xde\x06\x59\xfb\xfb\x9e\x23\x78\x7f\x4e\xf5\xf4\x7c\xc1\xa9\xab\x9c\xbd\x3b\xbd\xbc\x5e\x7f\xab\x8c\x69\xc3\xa4\x72\x8d\x91\x53\x39\x67\x2a\x78\x7a\xd9\xb9\xa6\xde\xfc\xa1\x1c\x96\x82\x49\x19\xf6\x40\x21\x9f\x03\x96\xd3\x8f\x0b\xec\x01\xcb\x85\x93\xed\x2f\x7e\xa5\x10\xa4\xff\x57\xca\x85\xf3\xca\xb1\x73\xef\x6f\xcb\x67\x4e\xcb\xe9\xa7\xca\x05\x63\x56\x85\x55\x2e\x90\x8a\x9a\x6a\x96\xb1\xf7\x3e\x7e\xce\x9c\xf0\x2a\xbd\xfc\x5a\x39\x46\x0a\x4b\x63\xff\xb6\x1c\x85\xf9\xdb\x83\x45\xcf\x01\x77\x0e\xcd\x78\x4d\x78\x83\xfe\x2a\x98\xb4\xc1\x79\x3d\x5f\xf7\x5b\x6f\x2f\x47\xd3\x41\x0d\x7a\xc5\xd4\xd5\x34\x98\xfe\x09\xa0\x21\x25\x64\xe9\x19\x74\x0d\x58\xe8\xaa\x12\xce\x98\xd2\xa1\x6a\x20\xd0\x3a\x9a\x1a\xf2\xd1\x9c\xcf\x6e\x3f\x81\xc9\x11\x52\x19\xac\x42\x0b\xa6\x73\xb1\x27\x30\x1b\x86\x52\x13\x46\x48\x56\xb3\x7c\x24\x69\x18\x5a\x1b\x69\x9c\x39\x55\xdf\xe7\x04\xcc\xc6\x16\xca\x31\x50\x4c\x6c\xe8\x06\x14\xba\xf6\x3c\x73\x32\x30\x39\xbc\xe0\x24\xf3\x90\x21\x18\xcf\x13\x39\x84\x18\xf3\xd0\xe1\x04\x6d\xfc\x34\xb1\xa3\x32\xde\x43\x06\x92\xc7\x09\xdd\xd9\xde\x4e\x60\xeb\x1a\xe8\x3c\xed\x27\x70\x33\x4c\x5c\xfa\xcb\x1c\x4d\x98\x4b\x06\xd7\xad\x08\x8e\xf0\xda\x28\xe9\xcd\x51\x80\x09\x02\xa1\xb6\x70\x8e\xee\x04\xce\x42\x98\x21\x79\x68\x93\x08\xce\x61\x5a\x48\xdb\xf1\x68\xa3\xde\xe0\xa0\x6c\xe0\xcb\xf8\x40\xe7\x3c\x24\x8b\x25\x07\xbc\x18\xd8\x1d\x9d\x17\x13\x84\x1d\xf0\x92\xcf\xde\x1c\xcd\x97\xd7\x33\x35\xfe\x85\x3d\x86\xfb\xc3\x97\x44\xcb\x2a\x2b\xb5\x0d\x2a\xae\x6d\x04\x6d\xea\x5c\xc1\xe4\xe9\x7a\xae\x70\x02\x0f\x37\x0e\xb2\x2b\xda\x42\x26\x3d\xbe\xc8\x17\x65\xdb\x83\xbe\xa0\x53\x5b\x42\x83\xb9\xe0\x61\xdd\x00\x4e\x81\x4e\x3c\x41\xc9\xe7\x95\xdd\x19\x73\x63\xee\x85\x74\x56\xb2\x44\x9a\x99\x02\x44\x50\x14\x42\x6e\x3d\x05\x93\x98\x8c\xca\x65\x3f\xcc\x16\xc8\xcb\xc0\xb8\xf0\xf1\x00\x79\x94\xbf\xae\x5f\x48\x27\xb8\x38\x4c\xdd\x6e\xa1\xd0\x52\x61\x82\x34\x55\xd3\xe7\x2d\x16\xb4\xa0\xda\x97\x35\x3a\x61\x30\x60\x37\x48\x85\x52\x9b\x3d\x61\xf0\x26\x13\xd0\xfb\x4f\xd6\xd2\xc6\xae\x14\xea\x86\x48\xc5\xa0\xe5\x61\xb1\x54\x22\x50\x28\xaa\x1b\xbd\x77\x58\x22\xa4\xbf\xf5\xa0\x6a\x74\x62\xd2\x63\x67\x41\xb6\x6a\xaf\xa3\x5d\x1c\x16\xd2\x7d\x2c\x6d\xb8\x65\xb7\xfd\xbb\x6b\x61\xa5\xd9\x47\x87\xd7\xb6\x90\x84\xb6\x04\x6a\xae\xce\x99\x44\x1b\xde\xb5\x1e\x67\xa1\xf5\x51\x0b\x4f\x8e\x17\x21\xda\x64\xd5\xc2\x5f\xe9\x08\xaf\x24\x98\x67\xd2\x7d\x30\xb5\xf0\x46\x06\x79\x4a\xdc\xf6\xde\xd8\x68\x1b\x6d\xa0\x1c\x79\xae\xd7\xc1\xb0\x37\x48\xb2\xac\xd9\x0e\x23\x6c\x85\x29\x14\x4c\xd8\xc7\xbe\x15\xde\x76\x5b\x63\xb3\xe6\x0d\x52\xd1\xae\x4e\xfd\xbd\xb7\xa9\x5a\x10\x7f\xdb\xbb\xba\xc9\xe7\xd5\xdb\x69\x02\xba\x42\x52\xbe\x66\xd2\x90\xd5\x92\xa0\x50\xdf\x6f\x13\x38\x30\xb5\x2d\xc7\x09\x1c\x15\xae\x89\x65\xc8\x78\x48\x3f\x6c\x93\x09\x92\x51\x09\xc1\x89\x54\x3e\x7e\x82\x52\x8b\x9a\x6b\xe8\x1d\x6c\x90\x08\x16\x32\x67\x83\xa7\x09\x41\xaf\x85\x0d\xec\x4d\x86\x26\x31\xfb\xfb\x54\xd5\x7c\x9a\x30\xa1\xc7\xb0\x43\x5e\x6d\xdf\x69\xb2\x7f\x95\x41\x70\x6a\x35\xe6\x1c\x13\x77\x8e\x78\xcd\xc3\xbc\xaa\x66\x38\x4f\xae\x62\x17\x89\x91\x79\x9e\x87\x77\xbb\xb3\x32\xcf\xe5\x02\x09\xdb\x02\x7b\xe0\xba\x5a\x88\xc3\x5d\xb7\xf9\x1e\x2a\x7b\xd6\xcc\xe2\xcf\xfb\x68\x0f\x69\xeb\x77\x35\x66\xda\x4b\x9f\x87\x47\x26\xa2\x79\x67\xde\xdd\xd9\x05\x92\x19\x76\x6d\xe7\xcb\x8a\xe0\xca\x3a\x21\xd4\x72\xf4\x7b\x8b\x49\x5c\x68\x39\x5c\xdb\x44\xb3\x4a\xe3\x44\x9b\x68\x71\x72\xd6\xcc\x47\x87\x43\xc3\x39\x12\xc3\x6b\x07\x39\xf7\xd5\xd4\x6c\x4f\x7c\x6c\x0b\x7b\x3b\x4d\x14\x1d\x04\xd4\x18\x4a\x6a\x8b\x77\xa2\x64\x9e\x7b\xe3\xf0\xc7\x13\xdd\xee\xaf\x72\xa0\xad\x9a\x13\xc9\x17\x2f\xd0\xe9\x15\xe4\xf4\x2b\xfd\x50\x90\x35\xfe\xfc\xf4\xd4\xe7\xdd\xf1\xf2\x5a\xcf\x85\x63\x6b\xca\x11\x70\x9e\x6b\x68\x44\xe1\x02\xae\x4d\xb8\xab\xa8\xf2\xdf\x2a\x76\x9f\xe8\xd6\xe8\xc4\xe2\x9e\xfb\x1b\xd9\x15\x70\x96\xfb\x48\x98\x73\xf1\x7b\x8c\x7d\x6f\x4c\x31\xf1\x73\xef\x3e\x81\xc5\x15\x2e\xdd\x11\x4f\xe9\x7e\x96\x4c\x09\xc1\xaa\xb2\xa2\xd2\x6b\x3b\xb2\xbb\xbc\xd9\x26\x94\x7d\xbc\x23\x59\x95\x0a\xb5\xaf\x4e\x5c\x56\x14\x6c\xe1\x9b\x3b\xef\xbc\x9a\xc9\xf1\xd6\xda\x19\x9d\x6b\x8d\x6a\xc8\x85\xe3\x2d\xd5\x78\xd9\x5f\x1e\x0a\xf5\x45\xd9\x2c\x5d\x67\x2c\x5c\x4b\x57\xb7\x78\x1f\x53\xb5\xee\x81\x48\x0b\x26\x45\x3e\x62\xa2\x31\xb7\x35\x05\x35\x43\x69\x06\xee\xc8\x47\xcd\x9c\xd4\x98\xde\xa1\xf3\xce\x17\x80\xf2\x8f\x70\x57\x7a\x45\x88\x6a\xc0\x83\xfe\x95\x77\x6e\x24\xe7\xb2\xe2\x64\x30\xa9\x07\x00\x21\x0a\xfd\x50\x6e\x03\xde\x95\x47\x23\x07\x92\xc8\x34\x38\xa7\x78\x56\xa6\x1e\x46\xd3\x90\x0c\x71\x6e\x40\x69\x7c\xb0\x86\x54\x50\xaf\x81\xb4\xf4\xa0\x31\xb0\xe5\x7c\x34\x37\x70\xbd\x29\xae\x07\x26\xd0\xe7\xc4\x27\xbd\xea\xda\xdc\x77\x1f\x58\x49\x35\x17\x34\x6a\x02\x6d\x1d\x85\x01\xa8\xf4\x8a\x6e\x87\x89\x42\xb6\x67\x43\x18\xa0\xcd\x94\x5e\x05\x2e\xa1\x02\x8f\xb7\xb1\x39\xf5\x4a\xb7\x95\xac\x7a\x69\x72\x6a\x43\xa2\xb2\x0b\x40\xa1\xee\x25\x35\x6d\xe4\x2e\x90\xca\x8a\x50\xc6\xf6\xd7\x0e\x21\x50\x58\x86\xab\xc8\x67\x4b\xb8\xb5\x67\x1d\x69\x9b\xf9\xd0\x22\x0f\x05\xc5\xeb\x8b\xdf\x54\xae\x5a\x7e\xeb\x5f\xc4\x46\x9c\x00\x0c\xe3\x6b\x36\x7b\x9e\xa6\xbf\x83\x98\x67\x44\x0f\x79\xa5\xb0\x77\x65\x57\xfd\x24\x9f\xdc\xde\xc0\xde\x63\x82\x05\xbb\xc8\x47\x15\xd9\x73\xd6\xeb\x41\xcb\x2a\xd5\xa8\x40\x1c\xb2\xac\x96\x7f\x78\xf0\x89\x19\x4c\xc2\xcb\xbf\xdc\x09\x12\xd8\x4f\xcc\x44\x10\xfe\x65\x1a\xfe\x8a\x05\xce\xfc\xcb\x45\x87\xe1\xd3\xdb\xe7\x84\x9f\x14\x17\xe4\xb4\xe0\xbf\x5c\x4a\x1b\x7d\x7a\x13\x6d\x9f\x07\xf4\xc6\x6b\x68\x3b\xfc\x91\x57\xa5\x11\xe0\xce\xaf\x82\xc9\x93\x00\xe2\xd2\x0d\x17\x34\x61\x5a\x70\x49\x5c\x63\xf3\x44\x9a\x43\x40\x2d\x9b\x33\x97\x6a\x88\xc7\xe4\xb0\x25\x54\x0e\x2e\x0a\xc3\xcc\x49\xcb\xb9\x52\x14\x1c\xdf\xf5\xa5\x74\x9a\xbe\x93\x6a\xfd\x9d\xd4\x98\xef\xa4\x88\xdf\x49\xe7\xf9\x3b\xe9\xb2\x7c\x27\x5d\xd7\xef\xa4\x44\xdf\x49\xdf\xde\xbe\x93\x5a\xfb\x9d\xd4\xb9\xef\xa4\xde\x7f\x27\x0d\xe1\x3b\x29\xf3\x77\xd2\x18\xbf\x93\xfe\xfd\xfb\x9d\x34\xa5\xef\xa4\x39\x7f\x27\x2d\xe5\x3b\x69\xad\xdf\x49\xb7\xed\x3b\xe9\xe5\xf2\x9d\xf4\x7a\xfd\x4e\xba\xef\xdf\x49\x6f\xb7\x21\x8d\x7b\x8b\x31\xb4\x03\xf2\x79\x6c\x9a\xc1\xa3\x90\x0b\x95\x5a\x70\xb0\x37\x30\x2b\xe7\x06\xc8\xe4\xc4\xd6\xb1\x1f\x12\xcd\xa3\xd5\x89\xd9\x9e\x73\x8d\x98\x02\x5e\xfa\x03\x35\x17\xf6\xb9\xde\x0f\x46\xbd\x4f\x98\x94\xe7\x30\x50\x84\x01\x72\x7b\x82\x60\xdb\x51\x60\x20\xb1\x81\xee\x75\x8d\xc4\x0c\x81\xba\x67\x35\xfd\xd0\xeb\x4a\xda\x68\x88\x54\x3a\xa6\x3c\x19\x94\x10\xfa\xf9\x87\x6c\x7f\x83\x6e\xa6\xa0\xe0\xef\xfb\x0b\x8d\x7c\x2b\x2c\x58\x47\xaf\x18\x16\x52\x9b\xb2\x09\x0d\x09\xce\xe8\x3a\xc9\x43\xe1\x0d\x03\x26\x76\xbc\xd0\x0e\x43\x39\x6b\x48\xcb\x1d\x07\x1f\x30\xb8\x8d\x02\x4b\x16\xa3\x5a\x0e\xb9\xa4\x6a\x0b\xa7\xbd\x0f\x61\x6b\xc9\x09\x01\x3a\xa7\xf7\x23\xcb\x50\x74\xec\x21\x0b\x72\x6b\x5d\x53\x6c\x98\x48\x8c\x64\x04\xc1\x85\x23\x78\x34\x24\x98\x3f\x98\x16\x93\x9b\x37\xb4\x57\xe8\x2d\x0e\x17\x28\xfd\x7c\x30\xb6\x34\x78\x62\x58\xe7\x57\x70\xc3\x10\xac\x33\x24\xbd\x4a\x28\xd2\x46\xc8\xba\x1d\xd1\xe0\x0e\xb2\xca\xb4\x4f\x90\xf1\x03\xa3\x67\x3e\x3e\xb2\x04\x4e\x74\xc6\x92\x53\x1d\x76\xe7\x37\x4b\x6a\x35\xe3\x65\x1e\x28\x48\xb8\xa2\xed\x38\x4c\x85\x95\xd7\x40\xbb\x5a\xb9\xa0\xeb\xe3\xf3\x14\xa0\x60\x4f\x69\x78\x77\x30\x04\x52\x37\x2a\x5b\x50\x5b\x46\xdf\x95\x73\x81\x4d\x18\xca\xa6\xdd\x8f\x3e\x73\x5d\x04\xfe\xf2\xd6\xf3\x30\x26\xd5\x38\xec\xb4\x25\x59\x82\x32\x7f\xa8\xa6\xba\x23\xe4\x8e\x98\x51\xf5\xb3\x55\xe3\x3d\x4b\xf4\x91\xf5\xdc\x79\x20\x50\x2b\xb1\x57\x12\x68\x6a\x37\xb0\xb3\xb0\x09\x82\x6a\x29\x96\x0e\x77\xa4\xe7\xe3\xf9\xb3\x9e\x4f\xf8\xa1\x7f\x99\x41\xe1\xc4\xb6\x02\x51\x73\x60\x19\xfb\x09\x0d\xd8\x65\xde\x2e\x7f\x9b\x25\xd0\x54\x99\x8f\x66\x48\x34\x55\xfa\xbd\x63\x36\x34\xf5\x61\x0e\x70\x11\xbc\x84\x7c\xa6\x72\x42\xab\x28\x6c\x98\xdb\xd9\x86\x16\x0a\x48\x18\x29\x9b\x16\x2d\xab\x45\x62\xfe\x2e\xe1\x98\x18\x6d\x51\x36\x75\xba\xf0\x19\xf1\x84\x0e\x6d\x49\x64\x79\xa3\xbe\x6e\xd0\xb5\x9d\x9f\xa3\xab\x39\x0f\x46\x28\x7d\x6b\x34\x43\x3a\x4f\x33\xa4\x15\xc2\x42\x7d\x68\x6e\xc3\x7b\xe8\x86\x1e\x53\xe6\xa0\x52\xed\xcf\x26\xd2\x62\x3b\xec\x53\x85\x89\x4b\xfb\xee\xac\x39\x15\x51\xca\x82\x32\x9a\x28\x17\x54\x07\xd6\x6c\x3d\x15\xa0\x44\x16\x5a\x74\x7b\x5e\xd2\x09\x0b\xae\xe1\x01\x3f\x63\x55\x5f\x3b\xad\x97\xfe\xf5\x35\x71\xcf\xa8\xb5\x08\xa7\x8d\xa6\x26\x8e\xb0\xf4\x59\x3c\x18\x19\x3c\x1b\x74\xb6\x6d\x41\xdc\x9a\xa3\x96\x15\x7b\x84\xfe\x78\x95\x49\x99\xe9\x3e\xf9\xd7\x98\x30\x67\xb5\xed\x1b\xdf\xda\x33\x3b\x66\xc5\x41\x70\xb6\x78\xab\xf9\x39\x98\x91\xc7\x98\x21\xf0\xc4\xdd\x4a\x33\xe4\xa2\x2e\x9c\x4c\x7f\xba\x25\xf5\xba\x60\x32\x7c\x09\x8e\xc1\x8c\x3d\x32\xa3\xa7\x40\xb9\x1c\xa9\x80\x99\x0c\xb8\x0c\x12\x0c\xb5\x51\xb4\xb8\x5c\x56\x41\xa3\x0c\x08\x5a\x1e\xdb\x75\x26\x27\x58\x87\x8b\xba\xad\xe8\xf0\xd6\xbc\xc2\x4c\x01\x82\x26\x70\x2a\x93\xaf\x0e\xee\xc1\x6a\x13\x64\xde\xfa\x0a\x9e\x49\xc6\xad\x21\x8d\x2c\x4d\xd7\xb8\x1e\x91\x83\xc2\x94\x38\x0d\xb6\x63\x28\x2d\x7c\xfe\x94\x7e\x9d\x1d\x5f\x50\xf0\x73\x82\xd8\x7c\xdd\x11\x81\x9c\x47\x3f\xa7\xb6\xcf\x55\x4c\xac\x0b\x0f\x85\x8c\xd7\x31\x06\x4e\xe5\xc2\xd9\x43\x2a\x1a\xd2\x39\x5e\x4e\x2d\xe0\xf8\xf5\xdf\x21\xbe\x5e\x70\x3a\xcc\x94\xa0\xca\x1a\x6d\x7d\x24\x44\x35\x4f\x1f\x32\x23\x83\xc7\x6c\xbf\x60\x1f\x1f\xf0\x61\xe8\x5f\x27\x94\x3b\x9b\x9b\x01\xff\x17\x5f\xd6\xdd\x17\xfc\xdf\xef\x82\x63\x28\x77\x46\x5b\xde\xaa\x30\xbb\xf7\x5e\x0f\x93\xff\x3b\xee\xc0\x66\x04\x6b\x42\x5d\x56\x28\x19\x62\x94\xa5\x73\x57\xb9\x38\x08\x77\x95\xbc\x06\x20\x97\x8f\x0d\x33\x67\x70\xd0\x07\xbf\x80\xc7\x1f\x4f\x4f\x62\xbb\x05\x82\x21\x0b\x31\xf6\x68\x76\x41\x48\x3a\xc1\x5c\xce\x35\x37\x22\x4b\x7c\x94\xd8\xf3\x59\x16\xde\x69\x91\x03\x10\x5c\xe4\x14\xda\x1a\x5f\x30\x42\xa1\xa2\xe4\x9d\x0d\x71\x2f\x98\x3c\x66\x19\xfb\x30\xd4\x82\x45\xb5\x58\x89\x6e\x63\xf4\xc2\x79\x34\xd3\xe3\x77\xde\x65\x83\xa9\x02\x5f\xee\x02\xcf\xa9\x85\x11\x88\x49\x6d\x94\xa9\xc8\x8a\xbc\x77\x79\x97\x52\x5e\xbf\x10\x67\x64\xb5\xa2\x8b\xf7\xde\x5a\x60\xfc\xcf\x0c\x1e\xda\x00\x26\x53\xd0\xec\xdf\x87\x7c\xe7\xb8\xfd\xce\x73\xe0\x27\x4e\xcb\x4a\x81\x64\xef\x2f\x58\xe2\xde\x3f\xf6\xae\x92\x48\xaf\x7f\xab\x70\xbc\xfb\x40\xdf\xbb\x59\xe1\xd6\x8f\xc2\x45\x60\xd3\x18\x78\x1f\x25\xcd\x65\xa2\xa5\xc9\x28\x75\xa3\x4b\xdb\x6e\xa8\x45\x47\xda\x0e\xb6\x98\x78\xa6\xd2\x29\x9e\x64\xa3\xf7\x12\xc6\xc2\x13\x87\x65\x24\x3d\x17\x66\xb3\x56\xcf\x69\x84\xec\x42\xc7\xc4\x2d\xd3\xdb\x74\x79\x71\xe2\x81\x21\xbd\x93\x1e\x12\x1e\xca\x8b\xcb\x55\x8f\x90\x7b\xe1\xb4\xc0\x6d\xf4\x9b\x60\x06\x0f\xbd\x93\x0a\xc9\x48\x64\xde\x97\x46\x25\x83\x85\x23\x16\x4c\x79\xaa\xa9\xfb\xd3\x55\xd6\x9b\x86\xb8\x41\x78\xcf\x8c\xad\x10\xe3\x9e\x39\xb4\x4f\x5d\x21\x19\xd5\xbe\xd5\xf3\x44\xa7\x15\x36\x8c\x35\xe7\x43\xf3\x02\x44\xb9\xa6\xb9\x93\xc6\xf3\x46\xa8\x41\x02\xe5\xe6\x03\xdf\x39\x7d\xac\x2b\x82\x9b\xa8\xb9\x1a\x69\x2e\x90\x4b\xea\x27\xfe\xca\x1e\x61\x96\x33\xc6\x91\xc5\x73\xb1\x8d\xe3\xf7\x48\xba\xd4\x34\x18\x61\x99\x21\xbc\xd1\x78\xa0\xa1\xda\x0f\x88\x66\xe5\x52\xb8\x1e\xed\x9a\xd1\xd0\xed\x9d\xf0\xe4\xba\x65\x56\xbe\x70\xc4\xa3\x59\x04\xad\xc4\x5e\x43\x01\x97\x23\xf8\x33\x8a\xe0\x56\x38\x2d\x8a\x63\x9b\xcb\x35\x23\x45\x80\xb3\xbd\x9d\xd6\x2a\x98\x75\x29\x1c\x22\x8f\x0c\xef\x5a\x3d\x04\x6e\xf8\x3b\x77\xcc\x27\x26\x5f\x6b\x30\xf8\x36\x3e\x6f\x33\x2a\x17\x1e\x33\xb8\xee\x14\xd5\x0d\x02\xdf\xe0\xec\xf1\x44\x9a\xcf\x09\x4f\xb4\x80\x59\xb0\xe4\x0b\x27\xd7\x9d\x08\x2d\xa9\xaa\xeb\xc4\xd7\x66\x4c\x12\xcb\x6c\x82\xa3\xbb\xf5\xc9\xf1\x86\x9d\x4c\xfc\x81\xaa\x9d\xba\xeb\xfe\x43\xfa\x0f\x74\x5b\x38\x8f\x0c\x19\xfb\x23\xfd\x4f\x77\xe2\x2d\xc9\x53\xc0\xd0\x3f\x7e\xa4\xc3\x66\x72\x6d\x9d\x93\x8f\x5c\x30\x14\xea\xf9\x3d\x0a\x9a\xca\x2e\xd3\xdc\x3b\x6a\x9b\xd6\xe2\xde\xbf\x28\xe8\x84\x90\xf1\x72\xb9\x1c\x6e\xa7\x8f\x26\x88\x8b\x58\x39\x9e\xb3\x3c\x32\xa3\x2d\x99\x68\xf4\x37\xf3\x03\x50\x3e\x58\xc9\xab\x0a\xc7\xc3\x99\xe4\xf8\x8e\x62\x94\x50\x30\xcd\x98\x30\xe8\xee\x51\x85\x71\x55\x40\x83\x10\xa8\x76\xd4\x18\x29\xdb\x96\xbc\x6c\x4d\x2e\x1c\xc6\xa2\xa2\x1c\x21\xd0\x0e\xaa\x15\xc6\xd4\xe4\x20\xe8\x76\x96\x53\x51\x1e\xae\x87\x43\xa7\xdb\x9a\x4b\x42\xc7\xad\x7e\xf3\xf6\x36\xc1\x94\x2d\xb7\x6c\xd1\x1b\x4f\x54\xef\x9e\xff\xad\x7a\x7b\x20\xc9\xb7\x9a\x8b\x25\xe7\x64\x3d\x91\x2c\x05\x61\x0c\x0f\x22\x76\xb6\x30\x4d\xe0\x60\x55\x09\x8d\x9a\x12\x68\x74\x58\xfa\x56\xb2\x30\x35\x33\x4d\x14\x26\x09\xc7\x52\xbd\xb3\xb4\xec\xd9\x9f\x16\x3e\xf0\xc4\x0f\xaa\x56\x4d\xfb\x47\xdb\x59\xe5\x71\x81\xb9\x43\xd3\x3b\x97\x17\x0a\xca\x97\xfc\xc8\xf4\xe4\x3e\x90\xfc\x51\xba\xab\x09\xb1\xc1\xc6\x8f\xdc\xc8\x7a\x2d\x9f\xde\xdb\xb3\x99\x6a\x2b\xd3\x23\xb7\x50\xb0\x3c\xcf\x1f\x58\x65\x3c\x67\xa1\x55\x65\x64\xe5\x58\xf0\x13\x64\x9e\xcb\xb0\x85\xa7\xa0\x32\xf8\xae\x97\x20\xd6\xdb\x81\xd8\x2c\xdc\x0e\x4b\xdc\xd2\xb1\x66\x2d\x26\xbd\xf6\x00\xc5\xe2\xd6\x7a\x95\xe3\xd2\xe2\x2e\xa8\x30\x4b\x30\x21\x7e\xbf\xc1\xaa\xaa\xf5\x51\xea\xfd\x4a\xde\x0f\xae\x47\xa5\x15\x92\xe5\x96\x95\xb4\x14\xf8\x28\x9d\xb4\xb6\x3d\x42\x14\xa1\x5e\x9e\x9e\xff\x9c\xb5\x6e\xed\xa9\xf6\xf2\x95\xb4\x23\x3b\x2c\xfd\x2b\x29\x70\x46\xd3\xdb\x81\x16\x0e\x9c\xd7\xee\x59\xed\x3f\x19\x48\x3b\x72\x6b\x59\x4d\x23\xfc\xb0\xec\xab\xbd\x97\x12\x2c\x07\x5b\x93\x00\xcc\xfe\xf4\x6d\x85\xcc\x13\xf4\x4d\x6a\x13\xe4\x20\x81\xbc\x82\xad\xb0\x63\x3f\x41\x32\xad\x8f\x84\x66\x2f\x6d\xe6\x46\x3f\x0e\x5a\x40\xf8\x4e\x06\xe3\x68\x1b\x18\x5b\x28\x9e\x3f\x20\x76\x07\xb1\x70\x54\xaf\xca\x1d\xf6\x91\x00\x41\xaf\xa8\xad\x2a\x97\x9e\x1a\xfa\x94\xb7\xe3\x7b\x0e\xed\x53\xd2\xae\x3f\xb8\xa6\x91\x81\xfb\x7f\x12\x73\xc2\xce\x74\x1d\xed\xab\x49\xe4\xdc\x78\x3b\xe2\xc4\x7b\x0f\x76\x06\x63\x01\x97\x74\xcb\x8e\x38\x4c\xed\xa8\x6b\x53\xe4\xb0\xb0\x32\xa0\xd7\xb6\x7a\x9c\xf8\x94\x3f\x2f\xbd\x19\xc9\xf0\xcc\x53\xef\xe1\x3c\xa5\x93\xa3\x52\x9c\xa0\xe1\x9e\x53\x10\x8b\xcc\x74\x1d\x0e\xc4\xd1\x46\xa0\x22\xe8\x15\x87\x90\xc2\x92\x98\xbd\x41\xcd\xa9\x61\x5f\x32\x08\xf9\x8e\xca\x9d\x55\x0b\xe7\x9a\x5d\x5d\x5a\x20\xd4\xf7\xe2\xc3\xce\x10\x6f\x2c\xa7\x7e\x10\x9c\xb6\x9e\x5c\x1e\x29\x67\x57\xb5\xdd\xa7\xea\xdc\x99\xf8\xe4\xaa\x65\x72\x7a\xcc\x64\x4b\x65\x6b\xe1\x16\x0e\x0f\x41\xaa\xab\xd7\xda\xee\x29\x78\x58\x48\x1b\xc2\x72\x5e\xfe\x0a\x11\xb0\x90\x7e\xf0\x39\x4d\xe5\xf3\x4d\x06\xe1\xc5\xc4\x8b\x04\x52\x82\xba\x3c\x84\x71\x02\xe8\x43\x21\xd4\x56\xf9\x86\x78\x4e\xec\xf0\x5e\xb5\xf0\x90\x26\x87\xbd\x2c\xdd\x49\x32\x3d\xfe\x94\x85\xd5\xcb\x5e\xb2\xa0\x04\xaa\x8d\xdb\x18\x88\x76\xf4\x59\x12\xe4\x73\x8e\xa0\x51\x88\xb2\xd0\x94\x39\x08\x4a\xf6\x70\x55\x10\x63\x82\x51\xfd\xf4\x70\xbd\x5e\xc9\x73\x2f\x5d\xb7\x0b\x06\x8a\x6e\xca\x60\xc2\x0d\xee\xe5\x83\xc6\xef\xc5\x62\x5b\xee\x8a\x06\x2d\xdf\x43\x6d\x8f\x46\x19\x96\x1d\xb7\xf4\x85\x23\x8c\x9b\xe1\x24\x00\xe7\x41\x89\x74\xab\x75\x40\xde\xa7\xba\x1f\x8a\x79\x78\xbb\x01\xf7\x3c\x9a\x7a\x93\x03\x25\xe4\xea\xc6\xfb\x16\x88\xec\x28\xab\xff\x7e\x75\xba\x80\x73\xfc\x9e\xad\x92\xf3\xef\x9d\x9f\xe1\xcd\x50\x07\x68\x9e\x66\x8c\xa9\x65\xa4\xda\xd6\xf6\x64\xf9\x26\x88\xa6\xbf\x89\x6c\xe2\xbe\x37\xee\x63\x14\xd6\x0d\x76\xff\x22\x41\x72\xeb\x21\xe0\x02\x7e\xe8\x27\x9e\xea\x46\xef\xca\xa9\x70\x6a\x7e\xb3\x75\x6e\x7b\x11\x49\x9e\x6a\xb7\x3a\xe4\x83\xe1\xec\xcd\xc9\xb3\xb6\x35\xd6\x7e\x48\x7b\x36\x33\xa7\x0b\x0f\x45\x13\x08\xd5\x5c\xcb\xc4\x12\xd9\xb7\x7e\x98\x02\xdf\x56\x7b\xa8\xcb\x62\xbb\xb5\x60\xb8\x39\x8e\xae\x12\xb0\xd5\xb8\x54\x74\xa0\xc5\xd3\x7e\x48\xfb\x34\xf1\xef\xdf\xbf\x7f\xf7\xcd\xda\xc8\xd2\x6f\xdc\xb4\xf6\xed\x76\xeb\xed\x9c\x68\x1e\x2d\x01\x74\x01\x53\x35\x9d\xde\x95\x61\x5b\x0f\xd3\x08\xa3\x5f\x3b\xe9\x2b\xa5\x66\xed\x50\xcd\xa0\x5b\xa2\xf0\x30\x47\xcd\xa4\x57\x70\x0e\x5a\x65\x42\xf4\xf6\xa9\xae\x02\x0b\xfd\x3e\x97\x58\xdf\x03\x40\xbf\x47\x07\xbb\x80\x9f\x46\x06\x71\x24\xce\xc2\x4b\x2b\xd8\x06\x08\xac\xd7\x2c\x67\x53\x9b\xd6\x80\x68\x0a\x67\x74\xce\xef\x0d\xab\xce\xc7\xe5\x01\x39\x00\x3b\x20\xe8\x11\x6d\xc0\x0d\x22\x8b\x93\xb6\xfd\xc1\x4b\x4e\xdc\x62\xc2\x83\x2c\x30\xcf\x60\x72\xbf\xcd\x24\x51\x62\x20\x4b\xca\xf7\x75\x16\xe8\x16\x68\x0f\xbc\x2d\x9c\xd8\x3c\x94\xe8\x02\x6f\x02\xa8\xd5\x4a\xaa\x60\x3f\x52\x42\x9d\x2a\x39\x83\xad\xf8\xd4\xac\x1c\xea\x0c\xbd\x58\xc6\x8a\xd5\xf3\xb3\x62\xc5\x77\xf2\xd7\x17\x54\xaa\x8d\xf8\xf3\x41\xf4\xe7\x2e\x9a\x32\xe6\x4c\x1c\xe2\x9a\x20\x8f\x89\x65\x43\x30\x61\x21\x0b\xeb\xa0\x25\xe8\xed\xab\xad\x39\x69\x9e\x25\xa8\x4d\x87\xb8\x5d\x65\x78\xb9\xe0\xf4\x40\xde\xd3\xa2\x6d\x25\x71\xfb\xd6\x30\x62\xda\x01\x3b\x1e\x60\xd3\xe0\xac\x34\xca\xd8\x83\xa6\x50\xc8\x3f\x3e\xe2\xed\xf4\xa0\x5e\xd0\x47\x07\x05\xdf\x51\xfd\x47\x40\xd3\xa9\xb2\x65\x4c\x84\x59\xb0\x1d\x07\xb7\x5f\x58\xa2\x33\x39\x2b\x99\x59\xb1\x0b\xcd\xf8\x1c\x4b\xe6\x06\xad\x38\x81\xad\xee\x9c\x63\xef\x23\x17\x64\xbd\x72\x30\x89\x33\x35\x1f\xcc\x97\xd0\x67\x76\x3e\xf1\x0d\xfa\x07\x46\x20\xd3\x5f\xf7\x7e\xa5\x20\x82\xdb\x28\xdd\xf3\x73\x51\xab\x7b\xcd\x34\xd5\x53\xc4\xe4\x1f\x26\x3f\x62\x1a\x19\x87\x88\x25\xf1\x66\xe1\xb1\xd6\xdc\x72\x50\x02\x20\xdb\xee\x6a\x94\xa5\xa2\x57\x0c\x06\x33\x2d\x3d\x7f\x35\xe2\xb4\x2c\xfe\x1f\x06\xc7\xb9\xfc\xf2\xba\x0e\xa2\x3a\x28\x82\x7a\x6f\xa7\x48\xb7\x1b\x7c\xa8\xa2\x77\x4e\xd9\x35\x8f\x6c\x58\xb4\x8f\xd8\x26\xda\x8f\x83\x17\xa4\x62\x5b\x48\x32\x48\xf4\x81\x6c\x03\xfc\x91\x4d\x46\x0b\xcf\x5d\x40\xd9\xde\xc0\x72\x80\x4e\xda\x1a\x61\x77\xb8\x74\x90\x1f\x39\x1e\x97\xea\x22\xc7\x7c\x9e\xb9\x06\xd3\x7c\x90\xd0\x75\x81\x5d\xaf\x34\x3c\x94\xa0\x82\xb5\x4e\xa3\xe4\xbe\xc0\x3f\x8c\x1a\x1a\x78\x3f\x98\xa3\x64\x20\x24\x2b\xbd\xb6\x80\xbc\xad\xac\xc6\xc1\x76\xe7\xed\x9d\x9e\x39\x81\x91\x63\xe0\xfe\xcc\x42\xcb\x3b\xb1\x9a\xe7\xa7\x3f\x4f\xe7\x3e\x1f\x9d\x65\x1d\x98\xfe\x05\x29\x70\x18\x81\x56\x23\xa2\xc3\x70\x50\x25\x08\x5a\x60\xd3\xa6\x9f\x6f\x26\xc1\xe6\xb0\x07\x32\xed\xe1\x44\x1a\xc7\x7b\x12\xe9\x15\xb3\xa5\x9e\x00\x1b\x3c\x01\xc5\x4d\x8f\x5b\x0a\x2a\x0f\xc2\x00\x7a\xb3\x61\x1a\x86\x49\x6c\xaa\xf6\x87\x6c\xa3\x05\x0a\xa7\xf6\xca\xc4\x73\xdf\xdd\xe0\x8e\x6b\x39\x59\x22\xeb\x98\xfa\x55\xa7\xa3\xb0\xde\xf2\x59\x77\x54\x10\x13\x87\x3e\x93\x89\xb3\xa7\x63\x8f\xb6\x2b\x35\x54\x6a\xb1\xa4\x7a\x79\x41\xe6\xb4\x0f\x21\xc3\xeb\x9f\xa6\x53\x83\x5e\xcf\xed\x44\x3a\xc5\x9a\xf4\x0a\x19\xdb\x22\x0c\x9c\xfa\x95\x82\xbf\x15\x1c\x95\xbd\x63\x8b\x1b\x8f\xf5\xfd\xb7\x52\x51\xd9\x73\x3b\x7a\x16\x38\xfd\xbd\x60\x6e\x2b\x26\x41\xb0\x1d\x71\x0a\xbe\xed\x13\x20\x3c\x0a\x4b\x56\xf7\x7b\x77\xad\x0f\x61\x67\x0e\x47\x00\xfb\x4e\xca\xf2\x7a\xa7\x64\xe3\x27\x88\x64\x9a\x99\x7a\x5e\x65\x81\xce\xea\x7b\x57\x3e\x3d\xc1\x6d\xe2\x64\x41\x65\x2b\xb8\x65\x04\x32\x49\x4f\xcf\x4f\x6d\x61\x27\x04\x97\x50\xa0\x43\x76\x34\xce\x89\x84\x26\x50\xc6\xd1\x5e\xc4\xd8\xe2\x26\xc4\x61\x26\x74\xd8\x88\xf6\xa8\x97\x48\xa1\x5f\x90\xe9\x65\x78\x35\x6e\xee\x89\xcf\xe9\x4f\x87\xf2\x32\x1c\x50\xc2\xe8\x48\x83\xba\x17\xb2\x12\xf6\xdb\xbb\x67\x9a\x3d\x1f\x0c\xe7\x1e\xaf\xd0\x0d\x83\x34\x3e\xa6\x96\x20\x1f\x8c\xc8\x21\xd3\x26\xae\x33\x77\x38\x91\x70\xc3\x94\x5b\xa8\xa0\x4f\x69\x86\xad\xc7\xa0\xfd\x02\x65\xc0\xb4\xec\xef\x2b\x47\x1c\x19\xd4\x53\x62\x9e\x55\x3b\x55\x45\xb1\x9f\x7c\x65\x60\xd9\x54\x25\x98\x4d\xa3\x2d\x13\x21\x90\xae\x53\xe2\xaa\x7b\xcb\x4b\xe8\xd8\x87\x54\x05\x72\x95\xbc\xf2\xa5\x8b\x32\x5e\x7b\x7e\x23\xd5\x1c\x19\xc7\x4b\x7a\x61\x44\xc5\x96\x70\x32\xfd\xaa\xef\xc1\x6c\x99\x55\x07\x47\x8e\x30\xed\xed\x3e\xdd\x91\x35\xca\x60\x94\x80\x8e\xc3\x0b\x67\x30\xbb\xaa\x09\x1c\x74\xa9\x2b\x39\xa6\x7e\x7b\x2e\x43\x78\xe3\x8c\xad\x14\x51\xc7\x4d\xd9\x0c\xa1\x40\xc2\x70\x9e\xf6\xde\xce\x0b\xcd\x45\xf0\xb0\x50\xb8\x06\x3e\xea\x44\x19\x36\x2c\x75\xc2\xf7\xfd\xfb\x91\xd3\x16\x65\xd6\x10\x32\xcc\xbd\x3a\x91\x35\x94\xb3\xac\xbf\x53\xd6\x09\x31\x14\x16\x70\xd6\xd7\x52\xd6\x89\x62\xc1\x8e\x63\xfa\x7c\xf7\x8a\xc3\x03\x03\x83\x4e\x7b\x1c\xfa\xa8\x6b\xa2\xb2\x2b\xcd\x12\x3f\xdd\x8b\x1a\xed\x93\x65\x75\xcc\x93\x6a\xf7\x3b\xd4\x8a\xe3\xac\xcc\xe8\xc1\x95\x87\x66\x07\x92\x9d\x66\xf5\xa2\x9e\xee\x6d\x39\x65\x67\x4e\xfe\xce\xc8\xde\x0b\xe0\xca\xc8\x6d\x0f\x1e\xc0\x21\xcb\x51\x89\x82\x42\xaf\x9f\xe8\xf6\xc5\xc8\x78\x95\x33\x47\x90\x41\xb7\x39\xf2\x1b\x5b\xcc\x87\x38\x8e\x5a\x4e\x46\x3e\xd6\x72\x8f\xd1\x5b\x6f\xd7\x0c\xbc\x1f\xed\x1d\x1c\xdd\xdb\x05\x31\xdc\xef\xe3\xe6\x15\x96\xc2\x3e\x37\xb7\xf5\xa1\x82\xd1\xc0\xec\xfd\xe6\xe6\xc7\xe2\x86\x2c\xba\xc6\x19\x75\x73\xe2\x0f\x64\x1b\xd5\x03\x5d\x44\x1c\x76\x58\x68\x88\xfa\xc5\x73\x4f\x09\x06\x42\x3e\x57\xdb\xd8\xad\x80\x2d\x23\xa1\x09\x35\x1f\x38\x31\x93\x6f\xb7\x30\xfb\xa1\x9f\x49\x02\x9f\x2f\xca\x2d\x99\xc2\x9a\x38\x44\x09\x96\xd8\x74\xd5\x82\x4a\xc0\xaf\xac\xf8\xe1\x12\x85\xf7\x73\xe8\x17\x4c\x14\x6f\x7d\x50\x54\x70\x03\x57\xfb\x72\x10\xcf\x97\x2d\x85\xbb\xdd\x1c\x18\xcb\x8f\x0b\xd6\xa1\xc9\x65\x23\x54\x1b\xba\xf1\x59\x6e\x2e\xd9\xec\xd3\xd4\x0e\xa2\xec\x6c\xbf\x6f\x9c\x1d\xfb\xa3\x71\xb9\x5f\xa6\x6b\x16\xf4\x40\x8e\x7a\xc2\xbb\x15\xae\xe2\xca\x01\x0d\x65\xcd\x35\x0c\x74\x97\x03\x4c\xb9\xad\x9f\x80\x0b\xdf\xc0\x60\xba\xad\x30\xea\xdf\x39\x50\x6c\xc6\x6d\xff\x5f\x4e\x99\xc1\xca\x5f\x93\xb1\x5e\x49\xfd\x30\xa3\xfd\x5e\xa8\xe8\xaf\xfe\x50\xba\x38\x2e\xea\x7c\x51\xaa\xfa\x58\x66\x51\xcf\x8f\x7a\x07\xf3\xe5\x2b\xe6\x8f\xaf\x98\xaf\x5f\x31\x7f\x7e\xc5\xfc\xfd\xc8\xbc\x90\x59\xb0\x3c\x70\x3e\xac\xc3\xc6\xc9\xd8\xaf\x5f\x77\xaa\x24\x30\x1d\x7e\x67\x5e\x99\x82\x6c\xd6\x36\x45\xed\x06\x3f\x25\x54\x0b\xf4\xfb\xe3\x99\x5d\x88\x0e\xf2\x78\x38\x1b\x8c\x5c\x0c\x4e\x2d\xf9\xde\x0d\x50\x37\x0c\x94\xea\xd8\xee\xbc\x8d\x9b\x19\xd1\x81\x85\xb1\x33\x79\xaf\x37\xd9\xad\x86\xda\xea\x11\x56\x54\x3d\x97\x34\x88\x1b\xd8\x1a\x7b\xf4\x94\xe3\xa4\xa2\xa3\xd2\xf3\x7c\x42\x65\x8d\xa1\xd7\x4e\x72\x44\x34\x35\x2a\xbf\xf7\x74\x5f\x8e\x14\x5e\x9e\x9e\x7f\x09\x80\x6e\xb1\xec\x85\x6e\x23\x45\x22\xbe\x78\xb3\xf0\xfc\xe3\xe9\x81\xda\xe4\x24\x6c\x5f\x9a\x68\x0a\xdc\x9b\x05\x36\x1a\x20\x48\x34\x0b\x1e\xdf\x5e\x28\x81\x7b\x2f\xdf\xe7\xc2\xda\x96\x0b\xe5\x91\xb3\x6d\xb1\x82\x1a\x60\x24\x97\x84\xe0\x55\x31\x87\x2c\xf1\x6e\xe1\xf5\x77\x7f\xb0\x1a\x0c\xa5\x15\x6d\x1a\xdd\xf3\x8b\xf7\xfb\x11\xb9\x06\x93\xea\xf2\x1e\x7b\xb4\x4b\x3c\x84\xc1\xf4\xeb\x40\x8d\xf4\xe0\xfb\xbd\xbb\x46\x6d\x98\xfb\xb5\xd9\xbc\xa1\x58\xaa\x6f\x86\x0d\x0b\x0b\x68\xe2\xac\xf9\x72\x2a\xca\x43\xbb\xab\xd9\xd3\x58\x45\x25\xd4\xe7\x5c\x4f\x05\xa8\x45\xa5\x8d\x09\xa5\x30\xab\x5c\x48\x60\x51\x7e\x67\xad\x7d\x3e\x8b\x51\xdd\xc9\xf6\x1b\x2f\xfd\x92\xa9\x85\x87\x3b\x7f\x05\xcd\x35\xbd\x75\x65\x0c\xe5\x31\xed\x52\x30\x3a\x5e\xd9\xa8\x25\xe0\xad\xdf\xa8\x29\x78\x7d\xbc\x68\x50\x96\xd2\x4b\x7a\xed\xf1\x15\xd8\x54\xd6\x85\xf5\xa0\x11\x6a\xe1\x91\x40\xf7\x4e\x18\xbd\x0c\x36\xd6\x5e\x59\xb1\xf9\x85\x01\x62\x64\x32\x0b\xdb\x06\x00\x86\x33\x2b\xec\x8f\xce\x39\xaa\xa5\x17\x8c\xa4\xe9\x5e\x0e\xee\xf3\xa7\x9f\x6d\x08\xf3\x49\x5d\x46\xfd\xac\x70\x1c\x40\x53\x16\x59\xe1\xf8\x70\x9e\x9c\x35\x9f\x5a\x7e\x54\x1d\x40\xf2\xfe\xe2\x5e\xc7\x7a\x79\x55\x4b\xec\x56\x1d\x1b\xb7\xfd\x86\x47\xf6\xe5\x60\xbc\x34\x86\xcc\xf2\x07\xc6\xa3\x46\xbf\x0b\xd3\xc6\xd2\x39\x0b\x06\xaa\xf9\x51\x67\xe4\x5c\xf0\xb1\xa3\x83\x97\x0e\x66\x6c\xb3\xee\xa0\xd0\x58\xf9\x25\x35\x5f\xde\x62\x8a\x92\xa1\xd4\x33\x9a\xda\x09\xad\x2c\x3b\xcc\x7d\xce\x6a\xa0\xb0\x98\x7e\xd6\x94\x4b\xae\x73\xdb\x99\x15\xce\x45\x9f\x2a\xe4\x86\x2d\xab\xee\x57\x36\xaa\x81\x9e\x94\x16\xad\xf7\x98\xb4\xda\x04\x14\x3a\xf0\xc2\xd2\x53\x30\xa7\xea\x54\x4b\x7e\xf4\x7d\x5f\x83\xc1\x24\xc0\x22\x18\x41\x84\x2d\x32\xed\x6a\x81\xa6\x9a\x65\xe5\xd6\x40\x1b\xa6\xf7\x0b\x23\x35\x38\xf2\x54\xee\x57\x7b\x6a\x88\x09\x0d\xe9\x02\x93\x43\x81\xe2\x35\x16\xf2\xa8\x46\x64\x3d\x28\xac\x8f\x54\x7d\x94\xf5\x5e\x5b\xf3\x88\x14\x1a\xe7\x16\xc1\x62\x19\xed\x1a\x16\xea\xb5\xaa\x4d\xce\x9b\x0e\x6d\xe5\x0b\x36\x70\x64\x6a\x47\xfd\x1b\x24\xb2\x7c\x33\x3d\xb1\xb3\xa1\xe3\x96\xfd\xec\x37\xde\x36\x41\x68\xb1\x08\x12\xdc\x04\x59\xb3\x75\xdc\xeb\x0a\x1b\xe6\x00\x0d\x0c\xf6\x17\x10\x2c\x09\xda\xcf\x23\x9a\xb4\xdd\x68\x50\x3d\x4c\xbb\x5f\x36\xe9\xdc\x9e\x8b\xb8\xd3\x0f\x17\xd4\x3f\xfc\xbe\x6d\x23\x74\x62\xca\x8d\x6c\xe1\x44\xa0\x34\x86\xf1\xf3\xa6\x8d\x0d\xb0\xc1\x9c\xfb\xd6\xdf\xd8\x58\xde\xb0\x39\xd3\xed\xb6\xa2\xab\xc6\x76\x03\x6d\xb7\x3a\x1d\x09\x9d\xcb\x8f\x37\xd8\xa0\x63\xcc\xce\x00\xe7\x22\x44\x4c\x06\xf3\x28\xd5\x5f\x4c\xbe\xd7\xcb\x2e\xd8\x7f\x1e\x32\x46\x77\x84\x58\x17\x9c\x94\xc4\x0f\xa1\xf6\xb3\xff\xfe\x9b\xbd\x0f\x37\x18\x84\x7b\x5f\xd5\x32\x6b\xc7\x75\x8e\xf7\xf0\x6d\xec\xbf\x0f\x02\xd1\x3e\xde\xd2\xb8\xed\x18\xe9\xb1\x60\x3f\x47\xbe\x10\x60\xfa\x20\xca\x32\x3c\xc2\x4b\x3e\x06\xd2\x4f\xd2\xb3\x6f\x1a\x35\x39\x8e\x78\xd4\x18\x2f\x7d\xe8\x0e\x47\xf2\xb0\x4d\x8d\xe2\xb4\x40\x6e\x7b\xe8\xfd\xae\x92\x4e\x78\xe9\x1f\x72\xb0\xf4\x71\x06\x5c\x38\x59\xea\x77\x70\xfa\x2f\x1c\x69\x6e\xf6\xe3\xe4\x8c\x3f\x7e\xcc\x72\xa9\xf3\x28\xfa\x5c\x2e\x2f\xe3\xd7\x2c\x5d\x70\xb9\xbc\x47\x4f\xd7\x9f\xef\xbf\xc6\xba\x5a\x68\xc5\xb4\xd3\x35\xa8\xfe\xef\xe7\x0f\x33\x69\x88\x0b\xfc\x84\xe2\xa2\x9b\x8d\xff\x05\xcb\x33\x4c\xcf\xd3\x86\xab\x7b\x9a\x7e\x17\x86\x27\x7b\x16\xfd\xf8\x0c\x74\x3c\xf8\xcb\x6a\xf0\x17\xbc\x68\xf3\xdf\x04\x3b\xba\xbf\x9f\x34\x7e\x5b\x0d\x70\xd5\xeb\x34\xf9\x25\xe8\xf4\x5b\xaf\xb7\xfd\xc9\x3e\xad\x9f\xfb\xd2\x4e\x5b\x03\xfa\xc7\x94\xe7\xc5\xcc\xe5\x07\xe0\xf4\x16\x7f\x62\xc1\x4f\xaa\xbf\xf3\x04\xd3\xfa\xa6\x7f\x68\xad\x7f\xc2\xb2\x4c\xb7\xd9\xcf\xf4\xa5\x96\x7f\x86\x55\xbf\x4e\x2f\xf0\x77\x9e\x65\xc5\x3f\x88\xc2\x4f\x98\xde\x70\x9d\x2f\xf4\x67\x7a\xfb\xe2\xe1\x29\xe2\x0f\x98\x97\xba\xe3\x17\xc2\x95\x5f\x60\xc1\x69\x5a\x1d\x6d\xfb\xbf\xf2\x3f\x79\x02\x4b\xaf\x70\x83\x4b\xfd\x39\x7d\x25\x4d\xb4\x4e\xb4\x5e\xe3\xf5\xef\x02\xf3\xd3\xf2\xdc\x5f\xf1\xe7\x09\xcc\x75\xb5\xf9\x5d\x71\xc5\x19\x5c\xf8\x05\x9a\x66\x03\x1c\x7e\xea\x5f\xf3\x2b\x5c\xd7\x2f\xba\x74\x8b\x9f\xde\x9e\xe1\x79\xb2\x7f\xdc\xb9\x73\x9f\x7f\x3d\xab\xd7\x0d\xfd\xf4\x4b\xbf\xb9\xdf\x10\x26\x40\xf8\x01\x73\xa0\xf0\xe9\x73\x75\x99\x26\xfd\x66\x7e\x98\x29\xe3\xba\x7c\x32\xa5\x2e\xd3\xac\xcd\x9b\xfb\x33\x01\xbe\xad\xf3\xf4\xcc\x9f\x86\xa0\xcb\x44\x4b\xa0\x1f\xf0\xf6\xfa\x49\x36\x97\x09\x19\x68\x5f\x9e\xc1\x3e\xeb\xe9\xb7\xf9\x57\x81\xca\x34\x55\xf3\x17\xdf\xa6\x39\x2e\x3f\xba\x5b\x1d\x06\x01\x78\xb3\x29\x68\xe3\xfe\x9a\xf5\x17\x04\xfc\x53\x3e\x3e\x2c\x2a\x01\x20\x19\xf1\x36\xab\x46\xff\x0a\xbf\xe8\xb1\x03\x03\xcb\xa2\x7f\x4e\x66\x75\x33\xf8\x3c\x1b\x7a\x7b\x8d\xbf\xa7\x4f\x9d\x98\x45\xd6\xe9\x2f\xd0\xbc\x86\x5f\xe9\xa3\xf8\xbf\x27\xd0\x18\xc8\x46\x9c\x26\x63\x5e\xe7\x5f\xe6\xb3\xfc\x4d\x22\x5e\xc0\x1f\xa0\xfd\x21\xd4\x73\x93\xea\x67\xd0\xfb\xf2\xcf\x1b\xcd\x33\x4c\x6f\x4f\x30\xe5\xff\x8e\xe3\x60\x70\x69\x66\x7c\x82\xff\x3a\xca\x13\x1e\x3e\x8b\x0d\x6e\x7a\x0d\x76\x39\xf2\x63\x83\xbf\xcc\xaf\xfa\x9f\x85\x33\x1c\x65\xbb\xb8\xd7\x34\x6f\xbf\x46\xb4\xbb\x7f\x3c\xa6\x76\xcc\x4a\x73\x8f\xc0\xf7\x95\x52\x4d\x4b\x3b\xd0\x77\x8b\xfa\xf2\xd7\xd1\xb5\xe5\x44\x76\x36\x19\x25\x72\xdd\xb9\x46\x4e\xe1\x3d\x5b\xfa\x91\x31\xb2\xa5\x07\x73\x0c\x53\x0e\x9e\x7e\xab\x96\xb2\xe1\x4b\x3f\x98\x6e\xb0\x6e\xd0\xbc\xd0\x0d\x06\x40\x6e\xae\xe8\x06\x9b\x80\xc4\xe6\x1b\x6f\xc6\x2b\x41\x7d\xfd\x81\x51\x90\x53\x81\x17\xea\x8e\xed\xd6\x7e\xff\xa2\x7c\x1a\xb7\xc4\xc5\x7e\x37\x66\xdf\x2e\xc9\xb5\x67\x04\x10\x37\x40\xf1\x7f\x01\x00\x00\xff\xff\xf1\x3c\x8a\xce\xe9\x3e\x00\x00") func blacklistTxtBytes() ([]byte, error) { return bindataRead( _blacklistTxt, "blacklist.txt", ) } func blacklistTxt() (*asset, error) { bytes, err := blacklistTxtBytes() if err != nil { return nil, err } info := bindataFileInfo{name: "blacklist.txt", size: 16105, mode: os.FileMode(420), modTime: time.Unix(1541751116, 0)} a := &asset{bytes: bytes, info: info} return a, nil } // Asset loads and returns the asset for the given name. // It returns an error if the asset could not be found or // could not be loaded. func Asset(name string) ([]byte, error) { cannonicalName := strings.Replace(name, "\\", "/", -1) if f, ok := _bindata[cannonicalName]; ok { a, err := f() if err != nil { return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) } return a.bytes, nil } return nil, fmt.Errorf("Asset %s not found", name) } // MustAsset is like Asset but panics when Asset would return an error. // It simplifies safe initialization of global variables. func MustAsset(name string) []byte { a, err := Asset(name) if err != nil { panic("asset: Asset(" + name + "): " + err.Error()) } return a } // AssetInfo loads and returns the asset info for the given name. // It returns an error if the asset could not be found or // could not be loaded. func AssetInfo(name string) (os.FileInfo, error) { cannonicalName := strings.Replace(name, "\\", "/", -1) if f, ok := _bindata[cannonicalName]; ok { a, err := f() if err != nil { return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) } return a.info, nil } return nil, fmt.Errorf("AssetInfo %s not found", name) } // AssetNames returns the names of the assets. func AssetNames() []string { names := make([]string, 0, len(_bindata)) for name := range _bindata { names = append(names, name) } return names } // _bindata is a table, holding each asset generator, mapped to its name. var _bindata = map[string]func() (*asset, error){ "blacklist.txt": blacklistTxt, } // AssetDir returns the file names below a certain // directory embedded in the file by go-bindata. // For example if you run go-bindata on data/... and data contains the // following hierarchy: // data/ // foo.txt // img/ // a.png // b.png // then AssetDir("data") would return []string{"foo.txt", "img"} // AssetDir("data/img") would return []string{"a.png", "b.png"} // AssetDir("foo.txt") and AssetDir("notexist") would return an error // AssetDir("") will return []string{"data"}. func AssetDir(name string) ([]string, error) { node := _bintree if len(name) != 0 { cannonicalName := strings.Replace(name, "\\", "/", -1) pathList := strings.Split(cannonicalName, "/") for _, p := range pathList { node = node.Children[p] if node == nil { return nil, fmt.Errorf("Asset %s not found", name) } } } if node.Func != nil { return nil, fmt.Errorf("Asset %s not found", name) } rv := make([]string, 0, len(node.Children)) for childName := range node.Children { rv = append(rv, childName) } return rv, nil } type bintree struct { Func func() (*asset, error) Children map[string]*bintree } var _bintree = &bintree{nil, map[string]*bintree{ "blacklist.txt": &bintree{blacklistTxt, map[string]*bintree{}}, }} // RestoreAsset restores an asset under the given directory func RestoreAsset(dir, name string) error { data, err := Asset(name) if err != nil { return err } info, err := AssetInfo(name) if err != nil { return err } err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) if err != nil { return err } err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) if err != nil { return err } err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) if err != nil { return err } return nil } // RestoreAssets restores an asset under the given directory recursively func RestoreAssets(dir, name string) error { children, err := AssetDir(name) // File if err != nil { return RestoreAsset(dir, name) } // Dir for _, child := range children { err = RestoreAssets(dir, filepath.Join(name, child)) if err != nil { return err } } return nil } func _filePath(dir, name string) string { cannonicalName := strings.Replace(name, "\\", "/", -1) return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) } ================================================ FILE: pkg/aggregator/blacklist.go ================================================ package aggregator import ( "bufio" "bytes" "strings" ) type blacklist struct { data []byte } func newBlacklist() (*blacklist, error) { var err error b := &blacklist{} b.data, err = Asset("blacklist.txt") if err != nil { return nil, err } return b, nil } // Has returns true if the given domain appears on the blacklist // Uses sub-string matching, so if usesfathom.com is blacklisted then this function will also return true for danny.usesfathom.com func (b *blacklist) Has(r string) bool { if r == "" { return false } scanner := bufio.NewScanner(bytes.NewReader(b.data)) domain := "" for scanner.Scan() { domain = scanner.Text() if strings.HasSuffix(r, domain) { return true } } return false } ================================================ FILE: pkg/aggregator/blacklist_test.go ================================================ package aggregator import ( "testing" ) func TestBlacklistHas(t *testing.T) { b, err := newBlacklist() if err != nil { t.Error(err) } table := map[string]bool{ "03e.info": true, "zvetki.ru": true, "usefathom.com": false, "foo.03e.info": true, // sub-string match } for r, e := range table { if v := b.Has(r); v != e { t.Errorf("Expected %v, got %v", e, v) } } } ================================================ FILE: pkg/aggregator/data/blacklist.txt ================================================ 03e.info 0n-line.tv 1-99seo.com 1-free-share-buttons.com 100dollars-seo.com 100searchengines.com 12masterov.com 12u.info 1pamm.ru 1webmaster.ml 24x7-server-support.site 2your.site 3-letter-domains.net 3waynetworks.com 4inn.ru 4istoshop.com 4webmasters.org 5-steps-to-start-business.com 5forex.ru 6hopping.com 7kop.ru 7makemoneyonline.com 7zap.com abcdefh.xyz abcdeg.xyz abclauncher.com acads.net acarreo.ru acunetix-referrer.com adanih.com adcash.com adf.ly adspart.com adtiger.tk adventureparkcostarica.com adviceforum.info advokateg.xyz aerodizain.com affordablewebsitesandmobileapps.com afora.ru aibolita.com aidarmebel.kz akuhni.by alfabot.xyz alibestsale.com aliexsale.ru alinabaniecka.pl alkanfarma.org allergick.com allergija.com allknow.info allmarketsnewdayli.gdn allnews.md allnews24.in allwomen.info allwrighter.ru alpharma.net altermix.ua amazon-seo-service.com amt-k.ru amtel-vredestein.com anal-acrobats.hol.es analytics-ads.xyz anapa-inns.ru android-style.com animalphotos.xyz animenime.ru anticrawler.org antiguabarbuda.ru apteka-pharm.ru arendakvartir.kz arendovalka.xyz arkkivoltti.net artdeko.info artpaint-market.ru artparquet.ru aruplighting.com ask-yug.com atleticpharm.org atyks.ru auto-complex.by auto-kia-fulldrive.ru auto-seo-service.org autoblog.org.ua autoseo-service.org autoseo-traffic.com autovideobroadcast.com aviva-limoux.com avkzarabotok.info avtointeres.ru avtovykup.kz azartclub.org azbukafree.com azlex.uz baixar-musicas-gratis.com baladur.ru balitouroffice.com balkanfarma.org bard-real.com.ua batut-fun.ru bavariagid.de beachtoday.ru bedroomlighting.us beremenyashka.com best-deal-hdd.pro best-ping-service-usa.blue best-seo-offer.com best-seo-software.xyz best-seo-solution.com bestmobilityscooterstoday.com bestofferhddbyt.info bestofferhddeed.info bestwebsitesawards.com betterhealthbeauty.com bezprostatita.com bif-ru.info biglistofwebsites.com billiard-classic.com.ua bio-market.kz biplanecentre.ru bird1.ru biteg.xyz bizru.info black-friday.ga blackhatworth.com blog100.org blog4u.top blogstar.fun blogtotal.de blue-square.biz bluerobot.info boltalko.xyz boostmyppc.com bpro1.top brakehawk.com brateg.xyz break-the-chains.com brillianty.info brk-rti.ru brothers-smaller.ru brusilov.ru bsell.ru budilneg.xyz budmavtomatika.com.ua bufetout.ru buketeg.xyz bukleteg.xyz burger-imperia.com burn-fat.ga buttons-for-website.com buttons-for-your-website.com buy-cheap-online.info buy-cheap-pills-order-online.com buy-forum.ru buy-meds24.com call-of-duty.info cardiosport.com.ua cartechnic.ru cenokos.ru cenoval.ru cezartabac.ro chcu.net cheap-trusted-backlinks.com chelyabinsk.dienai.ru chinese-amezon.com chizhik-2.ru ci.ua cityadspix.com civilwartheater.com cleaningservices.kiev.ua clicksor.com climate.by club-lukojl.ru coderstate.com codysbbq.com coffeemashiny.ru columb.net.ua commerage.ru comp-pomosch.ru compliance-alex.xyz compliance-alexa.xyz compliance-andrew.xyz compliance-barak.xyz compliance-brian.xyz compliance-don.xyz compliance-donald.xyz compliance-elena.xyz compliance-fred.xyz compliance-george.xyz compliance-irvin.xyz compliance-ivan.xyz compliance-john.top compliance-julianna.top computer-remont.ru conciergegroup.org connectikastudio.com cookie-law-enforcement-aa.xyz cookie-law-enforcement-bb.xyz cookie-law-enforcement-cc.xyz cookie-law-enforcement-dd.xyz cookie-law-enforcement-ee.xyz cookie-law-enforcement-ff.xyz cookie-law-enforcement-gg.xyz cookie-law-enforcement-hh.xyz cookie-law-enforcement-ii.xyz cookie-law-enforcement-jj.xyz cookie-law-enforcement-kk.xyz cookie-law-enforcement-ll.xyz cookie-law-enforcement-mm.xyz cookie-law-enforcement-nn.xyz cookie-law-enforcement-oo.xyz cookie-law-enforcement-pp.xyz cookie-law-enforcement-qq.xyz cookie-law-enforcement-rr.xyz cookie-law-enforcement-ss.xyz cookie-law-enforcement-tt.xyz cookie-law-enforcement-uu.xyz cookie-law-enforcement-vv.xyz cookie-law-enforcement-ww.xyz cookie-law-enforcement-xx.xyz cookie-law-enforcement-yy.xyz cookie-law-enforcement-zz.xyz copyrightclaims.org copyrightinstitute.org covadhosting.biz cp24.com.ua cubook.supernew.org customsua.com.ua cyber-monday.ga dailyrank.net darodar.com dawlenie.com dbutton.net dcdcapital.com deart-13.ru delfin-aqua.com.ua demenageur.com dengi-v-kredit.in.ua dermatovenerologiya.com descargar-musica-gratis.net detskie-konstruktory.ru dev-seo.blog dienai.ru diplomas-ru.com dipstar.org distonija.com dividendo.ru djekxa.ru djonwatch.ru dktr.ru docs4all.com docsarchive.net docsportal.net documentbase.net documentserver.net documentsite.net dogsrun.net dojki-hd.com domain-tracker.com domashniy-hotel.ru dominateforex.ml domination.ml doska-vsem.ru dostavka-v-krym.com dosugrostov.site drupa.com dvr.biz.ua e-buyeasy.com e-commerce-seo.com e-commerce-seo1.com earn-from-articles.com earnian-money.info easycommerce.cf ecommerce-seo.org ecomp3.ru econom.co edakgfvwql.ru edudocs.net eduinfosite.com eduserver.net egovaleo.it ek-invest.ru ekatalog.xyz eko-gazon.ru ekoproekt-kr.ru ekto.ee elektrikovich.ru elementspluss.ru elentur.com.ua elmifarhangi.com elvel.com.ua emerson-rus.ru eric-artem.com erot.co escort-russian.com este-line.com.ua etairikavideo.gr etehnika.com.ua eu-cookie-law-enforcement2.xyz euromasterclass.ru europages.com.ru eurosamodelki.ru event-tracking.com exdocsfiles.com express-vyvoz.ru eyes-on-you.ga f1nder.org fanoboi.com fast-wordpress-start.com fbdownloader.com feminist.org.ua fidalsa.de filesclub.net filesdatabase.net filter-ot-zheleza.ru financial-simulation.com finansov.info findercarphotos.com fix-website-errors.com floating-share-buttons.com flowertherapy.ru for-your.website forex-procto.ru forsex.info fortwosmartcar.pw forum69.info foxweber.com frauplus.ru free-fb-traffic.com free-fbook-traffic.com free-floating-buttons.com free-share-buttons.com free-social-buttons.com free-social-buttons.xyz free-social-buttons7.xyz free-traffic.xyz free-video-tool.com free-website-traffic.com freenode.info freewhatsappload.com freewlan.info freshnails.com.ua fsalas.com game300.ru gandikapper.ru gearcraft.us gearsadspromo.club generalporn.org gepatit-info.top germes-trans.com get-clickize.info get-free-social-traffic.com get-free-traffic-now.com get-more-freeer-visitors.info get-more-freeish-visitors.info get-seo-help.com get-your-social-buttons.info getaadsincome.info getadsincomely.info getlamborghini.ga getpy-click.info getrichquick.ml getrichquickly.info ghazel.ru ghostvisitor.com giftbig.ru girlporn.ru gkvector.ru glavprofit.ru global-smm.ru gobongo.info goodhumor24.com goodprotein.ru google-liar.ru googlemare.com googlsucks.com gorgaz.info grafaman.ru guardlink.org guidetopetersburg.com handicapvantoday.com happysong.ru hard-porn.mobi havepussy.com hawaiisurf.com hdmoviecamera.net hdmoviecams.com healbio.ru healgastro.com homeafrikalike.tk homemypicture.tk hongfanji.com hosting-tracker.com hottour.com housediz.com housemilan.ru howopen.ru howtostopreferralspam.eu hoztorg-opt.ru hseipaa.kz hulfingtonpost.com humanorightswatch.org hundejo.com hvd-store.com hyip-zanoza.me ico.re igadgetsworld.com igru-xbox.net ilikevitaly.com iloveitaly.ro iloveitaly.ru ilovevitaly.co ilovevitaly.com ilovevitaly.info ilovevitaly.org ilovevitaly.ru ilovevitaly.xyz iminent.com imperiafilm.ru impotentik.com incitystroy.ru incomekey.net increasewwwtraffic.info inet-shop.su infektsii.com infodocsportal.com inform-ua.info insider.pro interferencer.ru intex-air.ru investpamm.ru iskalko.ru isotoner.com ispaniya-costa-blanca.ru it-max.com.ua izhstrelok.ru jjbabskoe.ru jobius.com.ua jumkite.com justkillingti.me justprofit.xyz kabbalah-red-bracelets.com kabinet-binbank.ru kabinet-card-5ka.ru kabinet-click-alfabank.ru kabinet-lk-megafon.ru kabinet-login-mts.ru kabinet-mil.ru kabinet-mos.ru kabinet-my-beeline.ru kabinet-my-pochtabank.ru kabinet-online-vtb.ru kabinet-tinkoff.ru kabinet-ttk.ru kakablog.net kambasoft.com kamin-sam.ru karapuz.org.ua kazka.ru kazrent.com kerch.site kevblog.top keywords-monitoring-success.com keywords-monitoring-your-success.com kharkov.ua kino-fun.ru kino-key.info kino2018.cc kinobum.org kinopolet.net kinosed.net knigonosha.net komp-pomosch.ru komputers-best.ru komukc.com.ua konkursov.net kozhasobak.com krasnodar-avtolombard.ru kredytbank.com.ua laminat.com.ua landliver.org landoftracking.com laptop-4-less.com law-check-two.xyz law-enforcement-bot-ff.xyz law-enforcement-check-three.xyz law-enforcement-ee.xyz law-six.xyz laxdrills.com leeboyrussia.com legalrc.biz lerporn.info leto-dacha.ru lider82.ru lipidofobia.com.br littleberry.ru livefixer.com livia-pache.ru livingroomdecoratingideas.website lk-gosuslugi.ru login-tinkoff.ru loveorganic.ch lsex.xyz luckybull.io lukoilcard.ru lumb.co luton-invest.ru luxup.ru magicdiet.gq magnetic-bracelets.ru makemoneyonline.com makeprogress.ga manimpotence.com manualterap.roleforum.ru marblestyle.ru maridan.com.ua marketland.ml masterseek.com matras.space mattgibson.us max-apprais.com maxxximoda.ru mebel-iz-dereva.kiev.ua mebelcomplekt.ru mebeldekor.com.ua med-dopomoga.com med-zdorovie.com.ua medicineseasybuy.com meds-online24.com meduza-consult.ru megapolis-96.ru metallo-konstruktsii.ru metallosajding.ru mifepriston.net mikozstop.com mikrocement.com.ua mikrozaym2you.ru minegam.com mirobuvi.com.ua mirtorrent.net mksport.ru mobilemedia.md mockupui.com modforwot.ru modnie-futbolki.net moinozhki.com monetizationking.net money-for-placing-articles.com money7777.info moneytop.ru moneyzzz.ru mosrif.ru mostorgnerud.ru moy-dokument.com moyakuhnia.ru muscle-factory.com.ua musichallaudio.ru mybuh.kz myftpupload.com myplaycity.com nachalka21.ru nanochskazki.ru needtosellmyhousefast.com net-profits.xyz nevapotolok.ru newsrosprom.ru newstaffadsshop.club niki-mlt.ru nizniynovgorod.dienai.ru novosti-hi-tech.ru nubuilderian.info nufaq.com o-o-11-o-o.com o-o-6-o-o.com o-o-6-o-o.ru o-o-8-o-o.com o-o-8-o-o.ru obsessionphrases.com odiabetikah.com odsadsmobile.biz ofermerah.com office2web.com officedocuments.net ogorodnic.com online-binbank.ru online-hit.info online-intim.com online-mkb.ru online-templatestore.com online-vtb.ru onlinetvseries.me onlywoman.org ooo-olni.ru optsol.ru orakul.spb.ru osteochondrosis.ru ownshop.cf ozas.net paidonlinesites.com palvira.com.ua pc-services.ru perm.dienai.ru perper.ru petrovka-online.com photo-clip.ru photokitchendesign.com picturesmania.com pills24h.com piulatte.cz pizza-imperia.com pizza-tycoon.com pk-pomosch.ru pk-services.ru podarkilove.ru podemnik.pro podseka1.ru poiskzakona.ru pokupaylegko.ru popads.net pops.foundation popugaychiki.com pornhub-forum.ga pornhub-forum.uni.me pornhub-ru.com porno-chaman.info pornoelita.info pornoforadult.com pornogig.com pornohd1080.online pornoklad.ru pornonik.com pornoplen.com portnoff.od.ua pozdravleniya-c.ru priceg.com pricheski-video.com prlog.ru procrafts.ru prodaemdveri.com producm.ru prodvigator.ua professionalsolutions.eu prointer.net.ua promoforum.ru pron.pro prosmibank.ru prostitutki-rostova.ru.com psa48.ru punch.media purchasepillsnorx.com qualitymarketzone.com quit-smoking.ga qwesa.ru rank-checker.online rankings-analytics.com ranksonic.info ranksonic.net ranksonic.org rapidgator-porn.ga rapidsites.pro razborka-skoda.org.ua rcb101.ru realresultslist.com rednise.com regionshop.biz releshop.ru remkompov.ru remont-kvartirspb.com rent2spb.ru replica-watch.ru research.ifmo.ru resell-seo-services.com resellerclub.com responsive-test.net reversing.cc rfavon.ru rightenergysolutions.com.au roof-city.ru rospromtest.ru ru-lk-rt.ru ruinfocomp.ru rulate.ru rumamba.com rupolitshow.ru rusexy.xyz ruspoety.ru russian-postindex.ru russian-translator.com rybalka-opt.ru sad-torg.com.ua sady-urala.ru saltspray.ru sanjosestartups.com santaren.by santasgift.ml santehnovich.ru savetubevideo.com savetubevideo.info scansafe.net scat.porn screentoolkit.com scripted.com search-error.com searchencrypt.com security-corporation.com.ua sell-fb-group-here.com semalt.com semaltmedia.com seo-2-0.com seo-platform.com seo-smm.kz seoanalyses.com seocheckupx.com seocheckupx.net seoexperimenty.ru seojokes.net seopub.net seoservices2018.com sexsaoy.com sexyali.com sexyteens.hol.es shagtomsk.ru share-buttons-for-free.com share-buttons.xyz sharebutton.io sharebutton.net sharebutton.to shnyagi.net shoppingmiracles.co.uk shops-ru.ru sibecoprom.ru sim-dealer.ru simple-share-buttons.com sinhronperevod.ru site-auditor.online site5.com siteripz.net sitevaluation.org skinali.com sladkoevideo.com sledstvie-veli.net slftsdybbg.ru slkrm.ru slomm.ru slow-website.xyz smailik.org smartphonediscount.info snabs.kz snegozaderzhatel.ru snip.to snip.tw soaksoak.ru sochi-3d.ru social-button.xyz social-buttons-ii.xyz social-buttons.com social-traffic-1.xyz social-traffic-2.xyz social-traffic-3.xyz social-traffic-4.xyz social-traffic-5.xyz social-traffic-7.xyz social-widget.xyz socialbuttons.xyz socialseet.ru socialtrade.biz sohoindia.net solitaire-game.ru solnplast.ru sosdepotdebilan.com souvenirua.com sovetskie-plakaty.ru soyuzexpedition.ru sp-laptop.ru sp-zakupki.ru spb-plitka.ru spb-scenar.ru speedup-my.site spin2016.cf sportwizard.ru spravka130.ru spravkavspb.net sribno.net stavimdveri.ru steame.ru stiralkovich.ru stocktwists.com store-rx.com stream-tds.com stroyka47.ru studentguide.ru success-seo.com sundrugstore.com superiends.org supermama.top supervesti.ru svetka.info svetoch.moscow t-machinery.ru t-rec.su taihouse.ru tattoo-stickers.ru tattooha.com td-perimetr.ru technika-remont.ru tedxrj.com tentcomplekt.ru teplohod-gnezdo.ru texnika.com.ua tgtclick.com thaoduoctoc.com theautoprofit.ml theguardlan.com thesmartsearch.net tokshow.online tomck.com top-gan.ru top-l2.com top1-seo-service.com top10-way.com topquality.cf topseoservices.co track-rankings.online tracker24-gps.ru traffic-cash.xyz traffic2cash.org traffic2cash.xyz traffic2money.com trafficgenius.xyz trafficmonetize.org trafficmonetizer.org traphouselatino.net trion.od.ua tsatu.edu.ua tsc-koleso.ru tuningdom.ru twsufa.ru ua.tc uasb.ru ucoz.ru udav.net ufa.dienai.ru ukrainian-poetry.com ul-potolki.ru undergroundcityphoto.com unibus.su univerfiles.com unlimitdocs.net unpredictable.ga uptime-as.net uptime-eu.net uptime-us.net uptime.com uptimechecker.com uzpaket.com uzungil.com vaderenergy.ru validus.pro varikozdok.ru veloland.in.ua ventopt.by veselokloun.ru vesnatehno.com viagra-soft.ru video--production.com video-woman.com videos-for-your-business.com viel.su viktoria-center.ru vodaodessa.com vodkoved.ru vzheludke.com vzubkah.com w3javascript.com wallpaperdesk.info wdss.com.ua we-ping-for-youic.info web-revenue.xyz webmaster-traffic.com webmonetizer.net website-analytics.online website-analyzer.info website-speed-check.site website-speed-checker.site websites-reviews.com websocial.me weburlopener.com wmasterlead.com woman-orgasm.ru wordpress-crew.net wordpresscore.com workius.ru works.if.ua worldmed.info wufak.com ww2awards.info www-lk-rt.ru x5market.ru xkaz.org xn-------53dbcapga5atlplfdm6ag1ab1bvehl0b7toa0k.xn--p1ai xn-----6kcamwewcd9bayelq.xn--p1ai xn-----7kcaaxchbbmgncr7chzy0k0hk.xn--p1ai xn-----clckdac3bsfgdft3aebjp5etek.xn--p1ai xn----7sbabhjc3ccc5aggbzfmfi.xn--p1ai xn----7sbabm1ahc4b2aqff.su xn----7sbabn5abjehfwi8bj.xn--p1ai xn----7sbbpe3afguye.xn--p1ai xn----7sbho2agebbhlivy.xn--p1ai xn----8sbaki4azawu5b.xn--p1ai xn----8sbarihbihxpxqgaf0g1e.xn--80adxhks xn----8sbhefaln6acifdaon5c6f4axh.xn--p1ai xn----8sblgmbj1a1bk8l.xn----161-4vemb6cjl7anbaea3afninj.xn--p1ai xn----ctbbcjd3dbsehgi.xn--p1ai xn----ctbfcdjl8baejhfb1oh.xn--p1ai xn----ctbigni3aj4h.xn--p1ai xn----ftbeoaiyg1ak1cb7d.xn--p1ai xn----itbbudqejbfpg3l.com xn--80aaajkrncdlqdh6ane8t.xn--p1ai xn--80aanaardaperhcem4a6i.com xn--80adaggc5bdhlfamsfdij4p7b.xn--p1ai xn--80adgcaax6acohn6r.xn--p1ai xn--90acenikpebbdd4f6d.xn--p1ai xn--90acjmaltae3acm.xn--p1acf xn--c1acygb.xn--p1ai xn--d1abj0abs9d.in.ua xn--d1aifoe0a9a.top xn--e1aaajzchnkg.ru.com xn--e1agf4c.xn--80adxhks xtrafficplus.com xz618.com yaderenergy.ru yes-com.com yhirurga.ru ykecwqlixx.ru yodse.io youporn-forum.ga youporn-forum.uni.me youporn-ru.com yourserverisdown.com zahvat.ru zastroyka.org zavod-gm.ru zdm-auto.com zdorovie-nogi.info zelena-mriya.com.ua zoominfo.com zvetki.ru ================================================ FILE: pkg/aggregator/store.go ================================================ package aggregator import ( "fmt" "strings" "time" "github.com/usefathom/fathom/pkg/datastore" "github.com/usefathom/fathom/pkg/models" ) func (agg *Aggregator) getSiteStats(r *results, siteID int64, t time.Time) (*models.SiteStats, error) { cacheKey := fmt.Sprintf("%d-%s", siteID, t.Format("2006-01-02T15")) if stats, ok := r.Sites[cacheKey]; ok { return stats, nil } // get from db stats, err := agg.database.GetSiteStats(siteID, t) if err != nil && err != datastore.ErrNoResults { return nil, err } if stats == nil { stats = &models.SiteStats{ SiteID: siteID, New: true, Date: t, } } r.Sites[cacheKey] = stats return stats, nil } func (agg *Aggregator) getPageStats(r *results, siteID int64, t time.Time, hostname string, pathname string) (*models.PageStats, error) { cacheKey := fmt.Sprintf("%d-%s-%s-%s", siteID, t.Format("2006-01-02T15"), hostname, pathname) if stats, ok := r.Pages[cacheKey]; ok { return stats, nil } hostnameID, err := agg.database.HostnameID(hostname) if err != nil { return nil, err } pathnameID, err := agg.database.PathnameID(pathname) if err != nil { return nil, err } stats, err := agg.database.GetPageStats(siteID, t, hostnameID, pathnameID) if err != nil && err != datastore.ErrNoResults { return nil, err } if stats == nil { stats = &models.PageStats{ SiteID: siteID, New: true, HostnameID: hostnameID, PathnameID: pathnameID, Date: t, } } r.Pages[cacheKey] = stats return stats, nil } func (agg *Aggregator) getReferrerStats(r *results, siteID int64, t time.Time, hostname string, pathname string) (*models.ReferrerStats, error) { cacheKey := fmt.Sprintf("%d-%s-%s-%s", siteID, t.Format("2006-01-02T15"), hostname, pathname) if stats, ok := r.Referrers[cacheKey]; ok { return stats, nil } hostnameID, err := agg.database.HostnameID(hostname) if err != nil { return nil, err } pathnameID, err := agg.database.PathnameID(pathname) if err != nil { return nil, err } // get from db stats, err := agg.database.GetReferrerStats(siteID, t, hostnameID, pathnameID) if err != nil && err != datastore.ErrNoResults { return nil, err } if stats == nil { stats = &models.ReferrerStats{ SiteID: siteID, New: true, HostnameID: hostnameID, PathnameID: pathnameID, Date: t, Group: "", } if strings.Contains(hostname, "www.google.") { stats.Group = "Google" } else if strings.Contains(stats.Hostname, "www.bing.") { stats.Group = "Bing" } else if strings.Contains(stats.Hostname, "www.baidu.") { stats.Group = "Baidu" } else if strings.Contains(stats.Hostname, "www.yandex.") { stats.Group = "Yandex" } else if strings.Contains(stats.Hostname, "search.yahoo.") { stats.Group = "Yahoo!" } else if strings.Contains(stats.Hostname, "www.findx.") { stats.Group = "Findx" } } r.Referrers[cacheKey] = stats return stats, nil } ================================================ FILE: pkg/api/api.go ================================================ package api import ( "github.com/gorilla/sessions" "github.com/usefathom/fathom/pkg/datastore" ) type API struct { database datastore.Datastore sessions sessions.Store } // New instantiates a new API object func New(db datastore.Datastore, secret string) *API { return &API{ database: db, sessions: sessions.NewCookieStore([]byte(secret)), } } ================================================ FILE: pkg/api/auth.go ================================================ package api import ( "encoding/json" "net/http" "strings" gcontext "github.com/gorilla/context" "github.com/usefathom/fathom/pkg/datastore" ) type key int const ( userKey key = 0 ) type login struct { Email string `json:"email"` Password string `json:"password"` } func (l *login) Sanitize() { l.Email = strings.ToLower(strings.TrimSpace(l.Email)) } // GET /api/session func (api *API) GetSession(w http.ResponseWriter, r *http.Request) error { userCount, err := api.database.CountUsers() if err != nil { return err } // if 0 users in database, dashboard is public if userCount == 0 { return respond(w, http.StatusOK, envelope{Data: true}) } // if existing session, assume logged-in session, _ := api.sessions.Get(r, "auth") if !session.IsNew { return respond(w, http.StatusOK, envelope{Data: true}) } // otherwise: not logged-in yet return respond(w, http.StatusOK, envelope{Data: false}) } // URL: POST /api/session func (api *API) CreateSession(w http.ResponseWriter, r *http.Request) error { // check login creds var l login err := json.NewDecoder(r.Body).Decode(&l) if err != nil { return err } l.Sanitize() // find user with given email u, err := api.database.GetUserByEmail(l.Email) if err != nil && err != datastore.ErrNoResults { return err } // compare pwd if err == datastore.ErrNoResults || u.ComparePassword(l.Password) != nil { return respond(w, http.StatusUnauthorized, envelope{Error: "invalid_credentials"}) } // ignore error here as we want a (new) session regardless session, _ := api.sessions.Get(r, "auth") session.Values["user_id"] = u.ID err = session.Save(r, w) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: true}) } // URL: DELETE /api/session func (api *API) DeleteSession(w http.ResponseWriter, r *http.Request) error { session, _ := api.sessions.Get(r, "auth") if !session.IsNew { session.Options.MaxAge = -1 err := session.Save(r, w) if err != nil { return err } } return respond(w, http.StatusOK, envelope{Data: true}) } // Authorize is middleware that aborts the request if unauthorized func (api *API) Authorize(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // clear context from request after it is handled // see http://www.gorillatoolkit.org/pkg/sessions#overview defer gcontext.Clear(r) // first count users in datastore // if 0, assume dashboard is public userCount, err := api.database.CountUsers() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if userCount > 0 { session, err := api.sessions.Get(r, "auth") // an err is returned if cookie has been tampered with, so check that if err != nil { respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) return } userID, ok := session.Values["user_id"] if session.IsNew || !ok { respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) return } // validate user ID in session if _, err := api.database.GetUser(userID.(int64)); err != nil { respond(w, http.StatusUnauthorized, envelope{Error: "unauthorized"}) return } } next.ServeHTTP(w, r) }) } ================================================ FILE: pkg/api/auth_test.go ================================================ package api import "testing" func TestLoginSanitize(t *testing.T) { rawEmail := "Foo@foobar.com " l := &login{ Email: rawEmail, } l.Sanitize() if l.Email != "foo@foobar.com" { t.Errorf("Expected normalized email address, got %s", l.Email) } } ================================================ FILE: pkg/api/collect.go ================================================ package api import ( "encoding/base64" "net/http" "net/url" "strings" "time" "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/mssola/user_agent" "github.com/usefathom/fathom/pkg/aggregator" "github.com/usefathom/fathom/pkg/datastore" "github.com/usefathom/fathom/pkg/models" ) type Collector struct { Store datastore.Datastore Pageviews chan *models.Pageview // buffer vars updates []*models.Pageview inserts []*models.Pageview sizeu int sizei int } func NewCollector(store datastore.Datastore) *Collector { bufferCap := 100 // persist every 100 pageviews, see https://github.com/usefathom/fathom/issues/132 bufferTimeout := 1000 * time.Millisecond // or every 1000 ms, whichever comes first c := &Collector{ Store: store, Pageviews: make(chan *models.Pageview), updates: make([]*models.Pageview, bufferCap), inserts: make([]*models.Pageview, bufferCap), sizeu: 0, sizei: 0, } go c.aggregate() go c.worker(bufferCap, bufferTimeout) return c } func (c *Collector) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !shouldCollect(r) { return } q := r.URL.Query() now := time.Now() pageview := &models.Pageview{ ID: uuid.NewString()[0:30], SiteTrackingID: q.Get("sid"), Hostname: parseHostname(q.Get("h")), Pathname: parsePathname(q.Get("p")), IsNewVisitor: q.Get("nv") == "1", IsNewSession: q.Get("ns") == "1", IsUnique: q.Get("u") == "1", Referrer: q.Get("r"), IsFinished: false, IsBounce: true, Duration: 0, Timestamp: now, } // push pageview onto channel to be inserted (in batch) later c.Pageviews <- pageview // indicate that we're not tracking user data, see https://github.com/usefathom/fathom/issues/65 w.Header().Set("Tk", "N") // headers to prevent caching w.Header().Set("Content-Type", "image/gif") w.Header().Set("Expires", "Mon, 01 Jan 1990 00:00:00 GMT") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") // response, 1x1 px transparent GIF w.WriteHeader(http.StatusOK) b, _ := base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") w.Write(b) // find previous pageview by same visitor previousPageviewID := q.Get("pid") if !pageview.IsNewSession && previousPageviewID != "" { previousPageview, err := c.Store.GetPageview(previousPageviewID) if err != nil && err != datastore.ErrNoResults { log.Errorf("error getting previous pageview: %s", err) return } // if we have a recent pageview that is less than 30 minutes old if previousPageview != nil && previousPageview.Timestamp.After(now.Add(-30*time.Minute)) { previousPageview.Duration = (now.Unix() - previousPageview.Timestamp.Unix()) previousPageview.IsBounce = false previousPageview.IsFinished = true // push onto channel to be updated (in batch) later c.Pageviews <- previousPageview } } } func (c *Collector) aggregate() { var report aggregator.Report agg := aggregator.New(c.Store) timeout := 1 * time.Minute agg.Run() for { select { case <-time.After(timeout): // run aggregator at least once report = agg.Run() // if pool is not empty yet, keep running for !report.PoolEmpty { report = agg.Run() } } } } func (c *Collector) worker(cap int, timeout time.Duration) { var size int for { select { // persist pageviews in buffer when buffer at capacity case p := <-c.Pageviews: size = c.buffer(p) if size >= cap { c.persist() } // or after timeout passed case <-time.After(timeout): c.persist() } } } func (c *Collector) buffer(p *models.Pageview) int { if !p.IsFinished { c.inserts[c.sizei] = p c.sizei++ } else { c.updates[c.sizeu] = p c.sizeu++ } return (c.sizeu + c.sizei) } func (c *Collector) persist() { if (c.sizeu + c.sizei) == 0 { return } log.Debugf("persisting %d pageviews (%d inserts, %d updates)", (c.sizeu + c.sizei), c.sizei, c.sizeu) if err := c.Store.InsertPageviews(c.inserts[0:c.sizei]); err != nil { log.Errorf("error inserting pageviews: %s", err) } if err := c.Store.UpdatePageviews(c.updates[0:c.sizeu]); err != nil { log.Errorf("error updating pageviews: %s", err) } // reset buffer c.sizei = 0 c.sizeu = 0 } func shouldCollect(r *http.Request) bool { // abort if DNT header is set to "1" (these should have been filtered client-side already) if r.Header.Get("DNT") == "1" { return false } // don't track prerendered pages, see https://github.com/usefathom/fathom/issues/13 if r.Header.Get("X-Moz") == "prefetch" || r.Header.Get("X-Purpose") == "preview" { return false } // abort if this is a bot. ua := user_agent.New(r.UserAgent()) if ua.Bot() { return false } // discard if required query vars are missing requiredQueryVars := []string{"h", "p"} q := r.URL.Query() for _, k := range requiredQueryVars { if q.Get(k) == "" { return false } } return true } func parsePathname(p string) string { return "/" + strings.TrimLeft(strings.TrimRight(p, "/"), "/") } func parseHostname(r string) string { u, err := url.Parse(r) if err != nil { return "" } return u.Scheme + "://" + u.Host } ================================================ FILE: pkg/api/collect_test.go ================================================ package api import ( "net/http" "testing" ) func TestShouldCollect(t *testing.T) { r, _ := http.NewRequest("GET", "/", nil) r.Header.Add("User-Agent", "Mozilla/1.0") r.Header.Add("Referer", "http://usefathom.com/") if v := shouldCollect(r); v != false { t.Errorf("Expected %#v, got %#v", true, false) } } func TestParsePathname(t *testing.T) { if v := parsePathname("/"); v != "/" { t.Errorf("error parsing pathname. expected %#v, got %#v", "/", v) } if v := parsePathname("about"); v != "/about" { t.Errorf("error parsing pathname. expected %#v, got %#v", "/about", v) } if v := parsePathname("about/"); v != "/about" { t.Errorf("error parsing pathname. expected %#v, got %#v", "/about", v) } } func TestParseHostname(t *testing.T) { e := "https://usefathom.com" if v := parseHostname("https://usefathom.com"); v != e { t.Errorf("error parsing hostname. expected %#v, got %#v", e, v) } e = "http://usefathom.com" if v := parseHostname("http://usefathom.com"); v != e { t.Errorf("error parsing hostname. expected %#v, got %#v", e, v) } } ================================================ FILE: pkg/api/health.go ================================================ package api import "net/http" // GET /health func (api *API) Health(w http.ResponseWriter, _ *http.Request) error { if err := api.database.Health(); err != nil { w.WriteHeader(http.StatusServiceUnavailable) return err } w.WriteHeader(http.StatusOK) return nil } ================================================ FILE: pkg/api/http.go ================================================ package api import ( "encoding/json" "net/http" "github.com/gobuffalo/packr/v2" log "github.com/sirupsen/logrus" ) // Handler is our custom HTTP handler with error returns type Handler func(w http.ResponseWriter, r *http.Request) error type envelope struct { Data interface{} `json:",omitempty"` Error interface{} `json:",omitempty"` } func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if err := h(w, r); err != nil { HandleError(w, r, err) } } // HandlerFunc takes a custom Handler func and converts it to http.HandlerFunc func HandlerFunc(fn Handler) http.HandlerFunc { return http.HandlerFunc(Handler(fn).ServeHTTP) } // HandleError handles errors func HandleError(w http.ResponseWriter, r *http.Request, err error) { log.WithFields(log.Fields{ "request": r.Method + " " + r.RequestURI, "error": err, }).Error("error handling request") w.WriteHeader(http.StatusInternalServerError) w.Header().Set("Content-Type", "application/json") w.Write([]byte("false")) } func respond(w http.ResponseWriter, statusCode int, d interface{}) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) err := json.NewEncoder(w).Encode(d) return err } func serveFileHandler(box *packr.Box, filename string) http.Handler { return HandlerFunc(serveFile(box, filename)) } func serveFile(box *packr.Box, filename string) Handler { return func(w http.ResponseWriter, r *http.Request) error { f, err := box.Open(filename) if err != nil { return err } defer f.Close() d, err := f.Stat() if err != nil { return err } // setting security and cache headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Xss-Protection", "1; mode=block") w.Header().Set("Cache-Control", "max-age=432000") // 5 days http.ServeContent(w, r, filename, d.ModTime(), f) return nil } } func NotFoundHandler(box *packr.Box) http.Handler { return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusNotFound) w.Write(box.Bytes("404.html")) return nil }) } ================================================ FILE: pkg/api/http_test.go ================================================ package api import ( "encoding/json" "net/http" "net/http/httptest" "testing" ) func TestRespond(t *testing.T) { w := httptest.NewRecorder() respond(w, http.StatusOK, 15) if w.Code != 200 { t.Errorf("Invalid response code") } // assert json header if w.Header().Get("Content-Type") != "application/json" { t.Errorf("Invalid response header for Content-Type") } // assert json response var d int err := json.NewDecoder(w.Body).Decode(&d) if err != nil { t.Errorf("Invalid response body: %s", err) } } ================================================ FILE: pkg/api/page_stats.go ================================================ package api import ( "net/http" ) // URL: /api/sites/{id:[0-9]+}/stats/pages/agg func (api *API) GetAggregatedPageStatsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.SelectAggregatedPageStats(params.SiteID, params.StartDate, params.EndDate, params.Offset, params.Limit) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } func (api *API) GetAggregatedPageStatsPageviewsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.GetAggregatedPageStatsPageviews(params.SiteID, params.StartDate, params.EndDate) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } ================================================ FILE: pkg/api/params.go ================================================ package api import ( "net/http" "strconv" "time" "github.com/gorilla/mux" ) // Params defines the commonly used API parameters type Params struct { SiteID int64 Offset int Limit int StartDate time.Time EndDate time.Time } // GetRequestParams parses the query parameters and returns commonly used API parameters, with defaults func GetRequestParams(r *http.Request) *Params { params := &Params{ SiteID: 0, Limit: 20, Offset: 0, StartDate: time.Now(), EndDate: time.Now().AddDate(0, 0, -7), } vars := mux.Vars(r) if _, ok := vars["id"]; ok { if siteID, err := strconv.ParseInt(vars["id"], 10, 64); err == nil { params.SiteID = siteID } } q := r.URL.Query() if q.Get("after") != "" { if after, err := strconv.ParseInt(q.Get("after"), 10, 64); err == nil && after > 0 { params.StartDate = time.Unix(after, 0) } } if q.Get("before") != "" { if before, err := strconv.ParseInt(q.Get("before"), 10, 64); err == nil && before > 0 { params.EndDate = time.Unix(before, 0) } } if q.Get("limit") != "" { if limit, err := strconv.Atoi(q.Get("limit")); err == nil && limit > 0 { params.Limit = limit } } if q.Get("offset") != "" { if offset, err := strconv.Atoi(q.Get("offset")); err == nil && offset > 0 { params.Offset = offset } } return params } ================================================ FILE: pkg/api/params_test.go ================================================ package api import ( "fmt" "net/http" "testing" "time" ) func TestGetRequestParams(t *testing.T) { startDate := time.Now().AddDate(0, 0, -12) endDate := time.Now().AddDate(0, 0, -5) limit := 50 url := fmt.Sprintf("/?after=%d&before=%d&limit=%d", startDate.Unix(), endDate.Unix(), limit) r, _ := http.NewRequest("GET", url, nil) params := GetRequestParams(r) if params.Limit != 50 { t.Errorf("Expected %#v, got %#v", 50, params.Limit) } if startDate.Unix() != params.StartDate.Unix() { t.Errorf("Expected %#v, got %#v", startDate.Format("2006-01-02 15:04"), params.StartDate.Format("2006-01-02 15:04")) } if params.EndDate.Unix() != endDate.Unix() { t.Errorf("Expected %#v, got %#v", endDate.Format("2006-01-02 15:04"), params.EndDate.Format("2006-01-02 15:04")) } } ================================================ FILE: pkg/api/referrer_stats.go ================================================ package api import ( "net/http" ) func (api *API) GetAggregatedReferrerStatsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.SelectAggregatedReferrerStats(params.SiteID, params.StartDate, params.EndDate, params.Offset, params.Limit) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } func (api *API) GetAggregatedReferrerStatsPageviewsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.GetAggregatedReferrerStatsPageviews(params.SiteID, params.StartDate, params.EndDate) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } ================================================ FILE: pkg/api/routes.go ================================================ package api import ( "net/http" "github.com/gobuffalo/packr/v2" "github.com/gorilla/mux" ) func (api *API) Routes() *mux.Router { // register routes r := mux.NewRouter() r.Handle("/collect", NewCollector(api.database)).Methods(http.MethodGet) r.Handle("/api/session", HandlerFunc(api.GetSession)).Methods(http.MethodGet) r.Handle("/api/session", HandlerFunc(api.CreateSession)).Methods(http.MethodPost) r.Handle("/api/session", HandlerFunc(api.DeleteSession)).Methods(http.MethodDelete) r.Handle("/api/sites", api.Authorize(HandlerFunc(api.GetSitesHandler))).Methods(http.MethodGet) r.Handle("/api/sites", api.Authorize(HandlerFunc(api.SaveSiteHandler))).Methods(http.MethodPost) r.Handle("/api/sites/{id:[0-9]+}", api.Authorize(HandlerFunc(api.SaveSiteHandler))).Methods(http.MethodPost) r.Handle("/api/sites/{id:[0-9]+}", api.Authorize(HandlerFunc(api.DeleteSiteHandler))).Methods(http.MethodDelete) r.Handle("/api/sites/{id:[0-9]+}/stats/site", api.Authorize(HandlerFunc(api.GetSiteStatsHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/site/agg", api.Authorize(HandlerFunc(api.GetAggregatedSiteStatsHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/site/realtime", api.Authorize(HandlerFunc(api.GetSiteStatsRealtimeHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/pages/agg", api.Authorize(HandlerFunc(api.GetAggregatedPageStatsHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/pages/agg/pageviews", api.Authorize(HandlerFunc(api.GetAggregatedPageStatsPageviewsHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/referrers/agg", api.Authorize(HandlerFunc(api.GetAggregatedReferrerStatsHandler))).Methods(http.MethodGet) r.Handle("/api/sites/{id:[0-9]+}/stats/referrers/agg/pageviews", api.Authorize(HandlerFunc(api.GetAggregatedReferrerStatsPageviewsHandler))).Methods(http.MethodGet) r.Handle("/health", HandlerFunc(api.Health)).Methods(http.MethodGet) // static assets & 404 handler box := packr.NewBox("./../../assets/build") r.Path("/tracker.js").Handler(serveTrackerFile(box)) r.Path("/").Handler(serveFileHandler(box, "index.html")) r.Path("/index.html").Handler(serveFileHandler(box, "index.html")) r.PathPrefix("/assets").Handler(http.StripPrefix("/assets", http.FileServer(box))) r.NotFoundHandler = NotFoundHandler(box) return r } func serveTrackerFile(box *packr.Box) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Tk", "N") next := serveFile(box, "js/tracker.js") next.ServeHTTP(w, r) }) } ================================================ FILE: pkg/api/site_stats.go ================================================ package api import ( "net/http" ) // URL: /api/sites/{id:[0-9]+}/stats/site/agg func (api *API) GetAggregatedSiteStatsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.GetAggregatedSiteStats(params.SiteID, params.StartDate, params.EndDate) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } // URL: /api/sites/{id:[0-9]+}/stats/site/realtime func (api *API) GetSiteStatsRealtimeHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.GetRealtimeVisitorCount(params.SiteID) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } // URL: /api/sites/{id:[0-9]+}/stats/site func (api *API) GetSiteStatsHandler(w http.ResponseWriter, r *http.Request) error { params := GetRequestParams(r) result, err := api.database.SelectSiteStats(params.SiteID, params.StartDate, params.EndDate) if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } ================================================ FILE: pkg/api/sites.go ================================================ package api import ( "encoding/json" "math/rand" "net/http" "strconv" "time" "github.com/gorilla/mux" "github.com/usefathom/fathom/pkg/models" ) // seed rand pkg on program init func init() { rand.Seed(time.Now().UTC().UnixNano()) } // GET /api/sites func (api *API) GetSitesHandler(w http.ResponseWriter, r *http.Request) error { result, err := api.database.GetSites() if err != nil { return err } return respond(w, http.StatusOK, envelope{Data: result}) } // POST /api/sites // POST /api/sites/{id} func (api *API) SaveSiteHandler(w http.ResponseWriter, r *http.Request) error { var s *models.Site vars := mux.Vars(r) sid, ok := vars["id"] if ok { id, err := strconv.ParseInt(sid, 10, 64) if err != nil { return err } s, err = api.database.GetSite(id) if err != nil { return err } } else { s = &models.Site{ TrackingID: generateTrackingID(), } } err := json.NewDecoder(r.Body).Decode(s) if err != nil { return err } if err := api.database.SaveSite(s); err != nil { return err } return respond(w, http.StatusOK, envelope{Data: s}) } // DELETE /api/sites/{id} func (api *API) DeleteSiteHandler(w http.ResponseWriter, r *http.Request) error { vars := mux.Vars(r) id, err := strconv.ParseInt(vars["id"], 10, 64) if err != nil { return err } if err := api.database.DeleteSite(&models.Site{ID: id}); err != nil { return err } return respond(w, http.StatusOK, envelope{Data: true}) } func generateTrackingID() string { return randomString(5) } func randomString(len int) string { bytes := make([]byte, len) for i := 0; i < len; i++ { bytes[i] = byte(65 + rand.Intn(25)) //a=65 and z = 65+25 } return string(bytes) } ================================================ FILE: pkg/cli/cli.go ================================================ package cli import ( "fmt" "os" "strings" "time" log "github.com/sirupsen/logrus" "github.com/urfave/cli" "github.com/usefathom/fathom/pkg/config" "github.com/usefathom/fathom/pkg/datastore" ) type App struct { *cli.App database datastore.Datastore config *config.Config } // CLI application var app *App // Run parses the CLI arguments & run application command func Run(version string, commit string, buildDate string) error { // force all times in UTC, regardless of server timezone time.Local = time.UTC // setup CLI app app = &App{cli.NewApp(), nil, nil} app.Name = "Fathom" app.Usage = "simple & transparent website analytics" app.Version = fmt.Sprintf("%v, commit %v, built at %v", strings.TrimPrefix(version, "v"), commit, buildDate) app.HelpName = "fathom" app.Flags = []cli.Flag{ cli.StringFlag{ Name: "config, c", Value: ".env", Usage: "Load configuration from `FILE`", }, } app.Before = before app.After = after app.Commands = []cli.Command{ serverCmd, userCmd, statsCmd, } if len(os.Args) < 2 || os.Args[1] != "--version" { log.Printf("%s version %s", app.Name, app.Version) } err := app.Run(os.Args) if err != nil { return err } return nil } func before(c *cli.Context) error { configFile := c.String("config") config.LoadEnv(configFile) app.config = config.Parse() app.database = datastore.New(app.config.Database) return nil } func after(c *cli.Context) error { err := app.database.Close() return err } ================================================ FILE: pkg/cli/server.go ================================================ package cli import ( "net/http" "strconv" "time" log "github.com/sirupsen/logrus" "github.com/urfave/cli" "github.com/gorilla/handlers" "github.com/usefathom/fathom/pkg/api" "golang.org/x/crypto/acme/autocert" ) var serverCmd = cli.Command{ Name: "server", Aliases: []string{"s"}, Usage: "start the fathom web server", Action: server, Flags: []cli.Flag{ cli.StringFlag{ EnvVar: "FATHOM_SERVER_ADDR,PORT", Name: "addr,port", Usage: "server address", Value: ":8080", }, cli.BoolFlag{ EnvVar: "FATHOM_LETS_ENCRYPT", Name: "lets-encrypt", }, cli.BoolFlag{ EnvVar: "FATHOM_GZIP", Name: "gzip", Usage: "enable gzip compression", }, cli.StringFlag{ EnvVar: "FATHOM_HOSTNAME", Name: "hostname", Usage: "domain when using --lets-encrypt", }, cli.BoolFlag{ EnvVar: "FATHOM_DEBUG", Name: "debug, d", }, }, } func server(c *cli.Context) error { var h http.Handler a := api.New(app.database, app.config.Secret) h = a.Routes() // set debug log level if --debug was passed if c.Bool("debug") { log.SetLevel(log.DebugLevel) } else { log.SetLevel(log.WarnLevel) } // set gzip compression if --gzip was passed if c.Bool("gzip") { h = handlers.CompressHandler(h) } // if addr looks like a number, prefix with : addr := c.String("addr") if _, err := strconv.Atoi(addr); err == nil { addr = ":" + addr } // start server without letsencrypt / tls enabled if !c.Bool("lets-encrypt") { // start listening server := &http.Server{ Addr: addr, Handler: h, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } log.Infof("Server is now listening on %s", server.Addr) log.Fatal(server.ListenAndServe()) return nil } // start server with autocert (letsencrypt) hostname := c.String("hostname") log.Infof("Server is now listening on %s:443", hostname) log.Fatal(http.Serve(autocert.NewListener(hostname), h)) return nil } ================================================ FILE: pkg/cli/stats.go ================================================ package cli import ( "encoding/json" "errors" "fmt" "os" "time" "github.com/urfave/cli" ) var statsCmd = cli.Command{ Name: "stats", Usage: "view stats", Action: stats, Flags: []cli.Flag{ cli.Int64Flag{ Name: "site-id", Usage: "ID of the site to retrieve stats for", }, cli.StringFlag{ Name: "start-date", Usage: "start date, expects a date in format 2006-01-02", }, cli.StringFlag{ Name: "end-date", Usage: "end date, expects a date in format 2006-01-02", }, cli.BoolFlag{ Name: "json", Usage: "get a json response", }, }, } func stats(c *cli.Context) error { start, _ := time.Parse("2006-01-02", c.String("start-date")) if start.IsZero() { return errors.New("Invalid argument: supply a valid --start-date") } end, _ := time.Parse("2006-01-02", c.String("end-date")) if end.IsZero() { return errors.New("Invalid argument: supply a valid --end-date") } // TODO: add method for getting total sum of pageviews across sites siteID := c.Int64("site-id") result, err := app.database.GetAggregatedSiteStats(siteID, start, end) if err != nil { return err } if c.Bool("json") { return json.NewEncoder(os.Stdout).Encode(result) } fmt.Printf("%s - %s\n", start.Format("Jan 01, 2006"), end.Format("Jan 01, 2006")) fmt.Printf("===========================\n") fmt.Printf("Visitors: \t%d\n", result.Visitors) fmt.Printf("Pageviews: \t%d\n", result.Pageviews) fmt.Printf("Sessions: \t%d\n", result.Sessions) fmt.Printf("Avg duration: \t%s\n", result.FormattedDuration()) fmt.Printf("Bounce rate: \t%.0f%%\n", result.BounceRate*100.00) return nil } ================================================ FILE: pkg/cli/user.go ================================================ package cli import ( "errors" "fmt" "github.com/usefathom/fathom/pkg/models" log "github.com/sirupsen/logrus" "github.com/urfave/cli" "github.com/usefathom/fathom/pkg/datastore" ) var userCmd = cli.Command{ Name: "user", Usage: "manage registered admin users", Action: userAdd, Subcommands: []cli.Command{ cli.Command{ Name: "add", Aliases: []string{"register"}, Action: userAdd, Flags: []cli.Flag{ cli.StringFlag{ Name: "email, e", Usage: "user email", }, cli.StringFlag{ Name: "password, p", Usage: "user password", }, cli.BoolFlag{ Name: "skip-bcrypt", Usage: "store password string as-is, skipping bcrypt", }, }, }, cli.Command{ Name: "delete", Action: userDelete, Flags: []cli.Flag{ cli.StringFlag{ Name: "email, e", Usage: "user email", }, }, }, }, } func userAdd(c *cli.Context) error { email := c.String("email") if email == "" { return errors.New("Invalid arguments: missing email") } password := c.String("password") if password == "" { return errors.New("Invalid arguments: missing password") } _, err := app.database.GetUserByEmail(email) if err != nil { if err == datastore.ErrNoResults { user := models.NewUser(email, password) // set password manually if --skip-bcrypt was given // this is used to supply an already encrypted password string if c.Bool("skip-bcrypt") { user.Password = password } if err := app.database.SaveUser(&user); err != nil { return fmt.Errorf("Error creating user: %s", err) } log.Infof("Created user %s", user.Email) return nil } return err } log.Infof("A user with this email %s already exists", email) return nil } func userDelete(c *cli.Context) error { email := c.String("email") if email == "" { return errors.New("Invalid arguments: missing email") } user, err := app.database.GetUserByEmail(email) if err != nil { if err == datastore.ErrNoResults { return fmt.Errorf("No user with email %s", email) } return err } if err := app.database.DeleteUser(user); err != nil { return err } log.Infof("Deleted user %s", user.Email) return nil } ================================================ FILE: pkg/config/config.go ================================================ package config import ( "math/rand" "net/url" "os" "path/filepath" "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" log "github.com/sirupsen/logrus" "github.com/usefathom/fathom/pkg/datastore/sqlstore" ) // Config wraps the configuration structs for the various application parts type Config struct { Database *sqlstore.Config Secret string } // LoadEnv loads env values from the supplied file func LoadEnv(file string) { if file == "" { log.Warn("Missing configuration file. Using defaults.") return } absFile, _ := filepath.Abs(file) _, err := os.Stat(absFile) fileNotExists := os.IsNotExist(err) if fileNotExists { log.Warnf("Error reading configuration. File `%s` does not exist.", file) return } log.Printf("Configuration file: %s", absFile) // read file into env values err = godotenv.Load(absFile) if err != nil { log.Fatalf("Error parsing configuration file: %s", err) } } // Parse environment into a Config struct func Parse() *Config { var cfg Config // with config file loaded into env values, we can now parse env into our config struct err := envconfig.Process("Fathom", &cfg) if err != nil { log.Fatalf("Error parsing configuration from environment: %s", err) } if cfg.Database.URL != "" { u, err := url.Parse(cfg.Database.URL) if err != nil { log.Fatalf("Error parsing DATABASE_URL from environment: %s", err) } if u.Scheme == "postgres" { cfg.Database.Driver = "postgres" } } // alias sqlite to sqlite3 if cfg.Database.Driver == "sqlite" { cfg.Database.Driver = "sqlite3" } // use absolute path to sqlite3 database if cfg.Database.Driver == "sqlite3" { cfg.Database.Name, _ = filepath.Abs(cfg.Database.Name) } // if secret key is empty, use a randomly generated one if cfg.Secret == "" { cfg.Secret = randomString(40) } return &cfg } func randomString(len int) string { bytes := make([]byte, len) for i := 0; i < len; i++ { bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25 } return string(bytes) } ================================================ FILE: pkg/config/config_test.go ================================================ package config import ( "io/ioutil" "os" "testing" ) func TestLoadEnv(t *testing.T) { before := len(os.Environ()) LoadEnv("") LoadEnv("1230") after := len(os.Environ()) if before != after { t.Errorf("Expected the same number of env values") } data := []byte("FATHOM_DATABASE_DRIVER=\"sqlite3\"") ioutil.WriteFile("env_values", data, 0644) defer os.Remove("env_values") LoadEnv("env_values") got := os.Getenv("FATHOM_DATABASE_DRIVER") if got != "sqlite3" { t.Errorf("Expected %v, got %v", "sqlite3", got) } } func TestParse(t *testing.T) { // empty config, should not fatal cfg := Parse() if cfg.Secret == "" { t.Errorf("expected secret, got empty string") } secret := "my-super-secret-string" os.Setenv("FATHOM_SECRET", secret) cfg = Parse() if cfg.Secret != secret { t.Errorf("Expected %#v, got %#v", secret, cfg.Secret) } os.Setenv("FATHOM_DATABASE_DRIVER", "sqlite") cfg = Parse() if cfg.Database.Driver != "sqlite3" { t.Errorf("expected %#v, got %#v", "sqlite3", cfg.Database.Driver) } } func TestDatabaseURL(t *testing.T) { data := []byte("FATHOM_DATABASE_URL=\"postgres://dbuser:dbsecret@dbhost:1234/dbname\"") ioutil.WriteFile("env_values", data, 0644) defer os.Remove("env_values") LoadEnv("env_values") cfg := Parse() driver := "postgres" url := "postgres://dbuser:dbsecret@dbhost:1234/dbname" if cfg.Database.Driver != driver { t.Errorf("Expected %#v, got %#v", driver, cfg.Database.Driver) } if cfg.Database.URL != url { t.Errorf("Expected %#v, got %#v", url, cfg.Database.URL) } } func TestRandomString(t *testing.T) { r1 := randomString(10) r2 := randomString(10) if r1 == r2 { t.Errorf("expected two different strings, got %#v", r1) } if l := len(r1); l != 10 { t.Errorf("expected string of length %d, got string of length %d", 10, l) } } ================================================ FILE: pkg/datastore/datastore.go ================================================ package datastore import ( "time" "github.com/usefathom/fathom/pkg/datastore/sqlstore" "github.com/usefathom/fathom/pkg/models" ) // ErrNoResults is returned whenever a single-item query returns 0 results var ErrNoResults = sqlstore.ErrNoResults // ??? // Datastore represents a database implementations type Datastore interface { // users GetUser(int64) (*models.User, error) GetUserByEmail(string) (*models.User, error) SaveUser(*models.User) error DeleteUser(*models.User) error CountUsers() (int64, error) // sites GetSites() ([]*models.Site, error) GetSite(id int64) (*models.Site, error) SaveSite(s *models.Site) error DeleteSite(s *models.Site) error // site stats GetSiteStats(int64, time.Time) (*models.SiteStats, error) GetAggregatedSiteStats(int64, time.Time, time.Time) (*models.SiteStats, error) SelectSiteStats(int64, time.Time, time.Time) ([]*models.SiteStats, error) GetRealtimeVisitorCount(int64) (int64, error) SaveSiteStats(*models.SiteStats) error // pageviews InsertPageviews([]*models.Pageview) error UpdatePageviews([]*models.Pageview) error GetPageview(string) (*models.Pageview, error) GetProcessablePageviews(limit int) ([]*models.Pageview, error) DeletePageviews([]*models.Pageview) error // page stats GetPageStats(int64, time.Time, int64, int64) (*models.PageStats, error) SavePageStats(*models.PageStats) error SelectAggregatedPageStats(int64, time.Time, time.Time, int, int) ([]*models.PageStats, error) GetAggregatedPageStatsPageviews(int64, time.Time, time.Time) (int64, error) // referrer stats GetReferrerStats(int64, time.Time, int64, int64) (*models.ReferrerStats, error) SaveReferrerStats(*models.ReferrerStats) error SelectAggregatedReferrerStats(int64, time.Time, time.Time, int, int) ([]*models.ReferrerStats, error) GetAggregatedReferrerStatsPageviews(int64, time.Time, time.Time) (int64, error) // hostnames HostnameID(name string) (int64, error) PathnameID(name string) (int64, error) // misc Health() error Close() error } // New instantiates a new datastore from the given configuration struct func New(c *sqlstore.Config) Datastore { return sqlstore.New(c) } ================================================ FILE: pkg/datastore/sqlstore/config.go ================================================ package sqlstore import ( "regexp" "strings" mysql "github.com/go-sql-driver/mysql" ) type Config struct { Driver string `default:"sqlite3"` URL string `default:""` Host string `default:""` User string `default:""` Password string `default:""` Name string `default:"fathom.db"` SSLMode string `default:""` } func (c *Config) DSN() string { var dsn string // if FATHOM_DATABASE_URL was set, use that // this relies on the user to set the appropriate parameters, eg ?parseTime=true when using MySQL if c.URL != "" { return c.URL } // otherwise, generate from individual fields switch c.Driver { case POSTGRES: if c.Host != "" { dsn += " host=" + c.Host } if c.Name != "" { dsn += " dbname=" + c.Name } if c.User != "" { dsn += " user=" + c.User } if c.Password != "" { dsn += " password=" + c.Password } if c.SSLMode != "" { dsn += " sslmode=" + c.SSLMode } dsn = strings.TrimSpace(dsn) case MYSQL: mc := mysql.NewConfig() mc.User = c.User mc.Passwd = c.Password mc.Addr = c.Host mc.Net = "tcp" mc.DBName = c.Name mc.Params = map[string]string{ "parseTime": "true", } if c.SSLMode != "" { mc.Params["tls"] = c.SSLMode } dsn = mc.FormatDSN() case SQLITE: dsn = c.Name + "?_busy_timeout=10000" } return dsn } // Dbname returns the database name, either from config values or from the connection URL func (c *Config) Dbname() string { if c.Name != "" { return c.Name } re := regexp.MustCompile(`(?:dbname=|[^\/]?\/)(\w+)`) m := re.FindStringSubmatch(c.URL) if len(m) > 1 { return m[1] } return "" } ================================================ FILE: pkg/datastore/sqlstore/config_test.go ================================================ package sqlstore import ( "fmt" "testing" ) func TestConfigDSN(t *testing.T) { c := Config{ Driver: "postgres", User: "john", Password: "foo", } e := fmt.Sprintf("user=%s password=%s", c.User, c.Password) if v := c.DSN(); v != e { t.Errorf("Invalid DSN. Expected %s, got %s", e, v) } c = Config{ Driver: "postgres", User: "john", Password: "foo", SSLMode: "disable", } e = fmt.Sprintf("user=%s password=%s sslmode=%s", c.User, c.Password, c.SSLMode) if v := c.DSN(); v != e { t.Errorf("Invalid DSN. Expected %s, got %s", e, v) } } func TestConfigDbname(t *testing.T) { var c Config c = Config{ URL: "postgres://pqgotest:password@localhost/pqgotest?sslmode=verify-full", } if e, v := "pqgotest", c.Dbname(); v != e { t.Errorf("Expected %q, got %q", e, v) } c = Config{ URL: "root@tcp(host.myhost)/mysqltest?loc=Local", } if e, v := "mysqltest", c.Dbname(); v != e { t.Errorf("Expected %q, got %q", e, v) } c = Config{ URL: "/mysqltest?loc=Local&parseTime=true", } if e, v := "mysqltest", c.Dbname(); v != e { t.Errorf("Expected %q, got %q", e, v) } } ================================================ FILE: pkg/datastore/sqlstore/hostnames.go ================================================ package sqlstore import ( "database/sql" ) func (db *sqlstore) HostnameID(name string) (int64, error) { var id int64 query := db.Rebind("SELECT id FROM hostnames WHERE name = ? LIMIT 1") err := db.Get(&id, query, name) if err == sql.ErrNoRows { // Postgres does not support LastInsertID, so use a "... RETURNING" select query query := db.Rebind(`INSERT INTO hostnames(name) VALUES(?)`) if db.Driver == POSTGRES { err := db.Get(&id, query+" RETURNING id", name) return id, err } // MySQL and SQLite do support LastInsertID, so use that r, err := db.Exec(query, name) if err != nil { return 0, err } return r.LastInsertId() } return id, err } ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/10_alter_stats_table_constraints.sql ================================================ -- +migrate Up DROP INDEX unique_daily_site_stats ON daily_site_stats; DROP INDEX unique_daily_page_stats ON daily_page_stats; DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname(100), pathname(100), date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname(100), pathname(100), date); -- +migrate Down DROP INDEX unique_daily_site_stats ON daily_site_stats; DROP INDEX unique_daily_page_stats ON daily_page_stats; DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname(100), pathname(100), date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname(100), pathname(100), date); ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/11_add_pageview_finished_column.sql ================================================ -- +migrate Up ALTER TABLE pageviews ADD COLUMN is_finished TINYINT(1) NOT NULL DEFAULT 0; -- +migrate Down ALTER TABLE pageviews DROP COLUMN is_finished; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/12_create_hostnames_table.sql ================================================ -- +migrate Up CREATE TABLE hostnames( id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; -- +migrate Down DROP TABLE IF EXISTS hostnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/13_create_unique_hostname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name(100)); -- +migrate Down DROP INDEX IF EXISTS unique_hostnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/14_create_pathnames_table.sql ================================================ -- +migrate Up CREATE TABLE pathnames( id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; -- +migrate Down DROP TABLE IF EXISTS pathnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/15_create_unique_pathname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name(100)); -- +migrate Down DROP INDEX IF EXISTS unique_pathnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/16_fill_hostnames_table.sql ================================================ -- +migrate Up INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/17_fill_pathnames_table.sql ================================================ -- +migrate Up INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/18_alter_page_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_page_stats_old; RENAME TABLE daily_page_stats TO daily_page_stats_old; CREATE TABLE daily_page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ) CHARACTER SET=utf8; INSERT INTO daily_page_stats SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date FROM daily_page_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_page_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/19_alter_referrer_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_referrer_stats_old; RENAME TABLE daily_referrer_stats TO daily_referrer_stats_old; CREATE TABLE daily_referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ) CHARACTER SET=utf8; INSERT INTO daily_referrer_stats SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date FROM daily_referrer_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_referrer_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/1_initial_tables.sql ================================================ -- +migrate Up CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY NOT NULL, email VARCHAR(100) NOT NULL, password VARCHAR(255) NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; CREATE TABLE pageviews( id INT AUTO_INCREMENT PRIMARY KEY NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, session_id VARCHAR(16) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INT(4) NULL, timestamp DATETIME NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; CREATE TABLE daily_page_stats( hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, pageviews INT NOT NULL, visitors INT NOT NULL, entries INT NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; CREATE TABLE daily_site_stats( pageviews INT NOT NULL, visitors INT NOT NULL, sessions INT NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; CREATE TABLE daily_referrer_stats( url VARCHAR(255) NOT NULL, pageviews INT NOT NULL, visitors INT NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; CREATE UNIQUE INDEX unique_user_email ON users(email); CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname(100), pathname(100), date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url(100), date); -- +migrate Down DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS pageviews; DROP TABLE IF EXISTS daily_page_stats; DROP TABLE IF EXISTS daily_site_stats; DROP TABLE IF EXISTS daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/20_recreate_stats_indices.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); -- +migrate Down DROP INDEX unique_daily_page_stats ON daily_page_stats; DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/21_alter_page_stats_table.sql ================================================ -- +migrate Up CREATE TABLE page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ) CHARACTER SET=utf8; INSERT INTO page_stats SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') FROM daily_page_stats s ; DROP TABLE daily_page_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/22_alter_site_stats_table.sql ================================================ -- +migrate Up CREATE TABLE site_stats( site_id INTEGER NOT NULL DEFAULT 1, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, sessions INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ) CHARACTER SET=utf8; INSERT INTO site_stats SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') FROM daily_site_stats s ; DROP TABLE daily_site_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/23_alter_referrer_stats_table.sql ================================================ -- +migrate Up CREATE TABLE referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; INSERT INTO referrer_stats SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, CONCAT(date, ' 00:00:00') FROM daily_referrer_stats s; DROP TABLE daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/24_recreate_stat_table_indices.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); -- +migrate Down DROP INDEX unique_page_stats ON page_stats; DROP INDEX unique_referrer_stats ON referrer_stats; DROP INDEX unique_site_stats ON site_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/2_known_durations_column.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; -- +migrate Down ALTER TABLE daily_site_stats DROP COLUMN known_durations; ALTER TABLE daily_page_stats DROP COLUMN known_durations; ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/3_referrer_group_column.sql ================================================ -- +migrate Up DROP INDEX unique_daily_referrer_stats ON daily_referrer_stats; ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); UPDATE daily_referrer_stats SET hostname = SUBSTRING_INDEX( url, "/", 3) WHERE url != "" AND ( hostname = "" OR hostname IS NULL); UPDATE daily_referrer_stats SET pathname = REPLACE(url, hostname, "") WHERE url != "" AND (pathname = '' OR pathname IS NULL); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname(100), pathname(100), date); ALTER TABLE daily_referrer_stats DROP COLUMN url; -- +migrate Down ALTER TABLE daily_referrer_stats DROP COLUMN groupname; ALTER TABLE daily_referrer_stats DROP COLUMN hostname; ALTER TABLE daily_referrer_stats DROP COLUMN pathname; ALTER TABLE daily_referrer_stats ADD COLUMN url VARCHAR(255) NOT NULL; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/4_pageview_id_column.sql ================================================ -- +migrate Up ALTER TABLE pageviews DROP COLUMN session_id; ALTER TABLE pageviews DROP COLUMN id; ALTER TABLE pageviews ADD COLUMN id VARCHAR(31) NOT NULL FIRST; -- +migrate Down ALTER TABLE pageviews DROP COLUMN id; ALTER TABLE pageviews ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY NOT NULL FIRST; ALTER TABLE pageviews ADD COLUMN session_id VARCHAR(16) NOT NULL AFTER id; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/5_create_sites_table.sql ================================================ -- +migrate Up CREATE TABLE sites ( id INTEGER AUTO_INCREMENT PRIMARY KEY NOT NULL, tracking_id VARCHAR(8) UNIQUE, name VARCHAR(100) NOT NULL ) CHARACTER SET=utf8 ENGINE=INNODB; -- +migrate Down DROP TABLE IF EXISTS sites; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/6_add_site_tracking_id_column_to_pageviews_table.sql ================================================ -- +migrate Up TRUNCATE pageviews; ALTER TABLE pageviews ADD COLUMN site_tracking_id VARCHAR(8) NOT NULL; -- +migrate Down ALTER TABLE pageviews DROP COLUMN site_tracking_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/7_add_site_id_to_site_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_site_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/8_add_site_id_to_page_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_page_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/mysql/9_add_site_id_to_referrer_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_referrer_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/10_alter_numeric_column_precision.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ALTER COLUMN bounce_rate TYPE NUMERIC; ALTER TABLE daily_page_stats ALTER COLUMN bounce_rate TYPE NUMERIC; ALTER TABLE daily_referrer_stats ALTER COLUMN bounce_rate TYPE NUMERIC; ALTER TABLE daily_site_stats ALTER COLUMN avg_duration TYPE NUMERIC; ALTER TABLE daily_page_stats ALTER COLUMN avg_duration TYPE NUMERIC; ALTER TABLE daily_referrer_stats ALTER COLUMN avg_duration TYPE NUMERIC; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/11_alter_stats_table_constraints.sql ================================================ -- +migrate Up DROP INDEX IF EXISTS unique_daily_site_stats; DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname, pathname, date); -- +migrate Down DROP INDEX IF EXISTS unique_daily_site_stats; DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/12_add_pageview_finished_column.sql ================================================ -- +migrate Up ALTER TABLE pageviews ADD COLUMN is_finished BOOLEAN NOT NULL DEFAULT FALSE; -- +migrate Down ALTER TABLE pageviews DROP COLUMN is_finished; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/13_create_hostnames_table.sql ================================================ -- +migrate Up CREATE TABLE hostnames( id SERIAL PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS hostnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/14_create_unique_hostname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name); -- +migrate Down DROP INDEX IF EXISTS unique_hostnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/15_create_pathnames_table.sql ================================================ -- +migrate Up CREATE TABLE pathnames( id SERIAL PRIMARY KEY NOT NULL, name VARCHAR(255) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS pathnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/16_create_unique_pathname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name); -- +migrate Down DROP INDEX IF EXISTS unique_pathnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/17_fill_hostnames_table.sql ================================================ -- +migrate Up INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/18_fill_pathnames_table.sql ================================================ -- +migrate Up INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/19_alter_page_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_page_stats_old; ALTER TABLE daily_page_stats RENAME TO daily_page_stats_old; CREATE TABLE daily_page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); INSERT INTO daily_page_stats SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date FROM daily_page_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_page_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/1_initial_tables.sql ================================================ -- +migrate Up CREATE TABLE users( id SERIAL PRIMARY KEY NOT NULL, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL ); CREATE TABLE pageviews( id SERIAL PRIMARY KEY NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, session_id VARCHAR(16) NOT NULL, is_new_visitor BOOLEAN NOT NULL, is_new_session BOOLEAN NOT NULL, is_unique BOOLEAN NOT NULL, is_bounce BOOLEAN NULL, referrer VARCHAR(255) NULL, duration INTEGER NULL, timestamp TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE TABLE daily_page_stats( hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate NUMERIC(4) NOT NULL, avg_duration NUMERIC(4) NOT NULL, date DATE NOT NULL ); CREATE TABLE daily_site_stats( pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, sessions INTEGER NOT NULL, bounce_rate NUMERIC(4) NOT NULL, avg_duration NUMERIC(4) NOT NULL, date DATE NOT NULL ); CREATE TABLE daily_referrer_stats( url VARCHAR(255) NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate NUMERIC(4) NOT NULL, avg_duration NUMERIC(4) NOT NULL, date DATE NOT NULL ); CREATE UNIQUE INDEX unique_user_email ON users(email); CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url, date); -- +migrate Down DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS pageviews; DROP TABLE IF EXISTS daily_page_stats; DROP TABLE IF EXISTS daily_site_stats; DROP TABLE IF EXISTS daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/20_alter_referrer_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_referrer_stats_old; ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; CREATE TABLE daily_referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); INSERT INTO daily_referrer_stats SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date FROM daily_referrer_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_referrer_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/21_recreate_stats_indices.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); -- +migrate Down DROP INDEX unique_daily_page_stats; DROP INDEX unique_daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/22_alter_page_stats_table.sql ================================================ -- +migrate Up CREATE TABLE page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts TIMESTAMP WITHOUT TIME ZONE NOT NULL ); INSERT INTO page_stats SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp FROM daily_page_stats s; DROP TABLE daily_page_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/23_alter_referrer_stats_table.sql ================================================ -- +migrate Up CREATE TABLE referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts TIMESTAMP WITHOUT TIME ZONE NOT NULL ); INSERT INTO referrer_stats SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp FROM daily_referrer_stats s; DROP TABLE daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/24_alter_site_stats_table.sql ================================================ -- +migrate Up CREATE TABLE site_stats( site_id INTEGER NOT NULL DEFAULT 1, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, sessions INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts TIMESTAMP WITHOUT TIME ZONE NOT NULL ); INSERT INTO site_stats SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, (date || ' 00:00:00')::timestamp FROM daily_site_stats s; DROP TABLE daily_site_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/25_recreate_stat_table_indices.sql ================================================ -- +migrate Up DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); -- +migrate Down DROP INDEX IF EXISTS unique_page_stats; DROP INDEX IF EXISTS unique_referrer_stats; DROP INDEX IF EXISTS unique_site_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/26_alter_pageviews_table.sql ================================================ -- +migrate Up ALTER TABLE pageviews ALTER COLUMN timestamp TYPE TIMESTAMP WITHOUT TIME ZONE; -- +migrate Down ALTER TABLE pageviews ALTER COLUMN timestamp TYPE TIMESTAMP WITH TIME ZONE; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/2_known_durations_column.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; -- +migrate Down ALTER TABLE daily_site_stats DROP COLUMN known_durations; ALTER TABLE daily_page_stats DROP COLUMN known_durations; ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/3_referrer_group_column.sql ================================================ -- +migrate Up ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); 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); UPDATE daily_referrer_stats SET pathname = SPLIT_PART( url, hostname, 2 ) WHERE url != '' AND (pathname = '' OR pathname IS NULL); DROP INDEX IF EXISTS unique_daily_referrer_stats; ALTER TABLE daily_referrer_stats DROP COLUMN url; CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); -- +migrate Down ALTER TABLE daily_referrer_stats DROP COLUMN groupname; ALTER TABLE daily_referrer_stats DROP COLUMN hostname; ALTER TABLE daily_referrer_stats DROP COLUMN pathname; ALTER TABLE daily_referrer_stats ADD COLUMN url VARCHAR(255) NOT NULL; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/4_pageview_id_column.sql ================================================ -- +migrate Up TRUNCATE pageviews; -- postgres will fail because of NULL values otherwise ALTER TABLE pageviews DROP COLUMN session_id; ALTER TABLE pageviews DROP COLUMN id; ALTER TABLE pageviews ADD COLUMN id VARCHAR(31) NOT NULL; -- +migrate Down ALTER TABLE pageviews DROP COLUMN id; ALTER TABLE pageviews ADD COLUMN id INT AUTO_INCREMENT PRIMARY KEY NOT NULL; ALTER TABLE pageviews ADD COLUMN session_id VARCHAR(16) NOT NULL; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/5_create_sites_table.sql ================================================ -- +migrate Up CREATE TABLE sites ( id SERIAL PRIMARY KEY NOT NULL, tracking_id VARCHAR(8) UNIQUE, name VARCHAR(100) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS sites; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/6_add_site_tracking_id_column_to_pageviews_table.sql ================================================ -- +migrate Up TRUNCATE pageviews; -- postgres will fail because of NULL values otherwise ALTER TABLE pageviews ADD COLUMN site_tracking_id VARCHAR(8) NOT NULL; -- +migrate Down ALTER TABLE pageviews DROP COLUMN site_tracking_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/7_add_site_id_to_site_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_site_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/8_add_site_id_to_page_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_page_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/postgres/9_add_site_id_to_referrer_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ALTER TABLE daily_referrer_stats DROP COLUMN site_id; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/10_alter_stats_table_constraints.sql ================================================ -- +migrate Up DROP INDEX IF EXISTS unique_daily_site_stats; DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(site_id, date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname, pathname, date); -- +migrate Down DROP INDEX IF EXISTS unique_daily_site_stats; DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(hostname, pathname, date); ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/11_add_pageview_finished_column.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS pageviews; CREATE TABLE pageviews( id VARCHAR(31) NOT NULL, site_tracking_id VARCHAR(8) NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, is_finished TINYINT(1) NOT NULL DEFAULT 0, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS pageviews; CREATE TABLE pageviews( id VARCHAR(31) NOT NULL, site_tracking_id VARCHAR(8) NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/12_create_hostnames_table.sql ================================================ -- +migrate Up CREATE TABLE hostnames( id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS hostnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/13_create_unique_hostname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_hostnames_name ON hostnames(name); -- +migrate Down DROP INDEX IF EXISTS unique_hostnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/14_create_pathnames_table.sql ================================================ -- +migrate Up CREATE TABLE pathnames( id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS pathnames; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/15_create_unique_pathname_index.sql ================================================ -- +migrate Up CREATE UNIQUE INDEX unique_pathnames_name ON pathnames(name); -- +migrate Down DROP INDEX IF EXISTS unique_pathnames_name; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/15_vacuum.sql ================================================ -- +migrate Up notransaction VACUUM; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/16_fill_hostnames_table.sql ================================================ -- +migrate Up INSERT INTO hostnames(name) SELECT hostname FROM daily_page_stats UNION SELECT hostname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/17_fill_pathnames_table.sql ================================================ -- +migrate Up INSERT INTO pathnames(name) SELECT pathname FROM daily_page_stats UNION SELECT pathname FROM daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/18_alter_page_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_page_stats_old; ALTER TABLE daily_page_stats RENAME TO daily_page_stats_old; CREATE TABLE daily_page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); INSERT INTO daily_page_stats SELECT site_id, h.id, p.id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date FROM daily_page_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_page_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/19_alter_referrer_stats_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS daily_referrer_stats_old; ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; CREATE TABLE daily_referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); INSERT INTO daily_referrer_stats SELECT site_id, h.id, p.id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date FROM daily_referrer_stats_old s LEFT JOIN hostnames h ON h.name = s.hostname LEFT JOIN pathnames p ON p.name = s.pathname; DROP TABLE daily_referrer_stats_old; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/1_initial_tables.sql ================================================ -- +migrate Up CREATE TABLE users ( id INTEGER PRIMARY KEY, email VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL ); CREATE TABLE pageviews( id INTEGER PRIMARY KEY, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, session_id VARCHAR(16) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); CREATE TABLE daily_page_stats( hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); CREATE TABLE daily_site_stats( pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, sessions INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); CREATE TABLE daily_referrer_stats( url VARCHAR(255) NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, date DATE NOT NULL ); CREATE UNIQUE INDEX unique_user_email ON users(email); CREATE UNIQUE INDEX unique_daily_site_stats ON daily_site_stats(date); CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(hostname, pathname, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(url, date); -- +migrate Down DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS pageviews; DROP TABLE IF EXISTS daily_page_stats; DROP TABLE IF EXISTS daily_site_stats; DROP TABLE IF EXISTS daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/20_recreate_stats_indices.sql ================================================ -- +migrate Up DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_daily_page_stats ON daily_page_stats(site_id, hostname_id, pathname_id, date); CREATE UNIQUE INDEX unique_daily_referrer_stats ON daily_referrer_stats(site_id, hostname_id, pathname_id, date); -- +migrate Down DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/21_alter_page_stats_table.sql ================================================ -- +migrate Up CREATE TABLE page_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, entries INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ); INSERT INTO page_stats SELECT site_id, hostname_id, pathname_id, pageviews, visitors, entries, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' FROM daily_page_stats s ; DROP TABLE daily_page_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/22_alter_site_stats_table.sql ================================================ -- +migrate Up CREATE TABLE site_stats( site_id INTEGER NOT NULL DEFAULT 1, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, sessions INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ); INSERT INTO site_stats SELECT site_id, pageviews, visitors, sessions, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' FROM daily_site_stats s ; DROP TABLE daily_site_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/23_alter_referrer_stats_table.sql ================================================ -- +migrate Up CREATE TABLE referrer_stats( site_id INTEGER NOT NULL DEFAULT 1, hostname_id INTEGER NOT NULL, pathname_id INTEGER NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, avg_duration FLOAT NOT NULL, ts DATETIME NOT NULL ); INSERT INTO referrer_stats SELECT site_id, hostname_id, pathname_id, groupname, pageviews, visitors, bounce_rate, known_durations, avg_duration, date || ' 00:00:00' FROM daily_referrer_stats s; DROP TABLE daily_referrer_stats; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/24_recreate_stat_table_indices.sql ================================================ -- +migrate Up DROP INDEX IF EXISTS unique_daily_page_stats; DROP INDEX IF EXISTS unique_daily_referrer_stats; CREATE UNIQUE INDEX unique_page_stats ON page_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_referrer_stats ON referrer_stats(site_id, hostname_id, pathname_id, ts); CREATE UNIQUE INDEX unique_site_stats ON site_stats(site_id, ts); -- +migrate Down DROP INDEX IF EXISTS unique_page_stats; DROP INDEX IF EXISTS unique_referrer_stats; DROP INDEX IF EXISTS unique_site_stats; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/25_vacuum.sql ================================================ -- +migrate Up notransaction VACUUM; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/26_sites_id_autoinc.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS sites_old; ALTER TABLE sites RENAME TO sites_old; CREATE TABLE sites ( `id` INTEGER PRIMARY KEY AUTOINCREMENT, `tracking_id` VARCHAR(8) UNIQUE, `name` VARCHAR(100) NOT NULL ); INSERT INTO sites SELECT `id`, `tracking_id`, `name` FROM sites_old; -- +migrate Down DROP TABLE IF EXISTS sites_old; ALTER TABLE sites RENAME TO sites_old; CREATE TABLE sites ( `id` INTEGER PRIMARY KEY, `tracking_id` VARCHAR(8) UNIQUE, `name` VARCHAR(100) NOT NULL ); INSERT INTO sites SELECT `id`, `tracking_id`, `name` FROM sites_old; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/2_known_durations_column.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_page_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; ALTER TABLE daily_referrer_stats ADD COLUMN known_durations INTEGER NOT NULL DEFAULT 0; -- +migrate Down ALTER TABLE daily_site_stats DROP COLUMN known_durations; ALTER TABLE daily_page_stats DROP COLUMN known_durations; ALTER TABLE daily_referrer_stats DROP COLUMN known_durations; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/3_referrer_group_column.sql ================================================ -- +migrate Up ALTER TABLE daily_referrer_stats ADD COLUMN groupname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN hostname VARCHAR(255); ALTER TABLE daily_referrer_stats ADD COLUMN pathname VARCHAR(255); 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); UPDATE daily_referrer_stats SET pathname = SUBSTR(url, LENGTH(hostname)+1) WHERE url != '' AND (pathname = '' OR pathname IS NULL); -- drop `url` column... oh sqlite ALTER TABLE daily_referrer_stats RENAME TO daily_referrer_stats_old; CREATE TABLE daily_referrer_stats( hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, groupname VARCHAR(255) NULL, pageviews INTEGER NOT NULL, visitors INTEGER NOT NULL, bounce_rate FLOAT NOT NULL, avg_duration FLOAT NOT NULL, known_durations INTEGER NOT NULL DEFAULT 0, date DATE NOT NULL ); INSERT INTO daily_referrer_stats SELECT hostname, pathname, groupname, pageviews, visitors, bounce_rate, avg_duration, known_durations, date FROM daily_referrer_stats_old; -- +migrate Down -- TODO.... ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/4_pageview_id_column.sql ================================================ -- +migrate Up DROP TABLE pageviews; CREATE TABLE pageviews( id VARCHAR(31) NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); -- +migrate Down DROP TABLE pageviews; CREATE TABLE pageviews( id INTEGER PRIMARY KEY, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, session_id VARCHAR(16) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/5_create_sites_table.sql ================================================ -- +migrate Up CREATE TABLE sites ( id INTEGER PRIMARY KEY, tracking_id VARCHAR(8) UNIQUE, name VARCHAR(100) NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS sites; ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/6_add_site_tracking_id_column_to_pageviews_table.sql ================================================ -- +migrate Up DROP TABLE IF EXISTS pageviews; CREATE TABLE pageviews( id VARCHAR(31) NOT NULL, site_tracking_id VARCHAR(8) NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); -- +migrate Down DROP TABLE IF EXISTS pageviews; CREATE TABLE pageviews( id VARCHAR(31) NOT NULL, hostname VARCHAR(255) NOT NULL, pathname VARCHAR(255) NOT NULL, is_new_visitor TINYINT(1) NOT NULL, is_new_session TINYINT(1) NOT NULL, is_unique TINYINT(1) NOT NULL, is_bounce TINYINT(1) NULL, referrer VARCHAR(255) NULL, duration INTEGER(4) NULL, timestamp DATETIME NOT NULL ); ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/7_add_site_id_to_site_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_site_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/8_add_site_id_to_page_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_page_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/migrations/sqlite3/9_add_site_id_to_referrer_stats_table.sql ================================================ -- +migrate Up ALTER TABLE daily_referrer_stats ADD COLUMN site_id INTEGER NOT NULL DEFAULT 1; -- +migrate Down ================================================ FILE: pkg/datastore/sqlstore/page_stats.go ================================================ package sqlstore import ( "database/sql" "time" "github.com/usefathom/fathom/pkg/models" ) func (db *sqlstore) GetPageStats(siteID int64, date time.Time, hostnameID int64, pathnameID int64) (*models.PageStats, error) { stats := &models.PageStats{New: false} query := db.Rebind(`SELECT * FROM page_stats WHERE site_id = ? AND hostname_id = ? AND pathname_id = ? AND ts = ? LIMIT 1`) err := db.Get(stats, query, siteID, hostnameID, pathnameID, date.Format(DATE_FORMAT)) if err == sql.ErrNoRows { return nil, ErrNoResults } return stats, mapError(err) } func (db *sqlstore) SavePageStats(s *models.PageStats) error { if s.New { return db.insertPageStats(s) } return db.updatePageStats(s) } func (db *sqlstore) insertPageStats(s *models.PageStats) error { query := db.Rebind(`INSERT INTO page_stats(pageviews, visitors, entries, bounce_rate, avg_duration, known_durations, site_id, hostname_id, pathname_id, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) _, 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)) return err } func (db *sqlstore) updatePageStats(s *models.PageStats) error { 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 = ?`) _, 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)) return err } func (db *sqlstore) SelectAggregatedPageStats(siteID int64, startDate time.Time, endDate time.Time, offset int, limit int) ([]*models.PageStats, error) { var result []*models.PageStats query := db.Rebind(`SELECT h.name AS hostname, p.name AS pathname, SUM(pageviews) AS pageviews, SUM(visitors) AS visitors, SUM(entries) AS entries, COALESCE(SUM(entries*bounce_rate) / NULLIF(SUM(entries), 0), 0.00) AS bounce_rate, COALESCE(SUM(pageviews*avg_duration) / SUM(pageviews), 0.00) AS avg_duration FROM page_stats s LEFT JOIN hostnames h ON h.id = s.hostname_id LEFT JOIN pathnames p ON p.id = s.pathname_id WHERE site_id = ? AND ts >= ? AND ts <= ? GROUP BY hostname, pathname ORDER BY pageviews DESC LIMIT ? OFFSET ?`) err := db.Select(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT), limit, offset) return result, err } func (db *sqlstore) GetAggregatedPageStatsPageviews(siteID int64, startDate time.Time, endDate time.Time) (int64, error) { var result int64 query := db.Rebind(`SELECT COALESCE(SUM(pageviews), 0) FROM page_stats WHERE site_id = ? AND ts >= ? AND ts <= ?`) err := db.Get(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) return result, err } ================================================ FILE: pkg/datastore/sqlstore/pageviews.go ================================================ package sqlstore import ( "strings" "time" log "github.com/sirupsen/logrus" "github.com/usefathom/fathom/pkg/models" ) // GetPageview selects a single pageview by its string ID func (db *sqlstore) GetPageview(id string) (*models.Pageview, error) { result := &models.Pageview{} query := db.Rebind(`SELECT * FROM pageviews WHERE id = ? LIMIT 1`) err := db.Get(result, query, id) if err != nil { return nil, mapError(err) } return result, nil } // InsertPageviews bulks-insert multiple pageviews using a single INSERT statement // IMPORTANT: This does not insert the actual IsBounce, Duration and IsFinished values func (db *sqlstore) InsertPageviews(pageviews []*models.Pageview) error { n := len(pageviews) if n == 0 { return nil } // generate placeholders string placeholderTemplate := "(?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, FALSE, 0)," placeholders := strings.Repeat(placeholderTemplate, n) placeholders = placeholders[:len(placeholders)-1] nPlaceholders := strings.Count(placeholderTemplate, "?") // init values slice with correct length nValues := n * nPlaceholders values := make([]interface{}, nValues) // overwrite nil values in slice j := 0 for i := range pageviews { // test for columns with ignored values if pageviews[i].IsBounce != true || pageviews[i].Duration > 0 || pageviews[i].IsFinished != false { log.Warnf("inserting pageview with invalid column values for bulk-insert") } j = i * nPlaceholders values[j] = pageviews[i].ID values[j+1] = pageviews[i].SiteTrackingID values[j+2] = pageviews[i].Hostname values[j+3] = pageviews[i].Pathname values[j+4] = pageviews[i].IsNewVisitor values[j+5] = pageviews[i].IsNewSession values[j+6] = pageviews[i].IsUnique values[j+7] = pageviews[i].Referrer values[j+8] = pageviews[i].Timestamp } // string together query & execute with values 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 query = db.Rebind(query) _, err := db.Exec(query, values...) if err != nil { return err } return nil } // UpdatePageviews updates multiple pageviews using a single transaction // IMPORTANT: this function only updates the IsFinished, IsBounce and Duration values func (db *sqlstore) UpdatePageviews(pageviews []*models.Pageview) error { if len(pageviews) == 0 { return nil } tx, err := db.Beginx() if err != nil { return err } query := tx.Rebind(`UPDATE pageviews SET is_bounce = ?, duration = ?, is_finished = ? WHERE id = ?`) stmt, err := tx.Preparex(query) if err != nil { return err } for i := range pageviews { _, err := stmt.Exec(pageviews[i].IsBounce, pageviews[i].Duration, pageviews[i].IsFinished, pageviews[i].ID) if err != nil { tx.Rollback() return err } } err = tx.Commit() return err } // GetProcessablePageviews selects all pageviews which are "done" (ie not still waiting for bounce flag or duration) func (db *sqlstore) GetProcessablePageviews(limit int) ([]*models.Pageview, error) { var results []*models.Pageview thirtyMinsAgo := time.Now().Add(-30 * time.Minute) query := db.Rebind(`SELECT * FROM pageviews WHERE is_finished = TRUE OR timestamp < ? LIMIT ?`) err := db.Select(&results, query, thirtyMinsAgo, limit) return results, err } func (db *sqlstore) DeletePageviews(pageviews []*models.Pageview) error { ids := []string{} for _, p := range pageviews { ids = append(ids, "'"+p.ID+"'") } query := db.Rebind(`DELETE FROM pageviews WHERE id IN(` + strings.Join(ids, ",") + `)`) _, err := db.Exec(query) return err } ================================================ FILE: pkg/datastore/sqlstore/pathnames.go ================================================ package sqlstore import ( "database/sql" ) func (db *sqlstore) PathnameID(name string) (int64, error) { var id int64 query := db.Rebind("SELECT id FROM pathnames WHERE name = ? LIMIT 1") err := db.Get(&id, query, name) if err == sql.ErrNoRows { // Postgres does not support LastInsertID, so use a "... RETURNING" select query query := db.Rebind(`INSERT INTO pathnames(name) VALUES(?)`) if db.Driver == POSTGRES { err := db.Get(&id, query+" RETURNING id", name) return id, err } // MySQL and SQLite do support LastInsertID, so use that r, err := db.Exec(query, name) if err != nil { return 0, err } return r.LastInsertId() } return id, err } ================================================ FILE: pkg/datastore/sqlstore/referrer_stats.go ================================================ package sqlstore import ( "database/sql" "time" "github.com/usefathom/fathom/pkg/models" ) func (db *sqlstore) GetReferrerStats(siteID int64, date time.Time, hostnameID int64, pathnameID int64) (*models.ReferrerStats, error) { stats := &models.ReferrerStats{New: false} query := db.Rebind(`SELECT * FROM referrer_stats WHERE site_id = ? AND ts = ? AND hostname_id = ? AND pathname_id = ? LIMIT 1`) err := db.Get(stats, query, siteID, date.Format(DATE_FORMAT), hostnameID, pathnameID) if err == sql.ErrNoRows { return nil, ErrNoResults } return stats, mapError(err) } func (db *sqlstore) SaveReferrerStats(s *models.ReferrerStats) error { if s.New { return db.insertReferrerStats(s) } return db.updateReferrerStats(s) } func (db *sqlstore) insertReferrerStats(s *models.ReferrerStats) error { query := db.Rebind(`INSERT INTO referrer_stats(visitors, pageviews, bounce_rate, avg_duration, known_durations, groupname, site_id, hostname_id, pathname_id, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) _, 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)) return err } func (db *sqlstore) updateReferrerStats(s *models.ReferrerStats) error { 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 = ?`) _, 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)) return err } func (db *sqlstore) SelectAggregatedReferrerStats(siteID int64, startDate time.Time, endDate time.Time, offset int, limit int) ([]*models.ReferrerStats, error) { var result []*models.ReferrerStats sql := `SELECT MIN(h.name) AS hostname, MIN(p.name) AS pathname, COALESCE(MIN(groupname), '') AS groupname, SUM(visitors) AS visitors, SUM(pageviews) AS pageviews, SUM(pageviews*bounce_rate) / SUM(pageviews) AS bounce_rate, SUM(pageviews*avg_duration) / SUM(pageviews) AS avg_duration FROM referrer_stats s LEFT JOIN hostnames h ON h.id = s.hostname_id LEFT JOIN pathnames p ON p.id = s.pathname_id WHERE site_id = ? AND ts >= ? AND ts <= ? ` if db.Config.Driver == "sqlite3" { sql = sql + `GROUP BY COALESCE(NULLIF(groupname, ''), hostname_id || pathname_id ) ` } else { sql = sql + `GROUP BY COALESCE(NULLIF(groupname, ''), CONCAT(hostname_id, pathname_id) ) ` } sql = sql + ` ORDER BY pageviews DESC LIMIT ? OFFSET ?` query := db.Rebind(sql) err := db.Select(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT), limit, offset) return result, mapError(err) } func (db *sqlstore) GetAggregatedReferrerStatsPageviews(siteID int64, startDate time.Time, endDate time.Time) (int64, error) { var result int64 query := db.Rebind(`SELECT COALESCE(SUM(pageviews), 0) FROM referrer_stats WHERE site_id = ? AND ts >= ? AND ts <= ?`) err := db.Get(&result, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) return result, mapError(err) } ================================================ FILE: pkg/datastore/sqlstore/seed/pageviews.sql ================================================ INSERT INTO pageviews ( session_id, pathname, is_new_visitor, is_unique, is_bounce, referrer, duration, timestamp) VALUES ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-03 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 1, "", 14, "2018-05-03 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 13, "2018-05-04 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 16, "2018-05-04 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 16, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 0, "", 17, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 1, "", 18, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 1, "https://duckduckgo.com/", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://duckduckgo.com/", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 0, "https://duckduckgo.com/", 150, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 0, 1, 0, "https://mozilla.org/", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 10, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/about", 1, 1, 0, "", 11, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 21, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 0, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 0, 1, 1, "", 8, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/contact", 1, 1, 1, "https://wikipedia.com/", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 0, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 0, 1, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 0, 0, "", 8, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 0, 1, "", 24, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 14, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/pricing", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "", 24, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 1, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://pjrvs.com", 8, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 1, "https://pjrvs.com", 24, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://pjrvs.com", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 0, 1, "", 19, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 0, 0, "", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 0, 0, "", 19, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 19, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 15, "2018-05-05 15:00:00"), ( LEFT(UUID(), 8), "/", 1, 1, 0, "https://dvk.co/", 14, "2018-05-05 15:00:00"); ================================================ FILE: pkg/datastore/sqlstore/site_stats.go ================================================ package sqlstore import ( "database/sql" "time" log "github.com/sirupsen/logrus" "github.com/usefathom/fathom/pkg/models" ) func (db *sqlstore) GetSiteStats(siteID int64, date time.Time) (*models.SiteStats, error) { stats := &models.SiteStats{New: false} query := db.Rebind(`SELECT * FROM site_stats WHERE site_id = ? AND ts = ? LIMIT 1`) err := db.Get(stats, query, siteID, date.Format(DATE_FORMAT)) if err == sql.ErrNoRows { return nil, ErrNoResults } return stats, mapError(err) } func (db *sqlstore) SaveSiteStats(s *models.SiteStats) error { if s.New { return db.insertSiteStats(s) } return db.updateSiteStats(s) } func (db *sqlstore) insertSiteStats(s *models.SiteStats) error { query := db.Rebind(`INSERT INTO site_stats(site_id, visitors, sessions, pageviews, bounce_rate, avg_duration, known_durations, ts) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`) _, err := db.Exec(query, s.SiteID, s.Visitors, s.Sessions, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.Date.Format(DATE_FORMAT)) return err } func (db *sqlstore) updateSiteStats(s *models.SiteStats) error { query := db.Rebind(`UPDATE site_stats SET visitors = ?, sessions = ?, pageviews = ?, bounce_rate = ?, avg_duration = ?, known_durations = ? WHERE site_id = ? AND ts = ?`) _, err := db.Exec(query, s.Visitors, s.Sessions, s.Pageviews, s.BounceRate, s.AvgDuration, s.KnownDurations, s.SiteID, s.Date.Format(DATE_FORMAT)) return err } func (db *sqlstore) SelectSiteStats(siteID int64, startDate time.Time, endDate time.Time) ([]*models.SiteStats, error) { results := []*models.SiteStats{} query := db.Rebind(`SELECT * FROM site_stats WHERE site_id = ? AND ts >= ? AND ts <= ? ORDER BY ts DESC`) err := db.Select(&results, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) return results, err } func (db *sqlstore) GetAggregatedSiteStats(siteID int64, startDate time.Time, endDate time.Time) (*models.SiteStats, error) { stats := &models.SiteStats{} query := db.Rebind(`SELECT SUM(pageviews) AS pageviews, SUM(visitors) AS visitors, SUM(sessions) AS sessions, SUM(pageviews*avg_duration) / SUM(pageviews) AS avg_duration, COALESCE(SUM(sessions*bounce_rate) / SUM(sessions), 0.00) AS bounce_rate FROM site_stats WHERE site_id = ? AND ts >= ? AND ts <= ? LIMIT 1`) err := db.Get(stats, query, siteID, startDate.Format(DATE_FORMAT), endDate.Format(DATE_FORMAT)) return stats, mapError(err) } func (db *sqlstore) GetRealtimeVisitorCount(siteID int64) (int64, error) { var siteTrackingID string if err := db.Get(&siteTrackingID, db.Rebind(`SELECT tracking_id FROM sites WHERE id = ? LIMIT 1`), siteID); err != nil && err != sql.ErrNoRows { log.Error(err) return 0, mapError(err) } var sql string var total int64 // for backwards compatibility with tracking snippets without an explicit site tracking ID (< 1.1.0) if siteID == 1 { sql = `SELECT COUNT(*) FROM pageviews p WHERE ( site_tracking_id = ? OR site_tracking_id = '' ) AND is_finished = FALSE AND timestamp > ?` } else { sql = `SELECT COUNT(*) FROM pageviews p WHERE site_tracking_id = ? AND is_finished = FALSE AND timestamp > ?` } query := db.Rebind(sql) if err := db.Get(&total, query, siteTrackingID, time.Now().Add(-5*time.Minute)); err != nil { return 0, mapError(err) } return total, nil } ================================================ FILE: pkg/datastore/sqlstore/sites.go ================================================ package sqlstore import ( "database/sql" "github.com/usefathom/fathom/pkg/models" ) // GetSites gets all sites in the database func (db *sqlstore) GetSites() ([]*models.Site, error) { results := []*models.Site{} query := db.Rebind(`SELECT * FROM sites`) err := db.Select(&results, query) // don't err on no rows if err == sql.ErrNoRows { return results, nil } return results, err } func (db *sqlstore) GetSite(id int64) (*models.Site, error) { s := &models.Site{} query := db.Rebind("SELECT * FROM sites WHERE id = ?") err := db.Get(s, query, id) return s, mapError(err) } // SaveSite saves the website in the database (inserts or updates) func (db *sqlstore) SaveSite(s *models.Site) error { if s.ID > 0 { return db.updateSite(s) } return db.insertSite(s) } // InsertSite saves a new site in the database func (db *sqlstore) insertSite(s *models.Site) error { // Postgres does not support LastInsertID, so use a "... RETURNING" select query query := db.Rebind(`INSERT INTO sites(tracking_id, name) VALUES(?, ?)`) if db.Driver == POSTGRES { err := db.Get(&s.ID, query+" RETURNING id", s.TrackingID, s.Name) return err } // MySQL and SQLite do support LastInsertID, so use that r, err := db.Exec(query, s.TrackingID, s.Name) if err != nil { return err } s.ID, err = r.LastInsertId() if err != nil { return err } return nil } // UpdateSite updates an existing site in the database func (db *sqlstore) updateSite(s *models.Site) error { query := db.Rebind(`UPDATE sites SET name = ? WHERE id = ?`) _, err := db.Exec(query, s.Name, s.ID) return err } // DeleteSite deletes the given site in the database func (db *sqlstore) DeleteSite(s *models.Site) error { query := db.Rebind(`DELETE FROM sites WHERE id = ?`) _, err := db.Exec(query, s.ID) return err } ================================================ FILE: pkg/datastore/sqlstore/sqlstore.go ================================================ package sqlstore import ( "context" "database/sql" "errors" "time" _ "github.com/go-sql-driver/mysql" // mysql driver "github.com/gobuffalo/packr/v2" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" // postgresql driver _ "github.com/mattn/go-sqlite3" //sqlite3 driver migrate "github.com/rubenv/sql-migrate" log "github.com/sirupsen/logrus" ) const ( MYSQL = "mysql" POSTGRES = "postgres" SQLITE = "sqlite3" DATE_FORMAT = "2006-01-02 15:00:00" ) type sqlstore struct { *sqlx.DB Driver string Config *Config } // ErrNoResults is returned when a query yielded 0 results var ErrNoResults = errors.New("datastore: query returned 0 results") // New creates a new database pool func New(c *Config) *sqlstore { dsn := c.DSN() dbx, err := sqlx.Connect(c.Driver, dsn) if err != nil { log.Fatalf("Error connecting to database: %s", err) } db := &sqlstore{dbx, c.Driver, c} if c.Host == "" || c.Driver == SQLITE { log.Printf("Connected to %s database: %s", c.Driver, c.Dbname()) } else { log.Printf("Connected to %s database: %s on %s", c.Driver, c.Dbname(), c.Host) } // apply database migrations (if any) db.Migrate() return db } func (db *sqlstore) Migrate() { migrationSource := &migrate.PackrMigrationSource{ Box: packr.NewBox("./migrations"), Dir: db.Config.Driver, } migrate.SetTable("migrations") migrations, err := migrationSource.FindMigrations() if err != nil { log.Errorf("Error loading database migrations: %s", err) } if len(migrations) == 0 { log.Fatalf("Missing database migrations") } n, err := migrate.Exec(db.DB.DB, db.Config.Driver, migrationSource, migrate.Up) if err != nil { log.Errorf("Error applying database migrations: %s", err) } if n > 0 { log.Infof("Applied %d database migrations!", n) } } // Health check health of database func (db *sqlstore) Health() error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() return db.PingContext(ctx) } // Closes the db pool func (db *sqlstore) Close() error { return db.DB.Close() } func mapError(err error) error { if err == sql.ErrNoRows { return ErrNoResults } return nil } ================================================ FILE: pkg/datastore/sqlstore/users.go ================================================ package sqlstore import ( "database/sql" "github.com/usefathom/fathom/pkg/models" ) // GetUser retrieves user from datastore by its ID func (db *sqlstore) GetUser(ID int64) (*models.User, error) { u := &models.User{} query := db.Rebind("SELECT * FROM users WHERE id = ? LIMIT 1") err := db.Get(u, query, ID) if err != nil { if err == sql.ErrNoRows { return nil, ErrNoResults } return nil, err } return u, err } // GetUserByEmail retrieves user from datastore by its email func (db *sqlstore) GetUserByEmail(email string) (*models.User, error) { u := &models.User{} query := db.Rebind("SELECT * FROM users WHERE email = ? LIMIT 1") err := db.Get(u, query, email) return u, mapError(err) } // SaveUser inserts the user model in the connected database func (db *sqlstore) SaveUser(u *models.User) error { if u.ID > 0 { return db.updateUser(u) } return db.insertUser(u) } // insertUser saves a new user in the database func (db *sqlstore) insertUser(u *models.User) error { var query = db.Rebind("INSERT INTO users(email, password) VALUES(?, ?)") // Postgres does not support LastInsertID, so use a "... RETURNING" select query if db.Driver == POSTGRES { err := db.Get(&u.ID, query+" RETURNING id", u.Email, u.Password) return err } // MySQL and SQLite don't support RETURNING, but do support LastInsertId result, err := db.Exec(query, u.Email, u.Password) if err != nil { return err } u.ID, err = result.LastInsertId() return err } // updateUser updates an existing user in the database func (db *sqlstore) updateUser(u *models.User) error { var query = db.Rebind("UPDATE users SET email = ?, password = ? WHERE id = ?") _, err := db.Exec(query, u.Email, u.Password, u.ID) return err } // DeleteUser deletes the user in the datastore func (db *sqlstore) DeleteUser(u *models.User) error { query := db.Rebind("DELETE FROM users WHERE id = ?") _, err := db.Exec(query, u.ID) return err } // CountUsers returns the number of users func (db *sqlstore) CountUsers() (int64, error) { var c int64 var sql = `SELECT COUNT(*) FROM users` query := db.Rebind(sql) err := db.Get(&c, query) return c, err } ================================================ FILE: pkg/models/page_stats.go ================================================ package models import ( "time" ) type PageStats struct { New bool `db:"-" json:"-"` SiteID int64 `db:"site_id" json:"-"` HostnameID int64 `db:"hostname_id" json:"-"` PathnameID int64 `db:"pathname_id" json:"-"` Hostname string `db:"hostname"` Pathname string `db:"pathname"` Pageviews int64 `db:"pageviews"` Visitors int64 `db:"visitors"` Entries int64 `db:"entries"` BounceRate float64 `db:"bounce_rate"` AvgDuration float64 `db:"avg_duration"` KnownDurations int64 `db:"known_durations"` Date time.Time `db:"ts" json:",omitempty"` } func (s *PageStats) HandlePageview(p *Pageview) { s.Pageviews += 1 if p.IsUnique { s.Visitors += 1 } if p.Duration > 0.00 { s.KnownDurations += 1 s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) } if p.IsNewSession { s.Entries += 1 if p.IsBounce { s.BounceRate = ((float64(s.Entries-1) * s.BounceRate) + 1.00) / (float64(s.Entries)) } else { s.BounceRate = ((float64(s.Entries-1) * s.BounceRate) + 0.00) / (float64(s.Entries)) } } } ================================================ FILE: pkg/models/page_stats_test.go ================================================ package models import "testing" func TestPageStatsHandlePageview(t *testing.T) { s := PageStats{} p1 := &Pageview{ Duration: 100, IsBounce: false, IsUnique: true, IsNewSession: true, } p2 := &Pageview{ Duration: 60, IsUnique: false, IsNewSession: false, IsBounce: true, // should have no effect because only new sessions can bounce } p3 := &Pageview{ IsUnique: true, IsNewSession: true, IsBounce: true, } // add first pageview & test s.HandlePageview(p1) if s.Pageviews != 1 { t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 100 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) } if s.BounceRate != 0.00 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) } // add second pageview s.HandlePageview(p2) if s.Pageviews != 2 { t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 80 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) } // should still be 0.00 because p2 was not a new session if s.BounceRate != 0.00 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) } // add third pageview s.HandlePageview(p3) if s.Visitors != 2 { t.Errorf("Visitors: expected %d, got %d", 2, s.Visitors) } if s.BounceRate != 0.50 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) } } ================================================ FILE: pkg/models/pageview.go ================================================ package models import ( "time" ) type Pageview struct { ID string `db:"id"` SiteTrackingID string `db:"site_tracking_id"` Hostname string `db:"hostname"` Pathname string `db:"pathname"` IsNewVisitor bool `db:"is_new_visitor"` IsNewSession bool `db:"is_new_session"` IsUnique bool `db:"is_unique"` IsBounce bool `db:"is_bounce"` IsFinished bool `db:"is_finished"` Referrer string `db:"referrer"` Duration int64 `db:"duration"` Timestamp time.Time `db:"timestamp"` } ================================================ FILE: pkg/models/referrer_stats.go ================================================ package models import ( "time" ) type ReferrerStats struct { New bool `db:"-" json:"-"` SiteID int64 `db:"site_id" json:"-"` HostnameID int64 `db:"hostname_id" json:"-"` PathnameID int64 `db:"pathname_id" json:"-"` Hostname string `db:"hostname"` Pathname string `db:"pathname"` Group string `db:"groupname"` Visitors int64 `db:"visitors"` Pageviews int64 `db:"pageviews"` BounceRate float64 `db:"bounce_rate"` AvgDuration float64 `db:"avg_duration"` KnownDurations int64 `db:"known_durations"` Date time.Time `db:"ts" json:",omitempty"` } func (s *ReferrerStats) HandlePageview(p *Pageview) { s.Pageviews += 1 if p.IsNewVisitor { s.Visitors += 1 } if p.IsBounce { s.BounceRate = ((float64(s.Pageviews-1) * s.BounceRate) + 1.00) / (float64(s.Pageviews)) } else { s.BounceRate = ((float64(s.Pageviews-1) * s.BounceRate) + 0.00) / (float64(s.Pageviews)) } if p.Duration > 0.00 { s.KnownDurations += 1 s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) } } ================================================ FILE: pkg/models/referrer_stats_test.go ================================================ package models import "testing" func TestReferrerStatsHandlePageview(t *testing.T) { s := ReferrerStats{} p1 := &Pageview{ Duration: 100, IsBounce: false, IsNewVisitor: true, } p2 := &Pageview{ Duration: 60, IsNewVisitor: false, IsBounce: true, } p3 := &Pageview{ IsNewSession: true, IsBounce: true, } // add first pageview & test s.HandlePageview(p1) if s.Pageviews != 1 { t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 100 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) } if s.BounceRate != 0.00 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) } // add second pageview s.HandlePageview(p2) if s.Pageviews != 2 { t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 80 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) } if s.BounceRate != 0.50 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) } // add third pageview s.HandlePageview(p3) if int64(100.00*s.BounceRate) != 66 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.67, s.BounceRate) } } ================================================ FILE: pkg/models/site.go ================================================ package models // Site represents a group for tracking data type Site struct { ID int64 `db:"id" json:"id"` TrackingID string `db:"tracking_id" json:"trackingId"` Name string `db:"name" json:"name"` } ================================================ FILE: pkg/models/site_stats.go ================================================ package models import ( "fmt" "time" ) type SiteStats struct { New bool `db:"-" json:"-" ` SiteID int64 `db:"site_id" json:"-"` Visitors int64 `db:"visitors"` Pageviews int64 `db:"pageviews"` Sessions int64 `db:"sessions"` BounceRate float64 `db:"bounce_rate"` AvgDuration float64 `db:"avg_duration"` KnownDurations int64 `db:"known_durations" json:",omitempty"` Date time.Time `db:"ts" json:",omitempty"` } func (s *SiteStats) FormattedDuration() string { return fmt.Sprintf("%d:%d", int(s.AvgDuration/60.00), (int(s.AvgDuration) % 60)) } func (s *SiteStats) HandlePageview(p *Pageview) { s.Pageviews += 1 if p.Duration > 0.00 { s.KnownDurations += 1 s.AvgDuration = s.AvgDuration + ((float64(p.Duration) - s.AvgDuration) * 1 / float64(s.KnownDurations)) } if p.IsNewVisitor { s.Visitors += 1 } if p.IsNewSession { s.Sessions += 1 if p.IsBounce { s.BounceRate = ((float64(s.Sessions-1) * s.BounceRate) + 1) / (float64(s.Sessions)) } else { s.BounceRate = ((float64(s.Sessions-1) * s.BounceRate) + 0) / (float64(s.Sessions)) } } } ================================================ FILE: pkg/models/site_stats_test.go ================================================ package models import ( "testing" ) func TestSiteStatsFormattedDuration(t *testing.T) { s := SiteStats{ AvgDuration: 100.00, } e := "1:40" if v := s.FormattedDuration(); v != e { t.Errorf("FormattedDuration: expected %s, got %s", e, v) } s.AvgDuration = 1040.22 e = "17:20" if v := s.FormattedDuration(); v != e { t.Errorf("FormattedDuration: expected %s, got %s", e, v) } } func TestSiteStatsHandlePageview(t *testing.T) { s := SiteStats{} p1 := &Pageview{ Duration: 100, IsBounce: false, IsNewVisitor: true, IsNewSession: true, } p2 := &Pageview{ Duration: 60, IsNewVisitor: false, IsNewSession: false, IsBounce: true, // should have no effect because only new sessions can bounce } p3 := &Pageview{ IsNewSession: true, IsBounce: true, } // add first pageview & test s.HandlePageview(p1) if s.Pageviews != 1 { t.Errorf("Pageviews: expected %d, got %d", 1, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 100 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 100.00, s.AvgDuration) } if s.BounceRate != 0.00 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) } // add second pageview s.HandlePageview(p2) if s.Pageviews != 2 { t.Errorf("Pageviews: expected %d, got %d", 2, s.Pageviews) } if s.Visitors != 1 { t.Errorf("Visitors: expected %d, got %d", 1, s.Visitors) } if s.AvgDuration != 80 { t.Errorf("AvgDuration: expected %.2f, got %.2f", 80.00, s.AvgDuration) } // should still be 0.00 because p2 was not a new session if s.BounceRate != 0.00 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.00, s.BounceRate) } // add third pageview s.HandlePageview(p3) if s.BounceRate != 0.50 { t.Errorf("BounceRate: expected %.2f, got %.2f", 0.50, s.BounceRate) } } ================================================ FILE: pkg/models/user.go ================================================ package models import ( "strings" "golang.org/x/crypto/bcrypt" ) type User struct { ID int64 Email string Password string `json:"-"` } // NewUser creates a new User with the given email and password func NewUser(e string, pwd string) User { u := User{ Email: strings.ToLower(strings.TrimSpace(e)), } u.SetPassword(pwd) return u } // SetPassword sets a brcrypt encrypted password from the given plaintext pwd func (u *User) SetPassword(pwd string) { hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) u.Password = string(hash) } // ComparePassword returns true when the given plaintext password matches the encrypted pwd func (u *User) ComparePassword(pwd string) error { return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pwd)) } ================================================ FILE: pkg/models/user_test.go ================================================ package models import ( "testing" ) func TestNewUser(t *testing.T) { email := "foo@bar.com" pwd := "passw0rd01" u := NewUser(email, pwd) if u.Email != email { t.Errorf("Email: expected %s, got %s", email, u.Email) } if u.ComparePassword(pwd) != nil { t.Error("Password not set correctly") } } func TestUserPassword(t *testing.T) { u := &User{} u.SetPassword("password") if u.ComparePassword("password") != nil { t.Errorf("Password should match, but does not") } }