├── bgrun ├── go.sum ├── .github │ └── FUNDING.yml ├── go.mod ├── README.md ├── LICENSE └── default.go ├── .github └── FUNDING.yml ├── tpl ├── help │ ├── 404.md │ ├── translating.md │ ├── spa.md │ ├── campaigns.md │ ├── backend.md │ ├── countjs-location.md │ ├── csp.md │ ├── countjs-host.md │ ├── logfile.md │ ├── skip-path.md │ ├── domains.md │ ├── skip-dev.md │ ├── frame.md │ ├── consent.md │ ├── events.md │ ├── modify.md │ ├── privacy.md │ ├── path.md │ └── countjs-versions.md ├── _email_top.gotxt ├── _dashboard_pages_refs.gohtml ├── _email_bottom.gohtml ├── _email_bottom.gotxt ├── email_import_error.gotxt ├── email_verify.gotxt ├── _dashboard_configure_widget.gohtml ├── user.gohtml ├── contact.gohtml ├── _dashboard_warn_collect.gohtml ├── email_forgot_site.gotxt ├── email_import_done.gotxt ├── email_password_reset.gotxt ├── _dashboard_widgets.gohtml ├── contribute.gohtml ├── email_adduser.gotxt ├── _bottom.gohtml ├── user_forgot_pw.gohtml ├── email_export_done.gotxt ├── _backend_signin.gohtml ├── _dashboard_totals_row.gohtml ├── _favicon.gohtml ├── _user_nav.gohtml ├── email_welcome.gotxt ├── _dashboard_hchart.gohtml ├── email_report.gotxt ├── totp.gohtml ├── _dashboard_toprefs.gohtml ├── user_reset.gohtml ├── _user_dashboard_widgets.gohtml ├── _top.gohtml ├── api2.html ├── bosmang_cache.gohtml ├── user_forgot_code.gohtml ├── _dashboard_totals.gohtml ├── _backend_bottom.gohtml ├── _settings_nav.gohtml ├── _bottom_links.gohtml ├── settings_changecode.gohtml ├── settings_sites_rm_confirm.gohtml ├── bosmang_metrics.gohtml ├── settings_server.gohtml ├── settings_users.gohtml ├── _user_dashboard_widget.gohtml ├── bosmang_sites.gohtml ├── _dashboard_pages.gohtml ├── user_dashboard.gohtml ├── _dashboard_pages_text.gohtml ├── _contact.gohtml ├── _dashboard_pages_text_rows.gohtml ├── email_report.gohtml ├── bosmang_bgrun.gohtml ├── i18n_list.gohtml ├── signup.gohtml └── settings_delete.gohtml ├── .ignore ├── db ├── migrate │ ├── 2021-12-13-1-drop-role-postgres.sql │ ├── 2022-11-17-1-open-at.sql │ ├── 2023-12-15-1-rm-updates.sql │ ├── 2021-04-02-1-cluster-paths-postgres.sql │ ├── 2021-11-15-1-user-role-postgres.sql │ ├── 2022-01-14-1-idx.sql │ ├── 2022-11-05-1-paths-title.sql │ ├── 2022-10-21-1-apitoken-lastused.gotxt │ ├── 2021-12-13-2-superuser.sql │ ├── 2021-06-27-1-public-postgres.sql │ ├── 2021-06-27-1-public-sqlite.sql │ ├── 2021-04-01-1-store-warn.sql │ ├── 2021-12-02-2-language-enable-postgres.sql │ ├── 2021-12-09-1-email-reports-postgres.sql │ ├── 2021-04-07-1-billing-anchor-sqlite.sql │ ├── 2021-12-02-2-language-enable-sqlite.sql │ ├── 2022-02-16-1-rm-billing-sqlite.sql │ ├── 2022-02-16-1-rm-billing-postgres.sql │ ├── 2022-11-03-1-uncount.sql │ ├── gomig │ │ └── gomig.go │ ├── 2022-11-03-2-ununique.sql │ ├── 2022-10-17-1-campaigns.gotxt │ ├── 2021-04-07-1-billing-anchor-postgres.sql │ ├── 2022-03-06-1-campaigns.gotxt │ ├── 2022-01-13-1-unfk-postgres.sql │ └── 2021-12-09-1-email-reports-sqlite.sql └── query │ ├── paths.List.sql │ ├── hit_list.List-stats.sql │ ├── hit_stats.ListSizes.sql │ ├── hit_list.Totals.sql │ ├── hit_stats.ListCampaign.sql │ ├── hit_stats.ListSystem.sql │ ├── hit_stats.ListBrowser.sql │ ├── hit_list.PathCount.sql │ ├── hit_stats.ListSize.sql │ ├── hit_stats.ListLocation.sql │ ├── ref.ListRefsByPathID.sql │ ├── hit_stats.ListSystems.sql │ ├── hit_stats.ListBrowsers.sql │ ├── hit_list.List-counts.sql │ ├── hit_stats.ListCampaigns.sql │ ├── hit_stats.ListLanguages.sql │ ├── hit_stats.ListLocations.sql │ ├── hit_stats.ByRef.sql │ ├── hit_list.ListPathsLike.gotxt │ ├── paths.PathFilter.sql │ ├── hit_list.DiffTotal.sql │ ├── ref.ListTopRefs.sql │ ├── bosmang.List.sql │ └── hit_list.GetTotalCount.sql ├── public ├── logo.png ├── int-logo │ ├── wp.png │ ├── schlix.png │ ├── gatsby.svg │ └── write-as.svg ├── screenshot.png ├── screenshot2.png ├── screenshot3.png ├── fonts │ ├── latolatin.woff2 │ ├── latolatin-bold.woff2 │ └── latolatin-italic.woff2 ├── vcounter │ ├── vcounter.png │ └── vcounter-total.png ├── favicon │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── browserconfig.xml │ └── site.webmanifest ├── index.html ├── logo.svg ├── i18n.js ├── i18n.css └── script.js ├── pack └── GeoLite2-Country.mmdb.gz ├── deploy ├── README.md └── alpine │ └── README.md ├── .gitattributes ├── logscan └── testdata │ ├── common │ ├── common-vhost │ ├── combined │ └── combined-vhost ├── cmd └── goatcounter │ ├── testdata │ ├── access_log │ └── export.csv │ ├── pg_test.go │ ├── old.go │ ├── help_test.go │ ├── serve_test.go │ ├── saas_test.go │ ├── goat.go │ ├── monitor_test.go │ ├── db_migrate.go │ └── main_test.go ├── .editorconfig ├── .gogo-release ├── gctest └── pg.go ├── i18n └── en-GB.toml ├── .gitignore ├── helper_cgo.go ├── run-ci ├── netlify.toml ├── context_test.go ├── kommentaar.conf ├── metrics └── metrics_test.go ├── helper_test.go ├── cron ├── campaign_stat_test.go ├── hit_count.go ├── ref_count.go ├── language_stat.go ├── system_stat.go ├── size_stat.go ├── tasks_test.go ├── browser_stat.go ├── campaign_stat.go ├── location_stat_test.go ├── location_stat.go └── browser_stat_test.go ├── refspam_test.go ├── path_test.go ├── widgets ├── dummy.go ├── internal.go ├── languages.go ├── sizes.go ├── toprefs.go ├── systems.go ├── browsers.go └── campaigns.go ├── campaign.go ├── size.go ├── gen.go ├── acme └── acme_test.go ├── locations_test.go ├── tpl_test.go ├── handlers ├── dashboard_test.go └── website_test.go └── bosmang.go /bgrun/go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: arp242 2 | -------------------------------------------------------------------------------- /bgrun/.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: arp242 2 | -------------------------------------------------------------------------------- /bgrun/go.mod: -------------------------------------------------------------------------------- 1 | module zgo.at/bgrun 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /tpl/help/404.md: -------------------------------------------------------------------------------- 1 | This page doesn't seem to exist. 2 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | /public/jquery.js 2 | /public/*.min.* 3 | /pack 4 | /msg 5 | -------------------------------------------------------------------------------- /tpl/_email_top.gotxt: -------------------------------------------------------------------------------- 1 | {{t .Context "email/header|Hi there,"}} 2 | -------------------------------------------------------------------------------- /db/migrate/2021-12-13-1-drop-role-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table users drop column role; 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/logo.png -------------------------------------------------------------------------------- /db/migrate/2022-11-17-1-open-at.sql: -------------------------------------------------------------------------------- 1 | alter table users add column open_at timestamp null; 2 | -------------------------------------------------------------------------------- /db/migrate/2023-12-15-1-rm-updates.sql: -------------------------------------------------------------------------------- 1 | alter table users drop column seen_updates_at; 2 | -------------------------------------------------------------------------------- /tpl/_dashboard_pages_refs.gohtml: -------------------------------------------------------------------------------- 1 | {{horizontal_chart .Context .Refs .Count false true}} 2 | -------------------------------------------------------------------------------- /db/migrate/2021-04-02-1-cluster-paths-postgres.sql: -------------------------------------------------------------------------------- 1 | cluster paths using "paths#site_id#path"; 2 | -------------------------------------------------------------------------------- /db/migrate/2021-11-15-1-user-role-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table users drop constraint users_role_check; 2 | -------------------------------------------------------------------------------- /public/int-logo/wp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/int-logo/wp.png -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/screenshot.png -------------------------------------------------------------------------------- /public/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/screenshot2.png -------------------------------------------------------------------------------- /public/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/screenshot3.png -------------------------------------------------------------------------------- /bgrun/README.md: -------------------------------------------------------------------------------- 1 | Need to put this in its own repo; just need to finish some things and write 2 | docs. 3 | -------------------------------------------------------------------------------- /db/migrate/2022-01-14-1-idx.sql: -------------------------------------------------------------------------------- 1 | drop index "sites#parent"; 2 | create index "sites#parent" on sites(parent); 3 | -------------------------------------------------------------------------------- /public/fonts/latolatin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/fonts/latolatin.woff2 -------------------------------------------------------------------------------- /public/int-logo/schlix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/int-logo/schlix.png -------------------------------------------------------------------------------- /public/vcounter/vcounter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/vcounter/vcounter.png -------------------------------------------------------------------------------- /pack/GeoLite2-Country.mmdb.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/pack/GeoLite2-Country.mmdb.gz -------------------------------------------------------------------------------- /public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /public/fonts/latolatin-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/fonts/latolatin-bold.woff2 -------------------------------------------------------------------------------- /public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/latolatin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/fonts/latolatin-italic.woff2 -------------------------------------------------------------------------------- /public/vcounter/vcounter-total.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/vcounter/vcounter-total.png -------------------------------------------------------------------------------- /db/migrate/2022-11-05-1-paths-title.sql: -------------------------------------------------------------------------------- 1 | drop index "paths#path#title"; 2 | create index "paths#title" on paths(lower(title)); 3 | -------------------------------------------------------------------------------- /public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweiske/goatcounter/master/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /db/migrate/2022-10-21-1-apitoken-lastused.gotxt: -------------------------------------------------------------------------------- 1 | alter table api_tokens 2 | add column last_used_at timestamp {{check_timestamp "created_at"}}; 3 | -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | Various ways to deploy GoatCounter; see the subdirectories for details. 2 | 3 | - alpine – Set up an Alpine Linux machine. 4 | -------------------------------------------------------------------------------- /db/query/paths.List.sql: -------------------------------------------------------------------------------- 1 | select * from paths 2 | where 3 | site_id = :site 4 | {{:after and path_id > :after}} 5 | order by path_id asc 6 | {{:limit limit :limit}} 7 | -------------------------------------------------------------------------------- /tpl/_email_bottom.gohtml: -------------------------------------------------------------------------------- 1 |

Any problems, questions, comments, or something else to tell me? Just reply to this email.

2 | 3 |

Cheers,
4 | Martin

5 | -------------------------------------------------------------------------------- /tpl/_email_bottom.gotxt: -------------------------------------------------------------------------------- 1 | {{t .Context `email/signature|Any problems, questions, comments, or something else to tell me? Just reply to this email. 2 | 3 | Cheers, 4 | Martin`}} 5 | -------------------------------------------------------------------------------- /tpl/email_import_error.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | There was an error importing your data :-( 3 | 4 | The reported error: {{.Error}} 5 | 6 | {{template "_email_bottom.gotxt" .}} 7 | -------------------------------------------------------------------------------- /db/query/hit_list.List-stats.sql: -------------------------------------------------------------------------------- 1 | select path_id, day, stats 2 | from hit_stats 3 | where 4 | hit_stats.site_id = :site and 5 | path_id in (:paths) and 6 | day >= :start and day <= :end 7 | order by day asc 8 | -------------------------------------------------------------------------------- /tpl/email_verify.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | Please go here to verify your GoatCounter email address: 3 | {{.Site.URL .Context}}/user/verify/{{.User.EmailToken}} 4 | 5 | {{template "_email_bottom.gotxt" .}} 6 | -------------------------------------------------------------------------------- /db/migrate/2021-12-13-2-superuser.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select count(*) as count, site_id from users group by site_id 3 | ) 4 | update users set access = '{"all": "*"}' from x 5 | where x.count = 1 and users.site_id = x.site_id 6 | -------------------------------------------------------------------------------- /tpl/_dashboard_configure_widget.gohtml: -------------------------------------------------------------------------------- 1 |
2 | {{template "_user_dashboard_widget.gohtml" .}} 3 | 4 |
5 | -------------------------------------------------------------------------------- /tpl/user.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

{{.T "header/sign-in-at|Sign in at %(name)" (.Site.Display .Context)}}

4 | {{template "_backend_signin.gohtml" .}} 5 | 6 | {{template "_backend_bottom.gohtml" .}} 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Mark as generated so it won't show up in diffs etc. 2 | /go.sum linguist-generated=true 3 | /tpl/api.html linguist-generated=true 4 | /tpl/api.json linguist-generated=true 5 | /refspam.go linguist-generated=true 6 | -------------------------------------------------------------------------------- /logscan/testdata/common: -------------------------------------------------------------------------------- 1 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" 200 2326 2 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 3 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /dash-size HTTP/2.0" 200 - 4 | -------------------------------------------------------------------------------- /tpl/contact.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_top.gohtml" .}} 2 | 3 |

GoatCounter contact

4 |

There are a few ways to contact me:

5 | {{template "_contact.gohtml" (map "v" .Validate "r" "/contact" "a" .Args)}} 6 | 7 | {{template "_bottom.gohtml" .}} 8 | -------------------------------------------------------------------------------- /cmd/goatcounter/testdata/access_log: -------------------------------------------------------------------------------- 1 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 "http://www.example.com/start.html" "Mozilla/5.0" 2 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 "-" "Mozilla/5.0" 3 | -------------------------------------------------------------------------------- /db/migrate/2021-06-27-1-public-postgres.sql: -------------------------------------------------------------------------------- 1 | update sites set settings = jsonb_set(settings, '{public}', '"public"') where settings->'public' = 'true'; 2 | update sites set settings = jsonb_set(settings, '{public}', '"private"') where settings->'public' = 'false'; 3 | -------------------------------------------------------------------------------- /db/migrate/2021-06-27-1-public-sqlite.sql: -------------------------------------------------------------------------------- 1 | update sites set settings = json_set(settings, '$.public', 'public') where json_extract(settings, '$.public') = 1; 2 | update sites set settings = json_set(settings, '$.public', 'private') where json_extract(settings, '$.public') = 0; 3 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListSizes.sql: -------------------------------------------------------------------------------- 1 | select 2 | width as name, 3 | sum(count) as count 4 | from size_stats 5 | where 6 | site_id = :site and day >= :start and day <= :end 7 | {{:filter and path_id in (:filter)}} 8 | group by width 9 | order by count desc, name asc 10 | -------------------------------------------------------------------------------- /tpl/_dashboard_warn_collect.gohtml: -------------------------------------------------------------------------------- 1 | {{if not .IsCollected}} 2 |
3 | {{t .Context "p/collect-disabled|Collecting this information is currently %[disabled in settings]." 4 | (tag "a" `href="/settings/main#section-collect"`)}} 5 |
6 | {{end}} 7 | 8 | -------------------------------------------------------------------------------- /logscan/testdata/common-vhost: -------------------------------------------------------------------------------- 1 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" 200 2326 2 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 3 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /dash-size HTTP/2.0" 200 - 4 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | GoatCounter CDN 5 | 6 | 7 |

This is a CDN to serve static files for 8 | GoatCounter; 9 | not much else here.

10 | 11 | 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.{yaml,yml}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.{css,markdown,md}] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /public/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #9f00a7 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tpl/email_forgot_site.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | You requested a list of GoatCounter sites associated with ‘{{.Email}}’: 3 | {{range $s := .Sites}} 4 | - {{$s.URL $.Context}} 5 | {{else}} 6 | There are no GoatCounter domains associated with this email. 7 | {{end}} 8 | {{template "_email_bottom.gotxt" .}} 9 | -------------------------------------------------------------------------------- /tpl/email_import_done.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | Your import is finished; {{.Rows}} pageviews were imported successfully {{if eq .Errors.Len 0}}and there were no errors{{else}}but some pageviews could not be imported{{end}}. 3 | {{if gt .Errors.Len 0}} 4 | {{.Errors}}{{end}} 5 | {{template "_email_bottom.gotxt" .}} 6 | -------------------------------------------------------------------------------- /.gogo-release: -------------------------------------------------------------------------------- 1 | matrix=" 2 | linux amd64 3 | linux arm CC=armv7l-linux-musleabihf-gcc 4 | linux arm64 CC=aarch64-linux-musl-gcc 5 | " 6 | 7 | build_flags="-trimpath -ldflags='-extldflags=-static -w -s -X zgo.at/goatcounter/v2.Version=$tag' -tags osusergo,netgo,sqlite_omit_load_extension ./cmd/goatcounter" 8 | 9 | export CGO_ENABLED=1 10 | -------------------------------------------------------------------------------- /db/query/hit_list.Totals.sql: -------------------------------------------------------------------------------- 1 | select 2 | hour, 3 | sum(total) as total 4 | from hit_counts 5 | {{:no_events join paths using (path_id)}} 6 | where 7 | hit_counts.site_id = :site and hour >= :start and hour <= :end 8 | {{:no_events and paths.event = 0}} 9 | {{:filter and path_id in (:filter)}} 10 | group by hour 11 | order by hour asc 12 | -------------------------------------------------------------------------------- /tpl/email_password_reset.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | {{t .Context `email/password-reset|Someone (hopefully you) requested to reset the password on your GoatCounter account. 3 | 4 | You can do this here: 5 | %(link)` 6 | (printf "%s/user/reset/%s" (.Site.URL .Context) (deref .User.LoginRequest))}} 7 | 8 | {{template "_email_bottom.gotxt" .}} 9 | -------------------------------------------------------------------------------- /tpl/_dashboard_widgets.gohtml: -------------------------------------------------------------------------------- 1 |
2 | {{$div := false}} 3 | {{range $w := .Widgets}} 4 | {{if and (eq $w.Type "hchart") (not $div)}} 5 | {{$div = true}} 6 |
7 | {{end}} 8 | {{if and (ne $w.Type "hchart") $div}}
{{$div = false}}{{end}} 9 | {{$w.HTML}} 10 | {{end}} 11 | {{if $div}}
{{end}} 12 | 13 | -------------------------------------------------------------------------------- /cmd/goatcounter/pg_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | //go:build testpg 6 | // +build testpg 7 | 8 | package main 9 | 10 | func init() { 11 | pgSQL = true 12 | } 13 | -------------------------------------------------------------------------------- /db/migrate/2021-04-01-1-store-warn.sql: -------------------------------------------------------------------------------- 1 | delete from store where key='session'; 2 | 3 | insert into store (key, value) values ('display-once', 4 | 'If you just upgraded to 2.0 then you need to run "goatcounter reindex" to 5 | rebuild some tables; see the release notes: 6 | https://github.com/arp242/goatcounter/releases/tag/v2.0.0 7 | 8 | There are also some other incompatible changes.'); 9 | -------------------------------------------------------------------------------- /db/migrate/2021-12-02-2-language-enable-postgres.sql: -------------------------------------------------------------------------------- 1 | update sites set 2 | settings = jsonb_set(settings, '{collect}', to_jsonb(cast(settings->'collect' as int) | 64)), 3 | user_defaults = jsonb_set(user_defaults, '{widgets}', user_defaults->'widgets' || '[{"n":"languages"}]'); 4 | update users set 5 | settings = jsonb_set(settings, '{widgets}', settings->'widgets' || '[{"n":"languages"}]'); 6 | -------------------------------------------------------------------------------- /tpl/help/translating.md: -------------------------------------------------------------------------------- 1 | To translate GoatCounter you need to login in to your account and go to `/i18n`; 2 | there's a link in the user settings next to the language field as well. Here you 3 | can edit/update existing translations or add new ones; further instructions are 4 | on that page. 5 | 6 | Current translations: 7 | 8 | - Dutch 9 | - Italian 10 | - Spanish (Chilean) 11 | - Turkish 12 | -------------------------------------------------------------------------------- /logscan/testdata/combined: -------------------------------------------------------------------------------- 1 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" 200 2326 "http://www.example.com/start.html" "Mozilla/5.0" 2 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 "-" "-" 3 | 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /dash-size HTTP/2.0" 200 - "-" "-" 4 | 1.1.1.1 - - [15/May/2023:00:00:54 +0000] "GET /proxy.pac HTTP/1.1" 200 133 "" "" 5 | -------------------------------------------------------------------------------- /tpl/contribute.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_top.gohtml" .}} 2 | 3 |

Contribute financially

4 |

Servers aren't free, and running goatcounter.com isn't free either. Please 5 | consider contributing on GitHub 6 | sponsor. You can do a one-time contribution or set up a recurring monthly 7 | contribution.

8 | 9 | {{template "_bottom.gohtml" .}} 10 | -------------------------------------------------------------------------------- /public/int-logo/gatsby.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /db/migrate/2021-12-09-1-email-reports-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table users add column last_report_at timestamp not null default current_timestamp; 2 | 3 | create or replace function percent_diff(start float4, final float4) returns float4 as $$ 4 | begin 5 | return case 6 | when start=0 then float4 '+infinity' 7 | else (final - start) / start * 100 8 | end; 9 | end; $$ language plpgsql immutable strict; 10 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListCampaign.sql: -------------------------------------------------------------------------------- 1 | select 2 | ref as name, 3 | sum(count) as count 4 | from campaign_stats 5 | join campaigns using (campaign_id) 6 | where 7 | campaign_stats.site_id = :site and day >= :start and day <= :end and 8 | {{:filter path_id in (:filter) and}} 9 | campaign_id = :campaign 10 | group by campaign_id, ref 11 | order by count desc, ref asc 12 | limit :limit offset :offset 13 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListSystem.sql: -------------------------------------------------------------------------------- 1 | select 2 | trim(name || ' ' || version) as name, 3 | sum(count) as count 4 | from system_stats 5 | join systems using (system_id) 6 | where 7 | site_id = :site and day >= :start and day <= :end and 8 | {{:filter path_id in (:filter) and}} 9 | lower(name) = lower(:system) 10 | group by name, version 11 | order by count desc, name asc 12 | limit :limit offset :offset 13 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListBrowser.sql: -------------------------------------------------------------------------------- 1 | select 2 | trim(name || ' ' || version) as name, 3 | sum(count) as count 4 | from browser_stats 5 | join browsers using (browser_id) 6 | where 7 | site_id = :site and day >= :start and day <= :end and 8 | {{:filter path_id in (:filter) and}} 9 | lower(name) = lower(:browser) 10 | group by name, version 11 | order by count desc, name asc 12 | limit :limit offset :offset 13 | -------------------------------------------------------------------------------- /gctest/pg.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | //go:build testpg 6 | // +build testpg 7 | 8 | package gctest 9 | 10 | import ( 11 | _ "zgo.at/zdb/drivers/pq" 12 | ) 13 | 14 | func init() { 15 | pgSQL = true 16 | } 17 | -------------------------------------------------------------------------------- /db/query/hit_list.PathCount.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select path_id, path from paths 3 | where site_id = :site and lower(path) = lower(:path) 4 | ) 5 | select 6 | x.path, 7 | coalesce(sum(total), 0) as count 8 | from hit_counts 9 | join x using (path_id) 10 | where 11 | site_id = :site and 12 | path_id = x.path_id 13 | {{:start and hour >= :start}} 14 | {{:end and hour <= :end}} 15 | group by x.path, hit_counts.path_id 16 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListSize.sql: -------------------------------------------------------------------------------- 1 | select 2 | '↔ ' || width || 'px' as name, 3 | sum(count) as count 4 | from size_stats 5 | where 6 | site_id = :site and day >= :start and day <= :end 7 | {{:filter and path_id in (:filter)}} 8 | {{:max_size and width != 0 and width > :min_size and width <= :max_size}} 9 | {{:empty and width = 0}} 10 | group by width 11 | order by count desc, name asc 12 | limit :limit offset :offset 13 | -------------------------------------------------------------------------------- /tpl/help/spa.md: -------------------------------------------------------------------------------- 1 | Custom `count()` example for hooking in to an SPA nagivating by `#`: 2 | 3 | 12 | {{template "code" .}} 13 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListLocation.sql: -------------------------------------------------------------------------------- 1 | select 2 | coalesce(region_name, '(unknown)') as name, 3 | sum(count) as count 4 | from location_stats 5 | join locations on location = iso_3166_2 6 | where 7 | site_id = :site and day >= :start and day <= :end and 8 | {{:filter path_id in (:filter) and}} 9 | country = :country 10 | group by iso_3166_2, name 11 | order by count desc, name asc 12 | limit :limit offset :offset 13 | -------------------------------------------------------------------------------- /db/query/ref.ListRefsByPathID.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | ref_id, 4 | coalesce(sum(total), 0) as count 5 | from ref_counts 6 | where 7 | site_id = :site and path_id = :path and hour >= :start and hour <= :end 8 | group by ref_id 9 | limit :limit offset :offset 10 | ) 11 | select 12 | x.count, 13 | refs.ref_scheme as ref_scheme, 14 | refs.ref as name 15 | from x 16 | left join refs using (ref_id) 17 | order by count desc, ref 18 | -------------------------------------------------------------------------------- /logscan/testdata/combined-vhost: -------------------------------------------------------------------------------- 1 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET / HTTP/1.1" 200 2326 "http://www.example.com/start.html" "Mozilla/5.0" 2 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /test.html HTTP/1.1" 200 2326 "-" "-" 3 | example.com:127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /dash-size HTTP/2.0" 200 - "-" "-" 4 | example.com:1.1.1.1 - - [15/May/2023:00:00:54 +0000] "GET /proxy.pac HTTP/1.1" 200 133 "" "" 5 | -------------------------------------------------------------------------------- /db/migrate/2021-04-07-1-billing-anchor-sqlite.sql: -------------------------------------------------------------------------------- 1 | alter table sites add column billing_anchor timestamp; 2 | alter table sites add column notes text not null default ''; 3 | alter table sites add column extra_pageviews int; 4 | alter table sites add column extra_pageviews_sub varchar; 5 | 6 | -- No need to update sites as SQLite doesn't support saas/billing. 7 | alter table sites drop column plan; 8 | alter table sites add column plan varchar not null default 'free'; 9 | -------------------------------------------------------------------------------- /i18n/en-GB.toml: -------------------------------------------------------------------------------- 1 | [__meta__] 2 | generated = 2021-11-25T14:01:49Z 3 | language = "en-GB" 4 | maintainers = ["Martin Tournoij "] 5 | no-update = true 6 | 7 | ["dashboard/day-ago"] 8 | default = "%(n) days ago" 9 | one = "1 day ago" 10 | 11 | ["dashboard/week-ago"] 12 | default = "%(n) weeks ago" 13 | one = "1 week ago" 14 | 15 | ["dashboard/month-ago"] 16 | default = "%(n) months ago" 17 | one = "1 month ago" 18 | -------------------------------------------------------------------------------- /tpl/email_adduser.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | {{.AddedBy}} created an account for you at {{.Site.URL .Context}} 3 | 4 | {{if not .NewUser.Password}}Please go here to set a password{{if not .NewUser.EmailVerified}} and verify your email address.{{end}}: 5 | {{.Site.URL .Context}}/user/reset/{{.NewUser.LoginRequest}} 6 | {{else}}A password has been set for your account; go to the above URL to log in.{{end}} 7 | 8 | {{template "_email_bottom.gotxt" .}} 9 | -------------------------------------------------------------------------------- /db/migrate/2021-12-02-2-language-enable-sqlite.sql: -------------------------------------------------------------------------------- 1 | update sites set 2 | settings = json_replace(settings, '$.collect', json_extract(settings, '$.collect') | 64), 3 | user_defaults = json_replace(user_defaults, '$.widgets', json_insert(json_extract(user_defaults, '$.widgets'), '$[#]', json('{"n":"languages"}'))); 4 | update users set 5 | settings = json_replace(settings, '$.widgets', json_insert(json_extract(settings, '$.widgets'), '$[#]', json('{"n":"languages"}'))); 6 | -------------------------------------------------------------------------------- /db/migrate/2022-02-16-1-rm-billing-sqlite.sql: -------------------------------------------------------------------------------- 1 | alter table sites drop column plan; 2 | alter table sites drop column plan_pending; 3 | alter table sites drop column plan_cancel_at; 4 | alter table sites drop column stripe; 5 | alter table sites drop column billing_amount; 6 | alter table sites drop column billing_anchor; 7 | alter table sites drop column notes; 8 | alter table sites drop column extra_pageviews; 9 | alter table sites drop column extra_pageviews_sub; 10 | -------------------------------------------------------------------------------- /db/migrate/2022-02-16-1-rm-billing-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table sites drop column plan; 2 | alter table sites drop column plan_pending; 3 | alter table sites drop column plan_cancel_at; 4 | alter table sites drop column stripe; 5 | alter table sites drop column billing_amount; 6 | alter table sites drop column billing_anchor; 7 | alter table sites drop column notes; 8 | alter table sites drop column extra_pageviews; 9 | alter table sites drop column extra_pageviews_sub; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # "go build" binary. 2 | /goatcounter 3 | /goatcounter.* 4 | /goatcounter-* 5 | 6 | # UPX creates temporary files. 7 | *.upx 8 | 9 | # dist directory from gogo-release 10 | /dist 11 | 12 | # SQLite3 database; may also store journal hence last *. 13 | *.sqlite3* 14 | 15 | # Never store certificates. 16 | *.pem 17 | 18 | # Default acme-secrets. 19 | /acme-secrets 20 | 21 | # Coverage reports 22 | /coverage 23 | /coverage.* 24 | 25 | # Dev log 26 | /.dev.log 27 | -------------------------------------------------------------------------------- /helper_cgo.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | //go:build cgo 6 | // +build cgo 7 | 8 | package goatcounter 9 | 10 | import "github.com/mattn/go-sqlite3" 11 | 12 | func init() { 13 | sqlite3.SQLiteTimestampFormats = []string{"2006-01-02 15:04:05", "2006-01-02"} 14 | } 15 | -------------------------------------------------------------------------------- /db/migrate/2022-11-03-1-uncount.sql: -------------------------------------------------------------------------------- 1 | alter table hit_counts drop column total; 2 | alter table ref_counts drop column total; 3 | alter table hit_stats drop column stats; 4 | alter table browser_stats drop column count; 5 | alter table system_stats drop column count; 6 | alter table location_stats drop column count; 7 | alter table size_stats drop column count; 8 | alter table language_stats drop column count; 9 | alter table campaign_stats drop column count; 10 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListSystems.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | system_id, 4 | sum(count) as count 5 | from system_stats 6 | where 7 | site_id = :site and day >= :start and day <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by system_id 10 | order by count desc 11 | ) 12 | select 13 | systems.name, 14 | sum(x.count) as count 15 | from x 16 | join systems using (system_id) 17 | group by systems.name 18 | order by count desc 19 | limit :limit offset :offset 20 | -------------------------------------------------------------------------------- /db/migrate/gomig/gomig.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package gomig 6 | 7 | import "context" 8 | 9 | var Migrations = map[string]func(context.Context) error{ 10 | "2021-12-08-1-set-chart-text": KeepAsText, 11 | "2022-11-15-1-correct-hit-stats": CorrectHitStats, 12 | } 13 | -------------------------------------------------------------------------------- /tpl/_bottom.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{/* .page */}} 4 | 5 | {{template "_bottom_links.gohtml" .}} 6 | 7 | {{if eq .Domain "goatcounter.com"}} 8 | 10 | {{end}} 11 | 12 | 13 | -------------------------------------------------------------------------------- /tpl/user_forgot_pw.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_top.gohtml" .}} 2 | 3 |

{{.T "header/forgot-password|Forgot password"}}

4 | 5 |
6 | 7 |
8 | 9 | 10 |
11 | 12 | {{template "_bottom.gohtml" .}} 13 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListBrowsers.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | browser_id, 4 | sum(count) as count 5 | from browser_stats 6 | where 7 | site_id = :site and day >= :start and day <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by browser_id 10 | order by count desc 11 | ) 12 | select 13 | browsers.name, 14 | sum(x.count) as count 15 | from x 16 | join browsers using (browser_id) 17 | group by browsers.name 18 | order by count desc 19 | limit :limit offset :offset 20 | -------------------------------------------------------------------------------- /tpl/help/campaigns.md: -------------------------------------------------------------------------------- 1 | Campaigns are tracked automatically based on URL query parameters: 2 | 3 | - The campaign name is in the `utm_campaign` or `campaign` parameter. 4 | 5 | - An optional source can be in the `utm_source`, `ref`, `src`, or `source` 6 | parameter (it will use the Referrer if this is missing). 7 | 8 | There is no need to "create" campaigns; once it sees a campaign with a new name 9 | it will be created automatically and shown in the Campaigns dashboard widget. 10 | 11 | -------------------------------------------------------------------------------- /db/query/hit_list.List-counts.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select sum(total) as total, path_id from hit_counts 3 | where 4 | hit_counts.site_id = :site and 5 | {{:exclude path_id not in (:exclude) and}} 6 | {{:filter path_id in (:filter) and}} 7 | hour>=:start and hour<=:end 8 | group by path_id 9 | order by total desc, path_id desc 10 | limit :limit 11 | ) 12 | select path_id, paths.path, paths.title, paths.event from x 13 | join paths using (path_id) 14 | order by total desc, path_id desc 15 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListCampaigns.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | campaign_id, 4 | sum(count) as count 5 | from campaign_stats 6 | where 7 | site_id = :site and day >= :start and day <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by campaign_id 10 | order by count desc, campaign_id 11 | limit :limit offset :offset 12 | ) 13 | select 14 | campaign_id as id, 15 | campaigns.name as name, 16 | x.count as count 17 | from x 18 | join campaigns using (campaign_id) 19 | order by count desc, name asc 20 | -------------------------------------------------------------------------------- /run-ci: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # mick-image: go 3 | 4 | e=0 5 | set -x 6 | 7 | go run ./cmd/check ./... || e=1 8 | go test -race -timeout=3m ./... || e=1 9 | go test -race -timeout=3m -tags pgsql ./... || e=1 10 | 11 | # Make sure it at least compiles on macOS, Windows, and arm64 12 | trap 'rm goatcounter goatcounter.exe' EXIT 13 | GOOS=darwin go build ./cmd/goatcounter || e=1 14 | GOOS=windows go build ./cmd/goatcounter || e=1 15 | GOARCH=arm64 go build ./cmd/goatcounter || e=1 16 | 17 | exit $e 18 | -------------------------------------------------------------------------------- /public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListLanguages.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | language, 4 | sum(count) as count 5 | from language_stats 6 | where 7 | site_id = :site and day >= :start and day <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by language 10 | order by count desc, language 11 | limit :limit offset :offset 12 | ) 13 | select 14 | languages.iso_639_3 as id, 15 | languages.name as name, 16 | x.count as count 17 | from x 18 | join languages on languages.iso_639_3 = x.language 19 | order by count desc, name asc 20 | -------------------------------------------------------------------------------- /cmd/goatcounter/old.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | //go:build !go1.18 6 | // +build !go1.18 7 | 8 | package main 9 | 10 | // Make sure people don't try to build GoatCounter with older versions of Go, as 11 | // that will introduce some runtime problems (e.g. using %w). 12 | func init() { 13 | "You need Go 1.18 or newer to compile GoatCounter" 14 | } 15 | -------------------------------------------------------------------------------- /db/query/hit_stats.ListLocations.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | substr(location, 0, 3) as loc, 4 | sum(count) as count 5 | from location_stats 6 | where 7 | site_id = :site and day >= :start and day <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by loc 10 | order by count desc, loc 11 | limit :limit offset :offset 12 | ) 13 | select 14 | locations.iso_3166_2 as id, 15 | locations.country_name as name, 16 | x.count as count 17 | from x 18 | join locations on locations.iso_3166_2 = x.loc 19 | order by count desc, name asc 20 | -------------------------------------------------------------------------------- /tpl/help/backend.md: -------------------------------------------------------------------------------- 1 | You can use the `/api/v0/count` API endpoint to send pageviews from essentially 2 | anywhere, such as your app's middleware. 3 | 4 | A simple example from `curl`: 5 | 6 | token=[your api token] 7 | api={{.SiteURL}}/api/v0 8 | 9 | curl -X POST "$api/count" \ 10 | -H 'Content-Type: application/json' \ 11 | -H "Authorization: Bearer $token" \ 12 | --data '{"no_sessions": true, "hits": [{"path": "/one"}, {"path": "/two"}]}' 13 | 14 | The [API documentation](/api) contains detailed information and more examples. 15 | -------------------------------------------------------------------------------- /db/query/hit_stats.ByRef.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select ref_id, ref_scheme from refs 3 | where lower(ref) = lower(:ref) 4 | limit :limit offset :offset 5 | ), 6 | y as ( 7 | select 8 | path_id, 9 | coalesce(sum(total), 0) as count 10 | from ref_counts 11 | join x using (ref_id) 12 | where 13 | site_id = :site and hour >= :start and hour <= :end 14 | {{:filter and path_id in (:filter)}} 15 | group by path_id 16 | order by count desc 17 | ) 18 | select 19 | paths.path as name, 20 | y.count 21 | from y 22 | join paths using(path_id) 23 | order by count desc 24 | -------------------------------------------------------------------------------- /db/query/hit_list.ListPathsLike.gotxt: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select path_id, path, title from paths 3 | where site_id = :site and ( 4 | {{if .match_case}} 5 | path like :search 6 | {{if .match_title}}or title like :search{{end}} 7 | {{else}} 8 | lower(path) like lower(:search) 9 | {{if .match_title}}or lower(title) like lower(:search){{end}} 10 | {{end}} 11 | ) 12 | ) 13 | select 14 | path_id, path, title, 15 | sum(total) as count 16 | from hit_counts 17 | join x using(path_id) 18 | where site_id = :site 19 | group by path_id, path, title 20 | order by count desc 21 | -------------------------------------------------------------------------------- /db/query/paths.PathFilter.sql: -------------------------------------------------------------------------------- 1 | select path_id from paths 2 | where 3 | site_id = :site and ( 4 | lower(path) like lower(:filter) 5 | {{:match_title or lower(title) like lower(:filter)}} 6 | ) 7 | -- The limit is here because that's the limit in SQL parameters; the returned 8 | -- []int64 is passed as parameters later on. 9 | -- 10 | -- Having (and scrolling!) more than 65k pages is a rather curious usage 11 | -- pattern, but it has happened. This was just because they were sending data 12 | -- with many unique IDs in the URL which really ought to be removed. 13 | limit 65500 14 | -------------------------------------------------------------------------------- /tpl/email_export_done.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | The GoatCounter export you’ve requested is finished, go here to download it: 3 | {{.Site.URL .Context}}/settings/export/{{.Export.ID}} 4 | 5 | {{nformat .Export.NumRows .User}} rows have been exported with a file size of {{.Export.Size}}M. 6 | 7 | The pagination cursor is {{.Export.LastHitID}}; you can use this to export pageviews that were recorded after this export. 8 | 9 | The file integrity hash is {{.Export.Hash}} 10 | 11 | The export will be removed after 24 hours. 12 | 13 | {{template "_email_bottom.gotxt" .}} 14 | -------------------------------------------------------------------------------- /tpl/_backend_signin.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 |
7 | 8 |
9 | 10 |

{{.T "button/forgot-password|Forgot password?"}}

11 | -------------------------------------------------------------------------------- /tpl/help/countjs-location.md: -------------------------------------------------------------------------------- 1 | You can load the `count.js` script anywhere on your page, but it’s recommended 2 | to load it just before the closing `` tag if possible. 3 | 4 | The reason for this is that downloading the `count.js` script will take up some 5 | bandwidth which could be better used for the actual assets needed to render the 6 | site. The script is quite small (about 2K), so it’s not a huge difference, but 7 | might as well put it in the best location if possible. Just insert it in the 8 | `` or anywhere in the `` if your CMS doesn’t have an option to add 9 | it there. 10 | -------------------------------------------------------------------------------- /db/migrate/2022-11-03-2-ununique.sql: -------------------------------------------------------------------------------- 1 | alter table hit_counts rename column total_unique to total; 2 | alter table ref_counts rename column total_unique to total; 3 | alter table hit_stats rename column stats_unique to stats; 4 | alter table browser_stats rename column count_unique to count; 5 | alter table system_stats rename column count_unique to count; 6 | alter table location_stats rename column count_unique to count; 7 | alter table size_stats rename column count_unique to count; 8 | alter table language_stats rename column count_unique to count; 9 | alter table campaign_stats rename column count_unique to count; 10 | -------------------------------------------------------------------------------- /tpl/_dashboard_totals_row.gohtml: -------------------------------------------------------------------------------- 1 | 2 | {{if .Align}}{{end}} 3 | 4 |
5 | {{if .Loaded}} 6 | {{if not $.User.Settings.FewerNumbers}} 7 | {{nformat .Max $.User}} 8 | {{end}} 9 | 10 | {{else}} 11 | {{t $.Context "dashboard/loading|Loading…"}} 12 | {{end}} 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /tpl/_favicon.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[headers]] 2 | for = "/*" 3 | [headers.values] 4 | # Netlify sets cache to 0 to allow rollbacks, but this content isn't 5 | # going to change and we use "cache buster" query parameters anyway, so 6 | # set a Cache-Control header which prevents cache revalidation requests. 7 | Cache-Control = "public, max-age=7776000" # 90 days 8 | Access-Control-Allow-Origin = "*" 9 | 10 | [[headers]] 11 | for = "/count.js" 12 | [headers.values] 13 | Cross-Origin-Resource-Policy = "cross-origin" 14 | 15 | # count.min.js no longer exists, so redirect to count.js 16 | [[redirects]] 17 | from = "/count.min.js" 18 | to = "/count.js" 19 | -------------------------------------------------------------------------------- /db/query/hit_list.DiffTotal.sql: -------------------------------------------------------------------------------- 1 | with prev as ( 2 | select 3 | path_id, 4 | sum(total) as total 5 | from hit_counts 6 | where 7 | site_id = :site and path_id in (:paths) and 8 | hour >= :prevstart and hour <= :prevend 9 | group by path_id 10 | ), 11 | cur as ( 12 | select 13 | path_id, 14 | sum(c.total) as total 15 | from hit_counts c 16 | where 17 | site_id = :site and path_id in (:paths) and 18 | hour >= :start and hour <= :end 19 | group by path_id 20 | ) 21 | select 22 | percent_diff(coalesce(prev.total, 0), coalesce(cur.total, 0)) as diff 23 | from cur 24 | left join prev using (path_id) 25 | order by cur.total desc, path_id desc 26 | -------------------------------------------------------------------------------- /tpl/help/csp.md: -------------------------------------------------------------------------------- 1 | With the `Content-Security-Policy` header you can control which scripts are 2 | allowed to run on a page; if you're not using this header then you can ignore 3 | this page. 4 | 5 | For the standard integration you'll need to add the following: 6 | 7 | script-src https://{{.CountDomain}} 8 | connect-src {{.SiteURL}}/count 9 | 10 | The `script-src` is needed to load the `count.js` script, and the `connect-src` 11 | is needed to send pageviews to GoatCounter via `navigator.sendBeacon`. 12 | 13 | Alternatively you can host the `count.js` script anywhere you want, or include 14 | it directly in your page. See [count.js hosting](/code/countjs-host). 15 | -------------------------------------------------------------------------------- /tpl/_user_nav.gohtml: -------------------------------------------------------------------------------- 1 | 9 |

{{.T "p/settings-all-sites|These settings take effect for all the sites you have access to."}}

10 | -------------------------------------------------------------------------------- /db/query/ref.ListTopRefs.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | coalesce(ref_id, 1) as ref_id, 4 | coalesce(sum(total), 0) as count 5 | from ref_counts 6 | where 7 | site_id = :site and hour >= :start and hour <= :end 8 | {{:filter and path_id in (:filter)}} 9 | group by ref_id 10 | order by count desc, ref_id 11 | -- Over-select quite a bit here since we may filter on the refs.ref below; 12 | -- even with the over-select a CTE is quite a bit faster. 13 | limit :limit2 offset :offset 14 | ) 15 | select 16 | x.count, 17 | refs.ref_scheme as ref_scheme, 18 | refs.ref as name 19 | from x 20 | left join refs using (ref_id) 21 | {{:has_domain where refs.ref not like :ref}} 22 | limit :limit 23 | -------------------------------------------------------------------------------- /tpl/email_welcome.gotxt: -------------------------------------------------------------------------------- 1 | {{template "_email_top.gotxt" .}} 2 | Welcome to your GoatCounter account! 3 | 4 | Please go here to verify your email address: 5 | {{.Site.URL .Context}}/user/verify/{{.User.EmailToken}} 6 | 7 | Getting started is pretty easy, just add the following JavaScript anywhere on the page: 8 | 9 | 11 | 12 | Don’t see any pageviews in your testing? This is probably because your adblocker is blocking GoatCounter – not much can (or should) be done about that on GoatCounter’s end. 13 | 14 | Further documentation is available at {{.Site.URL .Context}}/code 15 | 16 | {{template "_email_bottom.gotxt" .}} 17 | -------------------------------------------------------------------------------- /cmd/goatcounter/help_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "testing" 9 | 10 | "zgo.at/zli" 11 | ) 12 | 13 | func TestHelp(t *testing.T) { 14 | exit, _, out := zli.Test(t) 15 | 16 | { 17 | runCmd(t, exit, "help", "db") 18 | wantExit(t, exit, out, 0) 19 | if len(out.String()) < 1_000 { 20 | t.Error() 21 | } 22 | out.Reset() 23 | } 24 | 25 | { 26 | runCmd(t, exit, "help", "all") 27 | wantExit(t, exit, out, 0) 28 | if len(out.String()) < 20_000 { 29 | t.Error() 30 | } 31 | out.Reset() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tpl/_dashboard_hchart.gohtml: -------------------------------------------------------------------------------- 1 | {{- $x := (t $.Context "dashboard/loading|Loading…") -}} 2 | {{- if $.Loaded -}}{{- $x = horizontal_chart .Context .Stats .TotalUTC .HasSubMenu true -}}{{- end -}} 3 | {{- if .RowsOnly -}} 4 | {{- $x -}} 5 | {{- else -}} 6 |
7 |
8 |

{{.Header}}

9 | ⚙️ 10 |
11 | {{template "_dashboard_warn_collect.gohtml" (map "IsCollected" .IsCollected "Context" .Context)}} 12 | {{if .Err}} 13 | {{t $.Context "p/error|Error: %(error-message)" .Err}} 14 | {{else}} 15 | {{$x}} 16 | {{end}} 17 |
18 | {{- end -}} 19 | -------------------------------------------------------------------------------- /tpl/email_report.gotxt: -------------------------------------------------------------------------------- 1 | Hi there! 2 | 3 | This is your GoatCounter report for {{.DisplayDate}} for the site {{.Site.URL .Context}}. 4 | 5 | Top 10 pages 6 | -------------------------------------------------------- 7 | {{.TextPagesTable}} 8 | 9 | Top 10 referrers 10 | -------------------------------------------------------- 11 | {{.TextRefTable}} 12 | 13 | This is the text version and best viewed with a monospace font. 14 | View the HTML version if the alignment is off. 15 | 16 | This email is sent because it’s enabled in your settings. 17 | Disable it in your settings if you want to stop receiving it: 18 | {{.Site.URL .Context}}/user/pref#section-email-reports 19 | 20 | {{template "_email_bottom.gotxt" .}} 21 | -------------------------------------------------------------------------------- /tpl/totp.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

Multi-factor auth

4 |

{{.T "p/have-mfa|This account is protected with multi-factor auth; please enter the code from your authenticator app."}}

5 | 6 |
7 | 8 | 9 | 10 | 11 |
14 | 15 |
16 | 17 | {{template "_backend_bottom.gohtml" .}} 18 | -------------------------------------------------------------------------------- /tpl/_dashboard_toprefs.gohtml: -------------------------------------------------------------------------------- 1 | {{- $x := (t $.Context "dashboard/loading|Loading…") -}} 2 | {{- if .Loaded -}}{{- $x = horizontal_chart .Context .Stats .Total .HasSubMenu true -}}{{- end -}} 3 | {{- if .RowsOnly -}} 4 | {{- $x -}} 5 | {{- else -}} 6 |
7 |
8 |

{{t .Context "header/toprefs|Top referrers"}}

9 | ⚙️ 10 |
11 | 12 | {{template "_dashboard_warn_collect.gohtml" (map "IsCollected" .IsCollected "Context" .Context)}} 13 | {{if .Err}} 14 | {{t .Context "p/error|Error: %(error-message)" .Err}} 15 | {{else}} 16 | {{$x}} 17 | {{end}} 18 |
19 | {{- end -}} 20 | -------------------------------------------------------------------------------- /tpl/user_reset.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

{{.T "header/reset-password|Reset password for %(email) at %(site-name)" (map 4 | "email" .User.Email 5 | "site-name" (.Site.Display .Context) 6 | )}}

7 |
8 | 9 |
10 | 11 | 12 |
13 | 14 | 15 |
16 | 17 | {{template "_backend_bottom.gohtml" .}} 18 | -------------------------------------------------------------------------------- /db/migrate/2022-10-17-1-campaigns.gotxt: -------------------------------------------------------------------------------- 1 | drop table campaign_stats; -- Wasn't correct before, so just drop it. 2 | 3 | create table campaign_stats ( 4 | site_id integer not null, 5 | path_id integer not null, 6 | 7 | day date not null, 8 | campaign_id integer not null, 9 | ref varchar not null, 10 | count integer not null, 11 | count_unique integer not null, 12 | 13 | constraint "campaign_stats#site_id#path_id#campaign_id#ref#day" unique(site_id, path_id, campaign_id, ref, day) {{sqlite "on conflict replace"}} 14 | ); 15 | create index "campaign_stats#site_id#day" on campaign_stats(site_id, day desc); 16 | {{cluster "campaign_stats" "campaign_stats#site_id#day"}} 17 | {{replica "campaign_stats" "campaign_stats#site_id#path_id#campaign_id#ref#day"}} 18 | -------------------------------------------------------------------------------- /tpl/_user_dashboard_widgets.gohtml: -------------------------------------------------------------------------------- 1 | {{range $i, $w := .Widgets}} 2 |
3 | 4 | 5 |
6 |
7 | {{if $w.Settings.HasSettings}}⚙️︎{{else}}{{end}} 8 | {{$w.Label $.Context}} 9 | {{$s := $w.Settings.Display $.Context $w.Name}} 10 | {{if $s}}({{$s}}){{end}} 11 |
12 | {{$.T "button/remove|Remove"}} 13 |
14 | 15 |
16 | {{template "_user_dashboard_widget.gohtml" (map 17 | "Context" $.Context 18 | "Validate" $.Validate 19 | "Widget" $w 20 | "I" $i 21 | )}} 22 |
23 |
24 | {{end}} 25 | -------------------------------------------------------------------------------- /db/migrate/2021-04-07-1-billing-anchor-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table sites add column if not exists billing_anchor timestamp; 2 | alter table sites add column if not exists notes text not null default ''; 3 | alter table sites add column if not exists extra_pageviews int; 4 | alter table sites add column if not exists extra_pageviews_sub varchar; 5 | 6 | alter table sites drop constraint sites_plan_check; 7 | 8 | update sites set plan='starter' where plan='personalplus'; 9 | update sites set plan='trial' where stripe is null; 10 | update sites set plan='free', stripe=null where stripe like 'cus_free_%'; 11 | update sites set plan='child' where parent is not null; 12 | 13 | update sites 14 | set settings = jsonb_set(to_jsonb(settings), '{data_retention}', '31', true) 15 | where 16 | cast(settings->'data_retention' as int) != 0 and 17 | cast(settings->'data_retention' as int) < 31; 18 | -------------------------------------------------------------------------------- /cmd/goatcounter/testdata/export.csv: -------------------------------------------------------------------------------- 1 | 2Path,Title,Event,UserAgent,Browser,System,Session,Bot,Referrer,Referrer scheme,Screen size,Location,FirstVisit,Date 2 | click-cv-html,HTML source,1,"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36",Chrome 86,Windows 7,287fb97cbed4e4f-8e4f5c975b8fee06,0,,,"1280,768,1",AR,1,2020-12-01T00:07:10Z 3 | /stupid-light.html,Stupid light software,0,"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36",Chrome 86,Windows 7,287fb97cbed4e4f-8e4f5c975b8fee06,0,,,"1280,768,1",AR,1,2020-12-01T00:07:44Z 4 | /static-go.html,Statically compiling Go programs,0,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15",Safari 14.0,macOS 10.15,c8b722d57674550-bcd20ab1989d7739,0,www.reddit.com,h,"1680,1050,2",RO,1,2020-12-27T00:37:37Z 5 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | ) 11 | 12 | func TestContext(t *testing.T) { 13 | ctx := context.Background() 14 | { 15 | c := cacheSites(ctx) 16 | if c == nil { 17 | t.Error("c is nil") 18 | } 19 | 20 | cfg := Config(ctx) 21 | if cfg == nil { 22 | t.Error("cfg is nil") 23 | } 24 | } 25 | 26 | ctx = NewCache(ctx) 27 | { 28 | c1 := cacheSites(ctx) 29 | c2 := cacheSites(ctx) 30 | if c1 != c2 { 31 | t.Errorf("%v %v", c1, c2) 32 | } 33 | } 34 | 35 | ctx = NewConfig(ctx) 36 | { 37 | c1 := Config(ctx) 38 | c2 := Config(ctx) 39 | if c1 != c2 { 40 | t.Errorf("%v %v", c1, c2) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tpl/_top.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{template "_favicon.gohtml" .}} 5 | 6 | 7 | GoatCounter – open source web analytics 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{if ne .Page "home"}}{{end}} 18 |
19 | {{- if .Flash}}
{{.Flash.Message}}
{{end -}} 20 | -------------------------------------------------------------------------------- /tpl/api2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GoatCounter API reference 6 | 7 | 8 | 9 | 14 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /db/migrate/2022-03-06-1-campaigns.gotxt: -------------------------------------------------------------------------------- 1 | alter table hits add column campaign int default null; 2 | 3 | create table campaigns ( 4 | campaign_id {{auto_increment}}, 5 | site_id integer not null, 6 | name varchar not null 7 | ); 8 | 9 | create table campaign_stats ( 10 | site_id integer not null, 11 | 12 | day date not null, 13 | campaign_id integer not null, 14 | ref varchar not null, 15 | count integer not null, 16 | count_unique integer not null, 17 | 18 | constraint "campaign_stats#site_id#campaign_id#ref#day" unique(site_id, campaign_id, ref, day) {{sqlite "on conflict replace"}} 19 | ); 20 | create index "campaign_stats#site_id#day" on campaign_stats(site_id, day desc); 21 | {{cluster "campaign_stats" "campaign_stats#site_id#day"}} 22 | {{replica "campaign_stats" "campaign_stats#site_id#campaign_id#ref#day"}} 23 | -------------------------------------------------------------------------------- /tpl/bosmang_cache.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 | 10 | 11 | Caches: 12 | 25 | 26 | {{template "_backend_bottom.gohtml" .}} 27 | -------------------------------------------------------------------------------- /tpl/help/countjs-host.md: -------------------------------------------------------------------------------- 1 | You can host the `count.js` script yourself, or include it in your page directly 2 | inside ` 17 | 18 | or: 19 | 20 | 25 | -------------------------------------------------------------------------------- /tpl/help/logfile.md: -------------------------------------------------------------------------------- 1 | You can send data from logfiles with the `goatcounter import` command; for 2 | example: 3 | 4 | $ export GOATCOUNTER_API_KEY=[..] 5 | $ goatcounter import -follow -format=combined -exclude=static \ 6 | -site='{{.SiteURL}}' \ 7 | /var/log/nginx/access_log 8 | 9 | This will keep watching the file for changes and report new pageviews as they 10 | come in. You can also batch import the data from logfiles by dropping the 11 | `-follow` flag. 12 | 13 | See `goatcounter help import` and `goatcounter help logfile` for more details. 14 | 15 | The biggest advantage of this is that you won't need to add any JavaScript to 16 | your site and that nothing will be blocked by adblockers, but there are a few 17 | downsides as well: 18 | 19 | - There will be more bot requests. 20 | - Some data won't be available: screen sizes, page titles. 21 | - It won't disambiguate to canonical paths from ``; i.e. 22 | `/page` and `/page?x=y` will show up as two different paths. 23 | -------------------------------------------------------------------------------- /tpl/user_forgot_code.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_top.gohtml" .}} 2 | 3 |

{{.T "header/forgot-domain|Forgot domain"}}

4 | {{- if .Err}}
{{.Err}}
{{end -}} 5 | 6 |

{{.T "forgot-domain-help|Email a list of all domains associated with an email address."}}

7 | 8 |
9 | 10 | 11 | {{validate "email" .Validate}} 12 | 13 | 14 | 15 | {{validate "turing_test" .Validate}} 16 | {{.T "help/turing-test|Just a little verification that you’re human :-)"}} 17 | 18 |

19 | 20 |
21 | 22 | {{template "_bottom.gohtml" .}} 23 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /cmd/goatcounter/serve_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "io" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestServe(t *testing.T) { 14 | exit, _, _, _, dbc := startTest(t) 15 | 16 | ready := make(chan struct{}, 1) 17 | stop := make(chan struct{}) 18 | go runCmdStop(t, exit, ready, stop, "serve", 19 | "-db="+dbc, 20 | "-debug=all", 21 | "-listen=localhost:31874", 22 | "-tls=http") 23 | <-ready 24 | 25 | resp, err := http.Get("http://localhost:31874/status") 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | defer resp.Body.Close() 30 | 31 | b, _ := io.ReadAll(resp.Body) 32 | if resp.StatusCode != 200 { 33 | t.Errorf("status %d: %s", resp.StatusCode, b) 34 | } 35 | if len(b) < 100 { 36 | t.Errorf("%s", b) 37 | } 38 | 39 | stop <- struct{}{} 40 | mainDone.Wait() 41 | } 42 | -------------------------------------------------------------------------------- /deploy/alpine/README.md: -------------------------------------------------------------------------------- 1 | This sets up a basic GoatCounter installation on Alpine Linux, using SQLite. 2 | 3 | This is also available as a ["StackScript" for Linode][s]; If you don't have a 4 | Linode account yet then [consider using my "referral URL"][r] and I'll get some 5 | cash back from Linode :-) 6 | 7 | It should be fine to run this more than once; and can be used to upgrade to a 8 | newer version. 9 | 10 | You can set the version to use with `GOATCOUNTER_VERSION`; this needs to be a 11 | release on GitHub: 12 | 13 | $ GOATCOUNTER_VERSION=v2.0.0 ./goatcounter-alpine.sh 14 | 15 | You can create additional sites with: 16 | 17 | $ cd /home/goatcounter 18 | $ ./bin/goatcounter db create site -vhost example.com -user.email me@example.com 19 | 20 | Files are stored in `/home/goatcounter`; see `/var/log/goatcounter/current` for 21 | logs; and you can configure the flags in `/etc/conf.d/goatcounter` 22 | 23 | [s]: https://cloud.linode.com/stackscripts/659823 24 | [r]: https://www.linode.com/?r=7acaf75737436d859e785dd5c9abe1ae99b4387e 25 | -------------------------------------------------------------------------------- /tpl/_dashboard_totals.gohtml: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{t .Context "dashboard/totals/header|Totals"}} 4 | {{if not $.User.Settings.FewerNumbers}} 5 | {{if .NoEvents}} 6 | {{t .Context `dashboard/totals/num-visits|%(num-visits) visits; excluding events` 7 | (map 8 | "num-visits" (tag "span" `` (nformat (sub .Total .TotalEvents) $.User)) 9 | )}} 10 | {{else}} 11 | {{t .Context `dashboard/totals/num-visits|%(num-visits) visits` 12 | (map 13 | "num-visits" (tag "span" `` (nformat .Total $.User)) 14 | )}} 15 | {{end}} 16 | {{end}} 17 |

18 | ⚙️ 19 |
20 | 21 | {{if .Err}} 22 | {{t .Context "p/error|Error: %(error-message)" .Err}} 23 | {{else}} 24 | {{template "_dashboard_totals_row.gohtml" .}}
25 | {{end}} 26 |
27 | 28 | -------------------------------------------------------------------------------- /tpl/_backend_bottom.gohtml: -------------------------------------------------------------------------------- 1 |
{{- /* .page */}} 2 | 3 | {{if or (.User.ID) (not .HideUI)}} 4 | {{template "_bottom_links.gohtml" .}} 5 | {{end}} 6 | 7 | 13 | {{- .User.Settings.String | unsafe_js -}} 14 | 15 | {{.JSTranslations | json}} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /kommentaar.conf: -------------------------------------------------------------------------------- 1 | # vim:ft=config 2 | 3 | title GoatCounter 4 | version 0.1 5 | contact-name Martin Tournoij 6 | contact-email support@goatcounter.com 7 | contact-site https://www.goatcounter.com/help/api 8 | auth basic 9 | description 10 |

Reference documentation for the GoatCounter API.

11 | 12 |

See /help/api for a more general introduction and a few examples.

13 | 14 |

Viewing this documentation at https://[my-code].goatcounter.com/api2.html (rather than using the www.goatcounter.com) enables the "try" feature.

15 | 16 | default-request-ct application/json 17 | default-response-ct application/json 18 | 19 | default-response 400: zgo.at/goatcounter/v2/handlers.apiError 20 | default-response 401: zgo.at/goatcounter/v2/handlers.authError 21 | default-response 403: zgo.at/goatcounter/v2/handlers.authError 22 | add-default-response 400 401 403 23 | 24 | map-types 25 | time.Time string 26 | 27 | map-format 28 | time.Time date-time 29 | -------------------------------------------------------------------------------- /cmd/goatcounter/saas_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "io" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestSaas(t *testing.T) { 14 | exit, _, _, _, dbc := startTest(t) 15 | 16 | ready := make(chan struct{}, 1) 17 | stop := make(chan struct{}) 18 | go func() { 19 | runCmdStop(t, exit, ready, stop, "saas", 20 | "-db="+dbc, 21 | "-debug=all", 22 | "-domain=goatcounter.com,a.a", 23 | "-listen=localhost:31874", 24 | "-tls=http") 25 | }() 26 | <-ready 27 | 28 | resp, err := http.Get("http://localhost:31874/status") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | defer resp.Body.Close() 33 | 34 | b, _ := io.ReadAll(resp.Body) 35 | if resp.StatusCode != 200 { 36 | t.Errorf("status %d: %s", resp.StatusCode, b) 37 | } 38 | if len(b) < 100 { 39 | t.Errorf("%s", b) 40 | } 41 | 42 | stop <- struct{}{} 43 | mainDone.Wait() 44 | } 45 | -------------------------------------------------------------------------------- /tpl/_settings_nav.gohtml: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /tpl/_bottom_links.gohtml: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package metrics 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestMetrics(t *testing.T) { 14 | { 15 | m := Start("test") 16 | time.Sleep(10 * time.Millisecond) 17 | m.Done() 18 | } 19 | { 20 | m := Start("test") 21 | time.Sleep(20 * time.Millisecond) 22 | m.Done() 23 | } 24 | 25 | { 26 | m := Start("test") 27 | m.AddTag("x") 28 | time.Sleep(15 * time.Millisecond) 29 | m.Done() 30 | } 31 | 32 | tr := func(d time.Duration) time.Duration { return d.Truncate(time.Millisecond) } 33 | 34 | have := "" 35 | for _, l := range List() { 36 | have += fmt.Sprintf("%s\t%s\t%s\t%s\n", l.Tag, 37 | tr(l.Times.Sum()), tr(l.Times.Min()), tr(l.Times.Max())) 38 | } 39 | 40 | want := ` 41 | test 30ms 10ms 20ms 42 | test·x 15ms 15ms 15ms 43 | `[1:] 44 | 45 | if want != have { 46 | t.Errorf("\nwant:\n%shave:\n%s", want, have) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tpl/help/skip-path.md: -------------------------------------------------------------------------------- 1 | Nothing will be automatically sent if `window.goatcounter.no_onload` is set; the 2 | easiest way to set this is from `data-goatcounter-settings` on the script tag: 3 | 4 | 7 | 8 | For static or server-side rendered sites this is usually the simplest approach. 9 | 10 | --- 11 | 12 | You can also set this in JavaScript (*before* the script loads); for example to 13 | automatically skip if the ``'s class contains `goatcounter-skip`: 14 | 15 | 20 | {{template "code" .}} 21 | 22 | Or match against a list of paths: 23 | 24 | 30 | {{template "code" .}} 31 | -------------------------------------------------------------------------------- /bgrun/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © Martin Tournoij 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | The software is provided "as is", without warranty of any kind, express or 16 | implied, including but not limited to the warranties of merchantability, 17 | fitness for a particular purpose and noninfringement. In no event shall the 18 | authors or copyright holders be liable for any claim, damages or other 19 | liability, whether in an action of contract, tort or otherwise, arising 20 | from, out of or in connection with the software or the use or other dealings 21 | in the software. 22 | -------------------------------------------------------------------------------- /tpl/settings_changecode.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

{{.T "header/change-code|Change site code"}}

4 |

{{.T `p/change-code-request| 5 |

Change your site code and login domain.

6 | 7 |

WARNING: this will take effect immediately 8 | and the old code can be registered again by anyone; if you’re already using it 9 | on a site then change it as soon as possible, or temporarily add two integration 10 | codes (with the old and new code) to prevent the loss of any pageviews.

11 | 12 |

Current code: %(current-code) (%(current-url))

13 | ` (map 14 | "current-code" .Site.Code 15 | "current-url" (.Site.URL .Context) 16 | )}}

17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | {{.T "p/notify-immediate-change|Will take effect immediately"}} 25 |
26 | 27 | {{template "_backend_bottom.gohtml" .}} 28 | -------------------------------------------------------------------------------- /cmd/goatcounter/goat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | const goat = ` 4 | 🐐🐐🐐🐐🐐🐐🐐🐐 5 | 🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐 6 | 🐐🐐🐐🐐🐐🐐🐐 🐐🐐🐐🐐🐐🐐🐐🐐 7 | 🐐🐐🐐🐐🐐🐐🐐 🐐🐐🐐🐐🐐🐐 8 | 🐐🐐🐐🐐🐐🐐🐐 🐐🐐🐐🐐🐐 9 | 🐐🐐🐐🐐🐐 🐐🐐🐐🐐🐐 10 | 🐐🐐🐐🐐 🐐🐐🐐🐐🐐 11 | 🐐🐐🐐🐐 🐐🐐🐐🐐 12 | 🐐🐐🐐🐐 🐐🐐🐐🐐 13 | 🐐🐐🐐🐐 🐐🐐🐐🐐 14 | 🐐🐐🐐🐐 🐐🐐🐐🐐 15 | 🐐🐐🐐🐐 🐐🐐🐐🐐 16 | 🐐🐐🐐 🐐🐐 🐐🐐🐐 17 | 🐐🐐 🐐🐐🐐 🐐🐐🐐 18 | 🐐🐐🐐🐐 🐐🐐🐐 19 | 🐐🐐🐐🐐🐐🐐🐐 🐐🐐🐐 20 | 🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐 21 | 🐐🐐🐐 🐐🐐🐐🐐🐐🐐🐐🐐🐐🐐 22 | 🐐🐐🐐 23 | 🐐🐐🐐 24 | 🐐🐐🐐 25 | 🐐🐐🐐 26 | 🐐🐐 27 | ` 28 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | "testing/fstest" 11 | 12 | "zgo.at/zdb" 13 | ) 14 | 15 | func TestEmbed(t *testing.T) { 16 | err := fstest.TestFS(DB, "db/schema.gotxt", "db/migrate/2022-10-17-1-campaigns.gotxt") 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | err = fstest.TestFS(DB, "db/goatcounter.sqlite3", "db/migrate/gomig/gomig.go") 22 | if err == nil { 23 | t.Fatal("db/goatcounter.sqlite3 in embeded files") 24 | } 25 | } 26 | 27 | func TestSQLiteJSON(t *testing.T) { 28 | zdb.RunTest(t, func(t *testing.T, ctx context.Context) { 29 | if zdb.SQLDialect(ctx) != zdb.DialectSQLite { 30 | return 31 | } 32 | 33 | var out string 34 | err := zdb.Get(ctx, &out, `select json('["a" , "b"]')`) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | want := `["a","b"]` 40 | if out != want { 41 | t.Errorf("\ngot: %q\nwant: %q", out, want) 42 | } 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /tpl/settings_sites_rm_confirm.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | {{template "_settings_nav.gohtml" .}} 3 | 4 | {{$link := (tag "a" (printf `href="//%s.%s"` .Rm.Code .Domain) (.Rm.Display .Context))}} 5 | {{if eq .Rm.ID .Site.ID}} 6 |

{{.T `p/remove-site-confirm-current| 7 | Are you sure you want to remove the site %(sitename)?
8 | This will remove all associated data and is the current site. 9 | ` $link}}

10 | {{else}} 11 |

{{.T `p/remove-site-confirm| 12 | Are you sure you want to remove the site %(sitename)?
13 | This will remove all associated data. 14 | ` $link}}

15 | {{end}} 16 | 17 | {{if .GoatcounterCom}} 18 |

{{.T `p/remove-site-confirm-contact| 19 | %[Contact] if you want to do something else, like merge it in to another site, or decouple it to a new account. 20 | ` (tag "a" (printf `target="_blank" href="//%s/contact"` .Domain))}}

21 | {{end}} 22 | 23 |
24 | 25 | 26 |
27 | 28 | {{template "_backend_bottom.gohtml" .}} 29 | -------------------------------------------------------------------------------- /tpl/help/domains.md: -------------------------------------------------------------------------------- 1 | GoatCounter doesn’t store the domain a pageview belongs to; if you add 2 | GoatCounter to several (sub)domains then there’s no way to distinguish between 3 | requests to `a.example.com/path` and `b.example.com/path` as they’re both 4 | recorded as `/path`. 5 | 6 | This might be improved at some point in the future; the options right now are: 7 | 8 | 1. Create a new site for every domain; this is a completely separate site which 9 | has the same user, login, etc. You will need to use a different site for 10 | every (sub)domain. 11 | 12 | 2. If you want everything in a single overview then you can add the domain to 13 | the path, instead of just sending the path: 14 | 15 | 20 | {{template "code" .}} 21 | 22 | For subdomains it it might be more useful to just add the first domain label 23 | instead of the full domain here, or perhaps just a short static string 24 | identifying the source. 25 | 26 | 27 | Also see [setting the endpoint in JavaScript](/code/modify#setting-the-endpoint-in-javascript-4). 28 | -------------------------------------------------------------------------------- /tpl/bosmang_metrics.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

Metrics

4 |

Sort by: 5 | Total · 6 | Mean · 7 | Median · 8 | Min · 9 | Max · 10 | Num calls 11 |

12 | 13 | {{range $v := .Metrics}} 14 |
15 | {{$v.Tag}} (over last {{$v.Times.Len}} invocations)
16 |
Total:  {{$v.Times.Sum | round_duration}}
17 | 	Min:    {{$v.Times.Min | round_duration}}
18 | 	Max:    {{$v.Times.Max | round_duration}}
19 | 	Median: {{$v.Times.Median | round_duration}}
20 | 	Mean:   {{$v.Times.Mean | round_duration}}
21 |     {{distribute_durations $v.Times 10}}
22 | {{else}} 23 |

Nothing recorded yet.

24 | {{end}} 25 | 26 | {{template "_backend_bottom.gohtml" .}} 27 | -------------------------------------------------------------------------------- /tpl/settings_server.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | {{template "_settings_nav.gohtml" .}} 3 | 4 |

Server management

5 | 6 |
 7 | Version:   {{.Version}}
 8 | Go:        {{.Go}} {{.GOOS}}/{{.GOARCH}} (race={{.Race}} cgo={{.Cgo}})
 9 | Database:  {{.Database}}
10 | Uptime:    {{.Uptime}}
11 | 
12 | 13 | 14 |

Various special pages for server management; these pages are available only 15 | to users with “server mangagement” access set.

16 | 24 | 25 | {{template "_backend_bottom.gohtml" .}} 26 | -------------------------------------------------------------------------------- /cron/campaign_stat_test.go: -------------------------------------------------------------------------------- 1 | package cron_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "zgo.at/goatcounter/v2" 8 | "zgo.at/goatcounter/v2/gctest" 9 | "zgo.at/zstd/zjson" 10 | "zgo.at/zstd/ztest" 11 | "zgo.at/zstd/ztime" 12 | ) 13 | 14 | func TestCampaignStats(t *testing.T) { 15 | ctx := gctest.DB(t) 16 | 17 | site := goatcounter.MustGetSite(ctx) 18 | now := time.Date(2019, 8, 31, 14, 42, 0, 0, time.UTC) 19 | 20 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 21 | {Site: site.ID, CreatedAt: now, Query: "utm_campaign=one", FirstVisit: true}, 22 | {Site: site.ID, CreatedAt: now, Query: "utm_campaign=one"}, 23 | {Site: site.ID, CreatedAt: now, Query: "utm_campaign=two"}, 24 | {Site: site.ID, CreatedAt: now, Query: "utm_campaign=three", FirstVisit: true}, 25 | }...) 26 | 27 | var have goatcounter.HitStats 28 | err := have.ListCampaigns(ctx, ztime.NewRange(now).To(now), nil, 10, 0) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | want := `{ 34 | "more": false, 35 | "stats": [ 36 | {"count": 1, "id": "1", "name": "one"}, 37 | {"count": 1, "id": "3", "name": "three"} 38 | ] 39 | }` 40 | if d := ztest.Diff(zjson.MustMarshalString(have), want, ztest.DiffJSON); d != "" { 41 | t.Error(d) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /refspam_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func TestRefspam(t *testing.T) { 12 | tests := []struct { 13 | in string 14 | want bool 15 | }{ 16 | {"notinthelist.com", false}, 17 | {"foo.notinthelist.com", false}, 18 | 19 | {"localhost", true}, 20 | {"a.localhost", true}, 21 | {"c.a.localhost", true}, 22 | 23 | {"adcash.com", true}, 24 | {"d.adcash.com", true}, 25 | 26 | {"dadcash.com", false}, 27 | {"localhost.com", false}, 28 | {"asdlocalhost.com", false}, 29 | } 30 | 31 | for _, tt := range tests { 32 | t.Run(tt.in, func(t *testing.T) { 33 | got := isRefspam(tt.in) 34 | if got != tt.want { 35 | t.Errorf("\ngot: %t\nwant: %t", got, tt.want) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func BenchmarkRefspam(b *testing.B) { 42 | isRefspam("notinthelist.com") // Run the sync.Once 43 | 44 | b.ReportAllocs() 45 | b.ResetTimer() 46 | v := false 47 | for n := 0; n < b.N; n++ { 48 | v = isRefspam("notinthelist.com") 49 | } 50 | _ = v 51 | } 52 | -------------------------------------------------------------------------------- /tpl/help/skip-dev.md: -------------------------------------------------------------------------------- 1 | Ignore IPs 2 | ---------- 3 | There is a ‘Ignore IPs’ settings in your site’s settings (*Settings → 4 | Tracking*). All requests from any IP address added here will be ignored. 5 | 6 | JavaScript 7 | ---------- 8 | Add `#toggle-goatcounter` to your site's URL to block your browser; for example: 9 | 10 | https://example.com#toggle-goatcounter 11 | 12 | If you filled in the domain in your settings then there should be a link there. 13 | If you edit it in your URL bar you may have to reload the page with F5 for it to 14 | work (you should get a popup). 15 | 16 | Skip loading staging/beta sites 17 | ------------------------------- 18 | You can check `location.host` if you want to load GoatCounter only on 19 | `production.com` and not `staging.com` or `development.com`; for example: 20 | 21 | 26 | {{template "code" .}} 27 | 28 | Request from `localhost` and the most common private networks are [already 29 | ignored][l] unless you add `allow_local`. 30 | 31 | [l]: https://github.com/arp242/goatcounter/blob/9525be9/public/count.js#L69-L72 32 | -------------------------------------------------------------------------------- /db/migrate/2022-01-13-1-unfk-postgres.sql: -------------------------------------------------------------------------------- 1 | alter table users drop constraint users_site_id_fkey; 2 | alter table api_tokens drop constraint api_tokens_site_id_fkey; 3 | alter table api_tokens drop constraint api_tokens_user_id_fkey; 4 | alter table paths drop constraint paths_site_id_fkey; 5 | alter table exports drop constraint exports_site_id_fkey; 6 | alter table user_agents drop constraint user_agents_browser_id_fkey; 7 | alter table user_agents drop constraint user_agents_system_id_fkey; 8 | alter table hit_counts drop constraint hit_counts_site_id_fkey; 9 | alter table ref_counts drop constraint ref_counts_site_id_fkey; 10 | alter table hit_stats drop constraint hit_stats_site_id_fkey; 11 | alter table location_stats drop constraint location_stats_site_id_fkey; 12 | alter table size_stats drop constraint size_stats_site_id_fkey; 13 | alter table language_stats drop constraint language_stats_site_id_fkey; 14 | alter table browser_stats drop constraint browser_stats_site_id_fkey; 15 | alter table browser_stats drop constraint browser_stats_browser_id_fkey; 16 | alter table system_stats drop constraint system_stats_site_id_fkey; 17 | alter table system_stats drop constraint system_stats_system_id_fkey; 18 | -------------------------------------------------------------------------------- /tpl/settings_users.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | {{template "_settings_nav.gohtml" .}} 3 | 4 |

{{.T "header/users|Users"}}

5 | 6 | 7 | 8 | {{range $u := .Users}} 9 | 10 | 11 | 25 | {{end}} 26 |
{{.T "header/email|Email"}}{{.T "header/access|Access"}}
{{$u.Email}}{{index $u.Access "all"}} 12 | {{if and $.GoatcounterCom (eq (len $.Users.Admins) 1) $u.AccessAdmin}} 13 | {{$.T "p/last-user|Can’t delete or edit last admin user"}} 14 | {{else}} 15 | {{$.T "button/edit|edit"}} | 16 |
19 | 20 | 21 |
22 | {{end}} 23 | {{if eq $u.ID $.User.ID}}   {{$.T "label/mark-current|(current)"}}{{end}} 24 |
27 |
28 | 29 | {{.T "button/add-user|Add new user"}} 30 | 31 | {{template "_backend_bottom.gohtml" .}} 32 | -------------------------------------------------------------------------------- /tpl/_user_dashboard_widget.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{range $k, $v := .Widget.Settings}} 6 | {{if not $v.Hidden}} 7 | {{$id := (print "widgets_" $.I "_s_" $k)}} 8 | {{$n := (print "widgets[" $.I "].s." $k)}} 9 | 10 | {{if eq $v.Type "checkbox"}} 11 | 12 | {{else if eq $v.Type "select"}} 13 | {{$x := $v.Value}}{{if not $x}}{{$x = ""}}{{end}} 14 | {{$opt := $v.Options}}{{if $v.OptionsFunc}}{{$opt = (call $v.OptionsFunc $.Context)}}{{end}} 15 | 16 | 21 | {{else}} 22 | 23 | 24 | {{end}} 25 | {{$v.Help}} 26 | {{if $.Validate}}{{validate (print "settings.widgets[" $.I "]." $k) $.Validate}}{{end}} 27 |
28 | {{end}} 29 | {{end}} 30 | -------------------------------------------------------------------------------- /tpl/bosmang_sites.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 | 15 | 16 |

Sites

17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{range $s := .Stats}} 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{end}} 36 |
Total hitsLast 30dAvg.SiteCodesCreated at
{{nformat $s.Total $.User}}{{nformat $s.LastMonth $.User}}{{nformat $s.Avg $.User}}{{$s.ID}}{{$s.Codes}}{{tformat $s.CreatedAt "" $.User}}
37 | 38 | {{template "_backend_bottom.gohtml" .}} 39 | -------------------------------------------------------------------------------- /db/query/bosmang.List.sql: -------------------------------------------------------------------------------- 1 | with 2 | accounts as ( 3 | select 4 | site_id as site_id, 5 | (select a.site_id || array_agg(site_id) from sites c where c.parent = a.site_id) as allsites, 6 | (select string_agg(code, ' | ') from sites d where d.site_id = a.site_id or d.parent = a.site_id) as codes 7 | from sites a 8 | where parent is null 9 | group by site_id 10 | order by site_id asc 11 | ), 12 | total as ( 13 | select site_id, sum(total) as t from hit_counts group by site_id 14 | ), 15 | last_month as ( 16 | select site_id, sum(total) as t from hit_counts where hour >= now() - interval '30 days' group by site_id 17 | ), 18 | grouped as ( 19 | select 20 | accounts.site_id, 21 | (select coalesce(sum(t), 0) from total where total.site_id = any(accounts.allsites)) as total, 22 | (select coalesce(sum(t), 0) from last_month where last_month.site_id = any(accounts.allsites)) as last_month, 23 | codes 24 | from accounts 25 | group by accounts.site_id, codes, allsites 26 | order by last_month desc 27 | ) 28 | select 29 | grouped.site_id, 30 | grouped.total, 31 | created_at, 32 | grouped.last_month, 33 | (coalesce(total, 0) / greatest(extract('days' from now() - created_at), 1) * 30.5)::int as avg, 34 | grouped.codes 35 | from grouped 36 | join sites using (site_id) 37 | where last_month > 10000 or total > 500000 38 | -------------------------------------------------------------------------------- /path_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter_test 6 | 7 | import ( 8 | "testing" 9 | 10 | . "zgo.at/goatcounter/v2" 11 | "zgo.at/goatcounter/v2/gctest" 12 | "zgo.at/zdb" 13 | ) 14 | 15 | func TestPathsUpdateTitle(t *testing.T) { 16 | ctx := gctest.DB(t) 17 | 18 | wantTitle := func(want string) { 19 | var got string 20 | err := zdb.Get(ctx, &got, `select title from paths limit 1`) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | if want != got { 26 | t.Errorf("want: %q, got: %q", want, got) 27 | } 28 | } 29 | 30 | p := Path{Path: "/x", Title: "original"} 31 | err := p.GetOrInsert(ctx) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | wantTitle("original") 36 | 37 | for i := 0; i < 10; i++ { 38 | p2 := Path{Path: "/x", Title: "new"} 39 | err := p2.GetOrInsert(ctx) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | if p2.ID != p.ID { 44 | t.Fatalf("wrong ID: %d", p2.ID) 45 | } 46 | wantTitle("original") 47 | } 48 | 49 | p2 := Path{Path: "/x", Title: "new"} 50 | err = p2.GetOrInsert(ctx) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | if p2.ID != p.ID { 55 | t.Fatalf("wrong ID: %d", p2.ID) 56 | } 57 | wantTitle("new") 58 | } 59 | -------------------------------------------------------------------------------- /tpl/_dashboard_pages.gohtml: -------------------------------------------------------------------------------- 1 | {{/* TODO: make option to split counts between events and regular pageviews */}} 2 |
3 |
4 |

{{t .Context "dashboard/pages/header|Pages"}} 5 | {{if not $.User.Settings.FewerNumbers}} 6 | {{t .Context `dashboard/pages/num-visits|%(num-visits) out of %(total-visits) visits shown` 7 | (map 8 | "num-visits" (tag "span" `class="total-display"` (nformat .TotalDisplay $.User)) 9 | "total-visits" (tag "span" `class="total"` (nformat .Total $.User)) 10 | )}} 11 | {{end}} 12 |

13 | ⚙️ 14 |
15 | 16 | {{if .Err}} 17 | {{t .Context "p/error|Error: %(error-message)" .Err}} 18 | {{else}} 19 | 20 | {{template "_dashboard_pages_rows.gohtml" .}} 21 |
22 | 26 | {{end}} 27 |
28 | -------------------------------------------------------------------------------- /db/query/hit_list.GetTotalCount.sql: -------------------------------------------------------------------------------- 1 | with x as ( 2 | select 3 | coalesce(sum(total), 0) as total 4 | from hit_counts 5 | where 6 | site_id = :site and hour >= :start and hour <= :end 7 | {{:filter and path_id in (:filter)}} 8 | ), y as ( 9 | select 10 | coalesce(sum(total), 0) as total_events 11 | from hit_counts 12 | join paths using (site_id, path_id) 13 | where 14 | hit_counts.site_id = :site and hour >= :start and hour <= :end and paths.event = 1 15 | {{:filter and path_id in (:filter)}} 16 | ), z as ( 17 | select 18 | coalesce(sum(total), 0) as total_utc 19 | from hit_counts 20 | where 21 | site_id = :site and hour >= :start_utc and hour <= :end_utc 22 | {{:filter and path_id in (:filter)}} 23 | ) 24 | select 25 | * 26 | -- TODO the below should be faster, but isn't quite correct. 27 | -- 28 | -- Get the UTC offset for the browser, screen size, etc. charts, which are 29 | -- always stored in UTC. Instead of calculating everything again, substract the 30 | -- pageviews outside of the UTC range, which is a lot faster. 31 | -- x.total - ( 32 | -- select coalesce(sum(total), 0) 33 | -- from hit_counts 34 | -- where site_id = :site and 35 | -- {{:filter path_id in (:filter) and}} 36 | -- (hour >= :start and hour <= cast(:start as timestamp) + :tz * interval '1 minute') or 37 | -- (hour >= :end and hour <= cast(:end as timestamp) + :tz * interval '1 minute') 38 | -- ) as total_utc 39 | from x, y, z; 40 | -------------------------------------------------------------------------------- /widgets/dummy.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | ) 13 | 14 | type Dummy struct { 15 | } 16 | 17 | func (w Dummy) Name() string { return "dummy" } 18 | func (w Dummy) Type() string { return "hchart" } 19 | func (w Dummy) Label(ctx context.Context) string { return "" } 20 | func (w *Dummy) SetHTML(h template.HTML) {} 21 | func (w Dummy) HTML() template.HTML { return "" } 22 | func (w *Dummy) SetErr(h error) {} 23 | func (w Dummy) Err() error { return nil } 24 | func (w Dummy) ID() int { return 0 } 25 | func (w Dummy) Settings() goatcounter.WidgetSettings { return goatcounter.WidgetSettings{} } 26 | func (w *Dummy) SetSettings(s goatcounter.WidgetSettings) {} 27 | func (w Dummy) RenderHTML(context.Context, SharedData) (string, any) { return "", nil } 28 | func (w *Dummy) GetData(ctx context.Context, a Args) (more bool, err error) { 29 | return false, nil 30 | } 31 | -------------------------------------------------------------------------------- /tpl/user_dashboard.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | {{template "_user_nav.gohtml" .}} 3 | 4 |

{{.T "header/dashboard|Dashboard"}}

5 | 6 | 7 |
8 | 9 | 10 | 11 | {{template "_user_dashboard_widgets.gohtml" .}} 12 | 13 |
14 | 20 |
21 | 22 |
23 |
24 | 25 | {{if .User.AccessSettings}} 26 | 28 | {{end}} 29 |
30 | 31 | 32 |
33 |
34 | 35 | {{if has_errors .Validate}} 36 |
38 | {{.T "p/additional-errors|Additional errors"}}:{{.Validate.HTML}}
39 | {{end}} 40 | 41 | {{template "_backend_bottom.gohtml" .}} 42 | -------------------------------------------------------------------------------- /tpl/help/frame.md: -------------------------------------------------------------------------------- 1 | Sometimes it may be useful to embed GoatCounter in a frame; by default ebedding 2 | GoatCounter in a frame is disallowed, but in *Settings → Sites that can embed 3 | GoatCounter* you can add a list of domains or URLs that are allowed to embed 4 | GoatCounter. 5 | 6 | You can add: 7 | 8 | - A domain such as `example.com`, which will enable embedding on that entire 9 | domain for http and https on the standard ports (80 and 443). 10 | - An URL such as `https://example.com:8000` will allow embedding only over 11 | https and on port 8000. 12 | - An URL such as `example.com/path` will allow embedding on that URL. 13 | 14 | You will still need to login, which will work inside a frame. If you have 15 | "Dashboard viewable by" set to "logged in users or with secret token" then you 16 | will need to add the token to the frame's `src`; for example: 17 | 18 | 19 | 20 | ### Hiding the user interface 21 | You can hide the UI chrome ("sign in" button, footer, date selector) by adding 22 | `hideui=1` in the URL: 23 | 24 | For public view: 25 | 26 | 27 | 28 | Or with an access token: 29 | 30 | 31 | 32 | This also removes some of the padding, max-width, and background colour, making 33 | it easier to embed things in an iframe. If you're logged in no UI is removed. 34 | 35 | **This is not a security feature and people will still be able to get a 36 | different view by hacking the URL.** 37 | -------------------------------------------------------------------------------- /tpl/help/consent.md: -------------------------------------------------------------------------------- 1 | It is my understanding that GoatCounter does not need GDPR consent notices, but 2 | right no-one can be 100% sure, lacking case law and clarification from the 3 | member states' regulatory agents. See [GDPR consent 4 | notices](https://www.goatcounter.com/gdpr) for some more details. 5 | 6 | If you want to add a consent notice, then a simple example might be: 7 | 8 | 40 | {{template "code" .}} 41 | -------------------------------------------------------------------------------- /tpl/_dashboard_pages_text.gohtml: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{t .Context "dashboard/pages/header|Pages"}} 4 | {{if not $.User.Settings.FewerNumbers}} 5 | {{t .Context `dashboard/pages/num-visits|%(num-visits) out of %(total-visits) visits shown` 6 | (map 7 | "num-visits" (tag "span" `class="total-display"` (nformat .TotalDisplay $.User)) 8 | "total-visits" (tag "span" `class="total"` (nformat .Total $.User)) 9 | )}} 10 | {{end}} 11 |

12 | ⚙️ 13 |
14 | 15 | 16 | 17 | 18 | {{if not $.User.Settings.FewerNumbers}} 19 | 20 | {{end}} 21 | 22 | 23 | 24 | 25 | 26 | {{template "_dashboard_pages_text_rows.gohtml" .}} 27 |
{{t .Context "dashboard/pages/visits|Visits"}}{{t .Context "dashboard/pages/change|Change"}}{{t .Context "dashboard/pages/path|Path"}}{{t .Context "dashboard/pages/title|Title"}}{{t .Context "dashboard/pages/stats|Stats"}}
28 | {{t .Context "link/show-more|Show more"}} 29 |
30 | -------------------------------------------------------------------------------- /bgrun/default.go: -------------------------------------------------------------------------------- 1 | package bgrun 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | ) 10 | 11 | // TODO: probably want to get rid of this; just easier for now to migrate things 12 | // from the old bgrun to this. 13 | 14 | var stderr io.Writer = os.Stderr 15 | 16 | var defaultRunner = func() *Runner { 17 | r := NewRunner(func(t string, err error) { 18 | fmt.Fprintf(stderr, "bgrun: error running task %q: %s\n", t, err.Error()) 19 | }) 20 | r.depth = 3 21 | return r 22 | }() 23 | 24 | func NewTask(name string, maxPar int, f func(context.Context) error) { 25 | defaultRunner.NewTask(name, maxPar, f) 26 | } 27 | func Reset() { defaultRunner.Reset() } 28 | func Run(name string, fun func(context.Context) error) error { return defaultRunner.Run(name, fun) } 29 | func MustRun(name string, fun func(context.Context) error) { defaultRunner.MustRun(name, fun) } 30 | func RunFunction(name string, fun func()) error { return defaultRunner.RunFunction(name, fun) } 31 | func MustRunFunction(name string, fun func()) { defaultRunner.MustRunFunction(name, fun) } 32 | func MustRunTask(name string) { defaultRunner.MustRunTask(name) } 33 | func RunTask(name string) error { return defaultRunner.RunTask(name) } 34 | func Wait(name string) { defaultRunner.Wait(name) } 35 | func WaitFor(d time.Duration, name string) error { return defaultRunner.WaitFor(d, name) } 36 | func History(newSize int) []Job { return defaultRunner.History(newSize) } 37 | func Running() []Job { return defaultRunner.Running() } 38 | -------------------------------------------------------------------------------- /tpl/_contact.gohtml: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 | 14 | Send message 15 |
16 |
17 |
18 |
19 | {{if .v}}{{validate "email" .v}}{{end}} 20 | Make sure this is correct 21 |
22 | 23 |
24 |
25 |
26 | {{if .v}}{{validate "turing" .v}}{{end}} 27 | Just to verify that you’re human 28 |
29 |
30 | 31 |
32 |
33 |
34 | {{if .v}}{{validate "message" .v}}{{end}} 35 | 36 |
37 | 38 | {{if .v}} 39 | {{if has_errors .v}} 40 |
42 | {{.T "p/additional-errors|Additional errors"}}:{{.v.HTML}}
43 | {{end}} 44 | {{end}} 45 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /campaign.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter 6 | 7 | import ( 8 | "context" 9 | 10 | "zgo.at/errors" 11 | "zgo.at/zdb" 12 | "zgo.at/zvalidate" 13 | ) 14 | 15 | type Campaign struct { 16 | ID int64 `db:"campaign_id" json:"campaign_id"` 17 | SiteID int64 `db:"site_id" json:"site_id"` 18 | Name string `db:"name" json:"name"` 19 | } 20 | 21 | func (c *Campaign) Defaults(ctx context.Context) {} 22 | 23 | func (c *Campaign) Validate() error { 24 | v := zvalidate.New() 25 | v.Required("name", c.Name) 26 | return v.ErrorOrNil() 27 | } 28 | 29 | func (c *Campaign) Insert(ctx context.Context) error { 30 | if c.ID > 0 { 31 | return errors.Errorf("Campaign.Insert: c.ID>0: %d", c.ID) 32 | } 33 | 34 | c.Defaults(ctx) 35 | err := c.Validate() 36 | if err != nil { 37 | return errors.Wrap(err, "Campaign.Insert") 38 | } 39 | 40 | c.ID, err = zdb.InsertID(ctx, "campaign_id", 41 | `insert into campaigns (site_id, name) values (?, ?)`, MustGetSite(ctx).ID, c.Name) 42 | if err != nil { 43 | return errors.Wrap(err, "Campaign.Insert") 44 | } 45 | return nil 46 | } 47 | 48 | func (c *Campaign) ByName(ctx context.Context, name string) error { 49 | k := c.Name 50 | if cc, ok := cacheCampaigns(ctx).Get(k); ok { 51 | *c = *cc.(*Campaign) 52 | return nil 53 | } 54 | 55 | err := zdb.Get(ctx, c, `select * from campaigns where site_id=? and lower(name)=lower(?)`, 56 | MustGetSite(ctx).ID, name) 57 | if err != nil { 58 | return errors.Wrap(err, "Campaign.ByName") 59 | } 60 | 61 | cacheCampaigns(ctx).SetDefault(k, c) 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /cron/hit_count.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateHitCounts(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | // Group by day + pathID 19 | type gt struct { 20 | total int 21 | hour string 22 | pathID int64 23 | } 24 | grouped := map[string]gt{} 25 | for _, h := range hits { 26 | if h.Bot > 0 { 27 | continue 28 | } 29 | 30 | hour := h.CreatedAt.Format("2006-01-02 15:00:00") 31 | k := hour + strconv.FormatInt(h.PathID, 10) 32 | v := grouped[k] 33 | if v.total == 0 { 34 | v.hour = hour 35 | v.pathID = h.PathID 36 | } 37 | 38 | if h.FirstVisit { 39 | v.total += 1 40 | } 41 | grouped[k] = v 42 | } 43 | 44 | siteID := goatcounter.MustGetSite(ctx).ID 45 | ins := zdb.NewBulkInsert(ctx, "hit_counts", []string{"site_id", "path_id", 46 | "hour", "total"}) 47 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 48 | ins.OnConflict(`on conflict on constraint "hit_counts#site_id#path_id#hour" do update set 49 | total = hit_counts.total + excluded.total`) 50 | } else { 51 | ins.OnConflict(`on conflict(site_id, path_id, hour) do update set 52 | total = hit_counts.total + excluded.total`) 53 | } 54 | 55 | for _, v := range grouped { 56 | ins.Values(siteID, v.pathID, v.hour, v.total) 57 | } 58 | return ins.Finish() 59 | }), "cron.updateHitCounts") 60 | } 61 | -------------------------------------------------------------------------------- /cmd/goatcounter/monitor_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/goatcounter/v2/gctest" 14 | ) 15 | 16 | func TestMonitorOnce(t *testing.T) { 17 | t.Skip() // TODO: flaky test. 18 | 19 | exit, _, out, ctx, dbc := startTest(t) 20 | 21 | t.Run("no pageviews", func(t *testing.T) { 22 | runCmd(t, exit, "monitor", 23 | "-db="+dbc, 24 | "-once", 25 | "-debug=all") 26 | wantExit(t, exit, out, 1) 27 | if !strings.Contains(out.String(), "no hits in last") { 28 | t.Error(out.String()) 29 | } 30 | }) 31 | 32 | t.Run("with pageviews", func(t *testing.T) { 33 | gctest.StoreHits(ctx, t, false, goatcounter.Hit{}) 34 | 35 | runCmd(t, exit, "monitor", 36 | "-db="+dbc, 37 | "-once", 38 | "-debug=all") 39 | wantExit(t, exit, out, 0) 40 | if !strings.Contains(out.String(), "1 hits") { 41 | t.Error(out.String()) 42 | } 43 | }) 44 | } 45 | 46 | func TestMonitorLoop(t *testing.T) { 47 | t.Skip() // TODO: flaky test. 48 | 49 | exit, _, out, ctx, dbc := startTest(t) 50 | 51 | gctest.StoreHits(ctx, t, false, goatcounter.Hit{}) 52 | 53 | ready := make(chan struct{}, 1) 54 | stop := make(chan struct{}) 55 | go runCmdStop(t, exit, ready, stop, "monitor", 56 | "-db="+dbc, 57 | "-period=1", 58 | "-debug=all") 59 | <-ready 60 | 61 | time.Sleep(2 * time.Second) 62 | stop <- struct{}{} 63 | mainDone.Wait() 64 | 65 | if !strings.Contains(out.String(), "no hits in last") || !strings.Contains(out.String(), "1 hits") { 66 | t.Error(out.String()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /widgets/internal.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | ) 13 | 14 | type TotalCount struct { 15 | goatcounter.TotalCount 16 | 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | NoEvents bool 23 | } 24 | 25 | func (w TotalCount) Name() string { return "totalcount" } 26 | func (w TotalCount) Type() string { return "data-only" } 27 | func (w TotalCount) Label(ctx context.Context) string { return "" } 28 | func (w *TotalCount) SetHTML(h template.HTML) {} 29 | func (w TotalCount) HTML() template.HTML { return w.html } 30 | func (w *TotalCount) SetErr(h error) { w.err = h } 31 | func (w TotalCount) Err() error { return w.err } 32 | func (w TotalCount) ID() int { return 0 } 33 | func (w TotalCount) Settings() goatcounter.WidgetSettings { return w.s } 34 | func (w *TotalCount) SetSettings(s goatcounter.WidgetSettings) { w.s = s } 35 | func (w TotalCount) RenderHTML(context.Context, SharedData) (string, any) { return "", nil } 36 | 37 | func (w *TotalCount) GetData(ctx context.Context, a Args) (more bool, err error) { 38 | w.TotalCount, err = goatcounter.GetTotalCount(ctx, a.Rng, a.PathFilter, w.NoEvents) 39 | w.loaded = true 40 | return false, err 41 | } 42 | -------------------------------------------------------------------------------- /size.go: -------------------------------------------------------------------------------- 1 | package goatcounter 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "zgo.at/errors" 9 | "zgo.at/zcache" 10 | "zgo.at/zdb" 11 | ) 12 | 13 | type Size struct { 14 | ID int64 `db:"size_id"` 15 | Width int16 `db:"width"` 16 | Height int16 `db:"height"` 17 | Scale float64 `db:"scale"` 18 | Size string `db:"size"` 19 | } 20 | 21 | func (s *Size) Defaults(ctx context.Context) {} 22 | func (s *Size) Validate(ctx context.Context) error { return nil } 23 | 24 | func (s Size) String() string { 25 | return strconv.Itoa(int(s.Width)) + "," + strconv.Itoa(int(s.Height)) + 26 | "," + strconv.FormatFloat(s.Scale, 'f', 15, 64) 27 | } 28 | 29 | func (s *Size) GetOrInsert(ctx context.Context, size Floats) error { 30 | k := fmt.Sprintf("%v", size) 31 | c, ok := cacheSizes(ctx).Get(k) 32 | if ok { 33 | *s = c.(Size) 34 | cacheSizes(ctx).Touch(k, zcache.DefaultExpiration) 35 | return nil 36 | } 37 | 38 | // Sometimes people send invalid values; don't error out, just set as 39 | // unknown size. 40 | if len(size) != 3 { 41 | s.ID = 0 42 | cacheSizes(ctx).SetDefault(k, *s) 43 | return nil 44 | } 45 | 46 | s.Width, s.Height, s.Scale = int16(size[0]), int16(size[1]), size[2] 47 | 48 | err := zdb.Get(ctx, s, `/* Size.GetOrInsert */ 49 | select * from sizes where size = ? limit 1`, s.String()) 50 | if err == nil { 51 | cacheSizes(ctx).SetDefault(k, *s) 52 | return nil 53 | } 54 | if !zdb.ErrNoRows(err) { 55 | return errors.Wrap(err, "Size.GetOrInsert get") 56 | } 57 | 58 | s.ID, err = zdb.InsertID(ctx, "size_id", 59 | `insert into sizes (width, height, scale) values (?, ?, ?)`, 60 | s.Width, s.Height, s.Scale) 61 | if err != nil { 62 | return errors.Wrap(err, "Size.GetOrInsert insert") 63 | } 64 | 65 | cacheSizes(ctx).SetDefault(k, *s) 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /tpl/_dashboard_pages_text_rows.gohtml: -------------------------------------------------------------------------------- 1 | {{range $i, $h := .Pages}} 2 | 5 | {{sum $.Offset $i}} 6 | {{if not $.User.Settings.FewerNumbers}} 7 | {{nformat $h.Count $.User}} 8 | {{$d := index $.Diff $i}} 9 | 10 | {{if is_inf $d}} 11 | {{t $.Context "new-paren|(new)"}} 12 | {{else}} 13 | {{if gt $d 0.0}}+{{else if lt $d 0.0}}–{{end}}{{printf "%.0f" (max (round (abs $d) 0) 1)}}% 14 | {{end}} 15 | 16 | {{end}} 17 | 18 | {{$h.Path}} 19 | 20 | {{if and $.Site.LinkDomain (not $h.Event)}} 21 |
22 | {{t $.Context "link/goto-path|Go to %(path)" ($.Site.LinkDomainURL false $h.Path)}} 23 | 24 | {{end}} 25 | 26 |
27 | {{if and $.Refs (eq $.ShowRefs $h.PathID)}} 28 | {{template "_dashboard_pages_refs.gohtml" (map "Context" $.Context "Refs" $.Refs "Count" $h.Count)}} 29 | {{end}} 30 |
31 | 32 | {{if $h.Title}}{{$h.Title}}{{else}}({{t $.Context "no-title|no title"}}){{end}} 33 | {{if $h.Event}}{{t $.Context "event|event"}}{{end}} 34 | {{text_chart $.Context .Stats $.Max $.Daily}} 35 | 36 | {{else}} 37 | {{t $.Context "dashboard/nothing-to-display|Nothing to display"}} 38 | {{- end}} 39 | -------------------------------------------------------------------------------- /public/i18n.js: -------------------------------------------------------------------------------- 1 | // Make sure textarea is hight enough to fit all content. 2 | // 3 | // TODO: on tab make sure the entire entry is visible, sometimes the original on 4 | // the left is larger. 5 | $('textarea').each(function(_, t) { 6 | $(t).css('height', (t.scrollHeight + 2) + 'px'); 7 | }) 8 | 9 | // Set which translations are visible. 10 | $('#i18n-controls').on('change', function(e) { 11 | var s = $('#i18n-controls input[name=i18n-show]').filter((_, e) => e.checked) 12 | switch (s[0].value) { 13 | case 'all': 14 | $('.i18n-message').css('display', 'block') 15 | break; 16 | case 'untrans': 17 | $('.i18n-message').css('display', 'none') 18 | $('.i18n-message[data-status=untrans]').css('display', 'block') 19 | break; 20 | } 21 | }) 22 | 23 | // Show information when textarea is active. 24 | $('.i18n-message textarea').on('focus', function(e) { 25 | $(this).closest('.i18n-message').find('.i18n-info').css('display', 'block') 26 | }) 27 | 28 | // Save on blur. 29 | // TODO: Also after user stopped typing for a second 30 | $('.i18n-message textarea').on('blur', function(e) { 31 | var msg = $(this).closest('.i18n-message') 32 | msg.find('.i18n-info').css('display', 'none') 33 | 34 | var data = {csrf: CSRF, 'entry.id': msg.attr('data-id')} 35 | msg.find('textarea').each((_, t) => { 36 | t = $(t) 37 | data['entry.' + t.attr('data-field')] = t.val() 38 | }) 39 | 40 | jQuery.ajax({ 41 | method: 'POST', 42 | url: location.path, 43 | dataType: 'json', 44 | data: data, 45 | 46 | // TODO: show error if any, set to checkbox onsuccess. 47 | success: function(data) { 48 | msg.attr('data-status', 'ok') 49 | }, 50 | error: function(xhr, settings, e) { 51 | msg.attr('data-status', 'err') 52 | msg.find('.i18n-err').attr('title', 'Error: ' + xhr.responseJSON.error) 53 | }, 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /gen.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | //go:build go_run_only 6 | // +build go_run_only 7 | 8 | package main 9 | 10 | import ( 11 | "bytes" 12 | "fmt" 13 | "os" 14 | "os/exec" 15 | 16 | "zgo.at/errors" 17 | "zgo.at/zstd/zio" 18 | "zgo.at/zstd/zruntime" 19 | ) 20 | 21 | func main() { 22 | if _, ok := os.LookupEnv("CI"); ok { 23 | return 24 | } 25 | 26 | for _, f := range []func() error{kommentaar} { 27 | err := f() 28 | if err != nil { 29 | fmt.Fprintf(os.Stderr, "%s: %s\n", zruntime.FuncName(f), err) 30 | } 31 | } 32 | } 33 | 34 | // TODO: would be better to just generate this on the first request, but it 35 | // takes about 10s now so that's a bit too slow :-/ 36 | func kommentaar() error { 37 | if !zio.ChangedFrom("./handlers/api.go", "./tpl/api.json") && 38 | !zio.ChangedFrom("./kommentaar.conf", "./tpl/api.json") { 39 | return nil 40 | } 41 | 42 | commands := map[string][]string{ 43 | "tpl/api.json": {"-config", "../kommentaar.conf", "-output", "openapi2-jsonindent", "."}, 44 | "tpl/api.html": {"-config", "../kommentaar.conf", "-output", "html", "."}, 45 | } 46 | 47 | for file, args := range commands { 48 | stderr := new(bytes.Buffer) 49 | cmd := exec.Command("kommentaar", args...) 50 | cmd.Dir = "./handlers" 51 | cmd.Stderr = stderr 52 | 53 | fmt.Println("running", cmd.Args) 54 | out, err := cmd.Output() 55 | if err != nil { 56 | out = stderr.Bytes() 57 | return errors.Errorf("running kommentaar: %s\n%s", err, out) 58 | } 59 | 60 | err = os.WriteFile(file, out, 0666) 61 | if err != nil { 62 | return errors.Errorf("kommentaar: %s\n%s", err) 63 | } 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /cron/ref_count.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateRefCounts(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | // Group by day + pathID + ref. 19 | type gt struct { 20 | total int 21 | hour string 22 | pathID int64 23 | refID int64 24 | } 25 | grouped := map[string]gt{} 26 | for _, h := range hits { 27 | if h.Bot > 0 { 28 | continue 29 | } 30 | 31 | hour := h.CreatedAt.Format("2006-01-02 15:00:00") 32 | k := hour + strconv.FormatInt(h.PathID, 10) + strconv.FormatInt(h.RefID, 10) 33 | v := grouped[k] 34 | if v.total == 0 { 35 | v.hour = hour 36 | v.pathID = h.PathID 37 | v.refID = h.RefID 38 | } 39 | 40 | if h.FirstVisit { 41 | v.total += 1 42 | } 43 | grouped[k] = v 44 | } 45 | 46 | siteID := goatcounter.MustGetSite(ctx).ID 47 | ins := zdb.NewBulkInsert(ctx, "ref_counts", []string{"site_id", "path_id", 48 | "ref_id", "hour", "total"}) 49 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 50 | ins.OnConflict(`on conflict on constraint "ref_counts#site_id#path_id#ref_id#hour" do update set 51 | total = ref_counts.total + excluded.total`) 52 | } else { 53 | ins.OnConflict(`on conflict(site_id, path_id, ref_id, hour) do update set 54 | total = ref_counts.total + excluded.total`) 55 | } 56 | 57 | for _, v := range grouped { 58 | ins.Values(siteID, v.pathID, v.refID, v.hour, v.total) 59 | } 60 | return ins.Finish() 61 | }), "cron.updateRefCounts") 62 | } 63 | -------------------------------------------------------------------------------- /tpl/help/events.md: -------------------------------------------------------------------------------- 1 | GoatCounter will automatically bind a click event on any element with the 2 | `data-goatcounter-click` attribute; for example to track clicks to an external 3 | link as `ext-example.com`: 4 | 5 | Example 6 | 7 | The `name` or `id` attribute will be used if `data-goatcounter-click` is empty, 8 | in that order. 9 | 10 | You can use `data-goatcounter-title` and `data-goatcounter-referrer` to set the 11 | title and/or referrer: 12 | 13 | Example 18 | 19 | The regular `title` attribute or the element's HTML (capped to 200 characters) 20 | is used if `data-goatcounter-title` is empty. There is no default for the 21 | referrer. 22 | 23 | ### Sending events from JavaScript 24 | You can send an event by setting the `event` parameter to `true` in `count()`. 25 | For example: 26 | 27 | $('#banana').on('click', function(e) { 28 | window.goatcounter.count({ 29 | path: 'click-banana', 30 | title: 'Yellow curvy fruit', 31 | event: true, 32 | }) 33 | }) 34 | 35 | The `path` doubles as the event name. This cannot have `/` as the first 36 | character. 37 | 38 | There is currently no real way to record the path with the event, although you 39 | can send it as part of the event name: 40 | 41 | window.goatcounter.count({ 42 | path: function(p) { return 'click-banana-' + p }, 43 | event: true, 44 | }) 45 | 46 | The callback will have the regular `path` passed to it, and you can add an event 47 | name there; you can also use `window.location.pathname` directly; the biggest 48 | difference with the passed value is that `` is taken in to 49 | account. 50 | -------------------------------------------------------------------------------- /tpl/help/modify.md: -------------------------------------------------------------------------------- 1 | A few examples on how to modify various parameters in the [JavaScript 2 | API](/code/js). Also see [Control the path that's sent to 3 | GoatCounter](/code/path). 4 | 5 | Custom path and referrer 6 | ------------------------ 7 | A basic example with some custom logic for `path`: 8 | 9 | 22 | {{template "code" .}} 23 | 24 | 25 | Setting the endpoint in JavaScript 26 | ---------------------------------- 27 | Normally GoatCounter gets the endpoint to send pageviews to from the 28 | `data-goatcounter` attribute on the ` 49 | 50 | 51 | Note that `data-goatcounter` will always override any `goatcounter.endpoint`, so 52 | don't include it! 53 | 54 | And remember to do this before the `count.js` script is loaded, or call 55 | `window.goatcounter.count()` manually. 56 | -------------------------------------------------------------------------------- /cron/language_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | "zgo.at/zstd/ztype" 15 | ) 16 | 17 | func updateLanguageStats(ctx context.Context, hits []goatcounter.Hit) error { 18 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 19 | type gt struct { 20 | count int 21 | day string 22 | language string 23 | pathID int64 24 | } 25 | grouped := map[string]gt{} 26 | for _, h := range hits { 27 | if h.Bot > 0 { 28 | continue 29 | } 30 | 31 | day := h.CreatedAt.Format("2006-01-02") 32 | k := day + ztype.Deref(h.Language, "") + strconv.FormatInt(h.PathID, 10) 33 | v := grouped[k] 34 | if v.count == 0 { 35 | v.day = day 36 | v.language = ztype.Deref(h.Language, "") 37 | v.pathID = h.PathID 38 | } 39 | 40 | if h.FirstVisit { 41 | v.count += 1 42 | } 43 | grouped[k] = v 44 | } 45 | 46 | siteID := goatcounter.MustGetSite(ctx).ID 47 | ins := zdb.NewBulkInsert(ctx, "language_stats", []string{"site_id", "day", "path_id", "language", "count"}) 48 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 49 | ins.OnConflict(`on conflict on constraint "language_stats#site_id#path_id#day#language" do update set 50 | count = language_stats.count + excluded.count`) 51 | } else { 52 | ins.OnConflict(`on conflict(site_id, path_id, day, language) do update set 53 | count = language_stats.count + excluded.count`) 54 | } 55 | 56 | for _, v := range grouped { 57 | if v.count > 0 { 58 | ins.Values(siteID, v.day, v.pathID, v.language, v.count) 59 | } 60 | } 61 | return ins.Finish() 62 | }), "cron.updateLanguageStats") 63 | } 64 | -------------------------------------------------------------------------------- /cron/system_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateSystemStats(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | type gt struct { 19 | count int 20 | day string 21 | systemID int64 22 | pathID int64 23 | } 24 | grouped := map[string]gt{} 25 | for _, h := range hits { 26 | if h.Bot > 0 { 27 | continue 28 | } 29 | if h.SystemID == 0 { 30 | continue 31 | } 32 | 33 | day := h.CreatedAt.Format("2006-01-02") 34 | k := day + strconv.FormatInt(h.SystemID, 10) + strconv.FormatInt(h.PathID, 10) 35 | v := grouped[k] 36 | if v.count == 0 { 37 | v.day = day 38 | v.systemID = h.SystemID 39 | v.pathID = h.PathID 40 | } 41 | 42 | if h.FirstVisit { 43 | v.count += 1 44 | } 45 | grouped[k] = v 46 | } 47 | 48 | siteID := goatcounter.MustGetSite(ctx).ID 49 | ins := zdb.NewBulkInsert(ctx, "system_stats", []string{"site_id", "day", 50 | "path_id", "system_id", "count"}) 51 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 52 | ins.OnConflict(`on conflict on constraint "system_stats#site_id#path_id#day#system_id" do update set 53 | count = system_stats.count + excluded.count`) 54 | } else { 55 | ins.OnConflict(`on conflict(site_id, path_id, day, system_id) do update set 56 | count = system_stats.count + excluded.count`) 57 | } 58 | 59 | for _, v := range grouped { 60 | if v.count > 0 { 61 | ins.Values(siteID, v.day, v.pathID, v.systemID, v.count) 62 | } 63 | } 64 | return ins.Finish() 65 | }), "cron.updateSystemStats") 66 | } 67 | -------------------------------------------------------------------------------- /db/migrate/2021-12-09-1-email-reports-sqlite.sql: -------------------------------------------------------------------------------- 1 | create table users2 ( 2 | user_id integer primary key autoincrement, 3 | site_id integer not null, 4 | 5 | email varchar not null check(length(email) > 5 and length(email) <= 255), 6 | email_verified integer not null default 0, 7 | password blob default null, 8 | totp_enabled integer not null default 0, 9 | totp_secret blob, 10 | access varchar not null default '{"all":"a"}', 11 | login_at timestamp null check(login_at = strftime('%Y-%m-%d %H:%M:%S', login_at)), 12 | login_request varchar null, 13 | login_token varchar null, 14 | csrf_token varchar null, 15 | email_token varchar null, 16 | seen_updates_at timestamp not null default current_timestamp check(seen_updates_at = strftime('%Y-%m-%d %H:%M:%S', seen_updates_at)), 17 | reset_at timestamp null, 18 | settings varchar not null default '{}', 19 | last_report_at timestamp not null default current_timestamp, 20 | 21 | created_at timestamp not null check(created_at = strftime('%Y-%m-%d %H:%M:%S', created_at)), 22 | updated_at timestamp check(updated_at = strftime('%Y-%m-%d %H:%M:%S', updated_at)), 23 | 24 | foreign key (site_id) references sites(site_id) on delete restrict on update restrict 25 | ); 26 | insert into users2 27 | select user_id, site_id, email, email_verified, password, totp_enabled, 28 | totp_secret, access, login_at, login_request, login_token, csrf_token, 29 | email_token, seen_updates_at, reset_at, settings, datetime(), created_at, 30 | updated_at 31 | from users; 32 | 33 | drop table users; 34 | alter table users2 rename to users; 35 | create index "users#site_id" on users(site_id); 36 | create unique index "users#site_id#email" on users(site_id, lower(email)); 37 | -------------------------------------------------------------------------------- /cron/size_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateSizeStats(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | type gt struct { 19 | count int 20 | day string 21 | width int 22 | pathID int64 23 | } 24 | grouped := map[string]gt{} 25 | for _, h := range hits { 26 | if h.Bot > 0 { 27 | continue 28 | } 29 | 30 | var width int 31 | if len(h.Size) > 0 { 32 | width = int(h.Size[0]) // TODO: apply scaling? 33 | } 34 | 35 | day := h.CreatedAt.Format("2006-01-02") 36 | k := day + strconv.Itoa(width) + strconv.FormatInt(h.PathID, 10) 37 | v := grouped[k] 38 | if v.count == 0 { 39 | v.day = day 40 | v.width = width 41 | v.pathID = h.PathID 42 | } 43 | 44 | if h.FirstVisit { 45 | v.count += 1 46 | } 47 | grouped[k] = v 48 | } 49 | 50 | siteID := goatcounter.MustGetSite(ctx).ID 51 | ins := zdb.NewBulkInsert(ctx, "size_stats", []string{"site_id", "day", 52 | "path_id", "width", "count"}) 53 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 54 | ins.OnConflict(`on conflict on constraint "size_stats#site_id#path_id#day#width" do update set 55 | count = size_stats.count + excluded.count`) 56 | } else { 57 | ins.OnConflict(`on conflict(site_id, path_id, day, width) do update set 58 | count = size_stats.count + excluded.count`) 59 | } 60 | 61 | for _, v := range grouped { 62 | if v.count > 0 { 63 | ins.Values(siteID, v.day, v.pathID, v.width, v.count) 64 | } 65 | } 66 | return ins.Finish() 67 | }), "cron.updateSizeStats") 68 | } 69 | -------------------------------------------------------------------------------- /cron/tasks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron_test 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/goatcounter/v2/cron" 14 | "zgo.at/goatcounter/v2/gctest" 15 | "zgo.at/zstd/zbool" 16 | "zgo.at/zstd/ztime" 17 | ) 18 | 19 | func TestDataRetention(t *testing.T) { 20 | ctx := gctest.DB(t) 21 | 22 | site := goatcounter.Site{Code: "bbbb", Settings: goatcounter.SiteSettings{DataRetention: 31}} 23 | err := site.Insert(ctx) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | ctx = goatcounter.WithSite(ctx, &site) 28 | 29 | now := time.Now().UTC() 30 | past := now.Add(-40 * 24 * time.Hour) 31 | 32 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 33 | {Site: site.ID, CreatedAt: now, Path: "/a", FirstVisit: zbool.Bool(true)}, 34 | {Site: site.ID, CreatedAt: now, Path: "/a", FirstVisit: zbool.Bool(false)}, 35 | {Site: site.ID, CreatedAt: past, Path: "/a", FirstVisit: zbool.Bool(true)}, 36 | {Site: site.ID, CreatedAt: past, Path: "/a", FirstVisit: zbool.Bool(false)}, 37 | }...) 38 | 39 | err = cron.TaskDataRetention() 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | cron.WaitDataRetention() 44 | 45 | var hits goatcounter.Hits 46 | err = hits.TestList(ctx, false) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | if len(hits) != 2 { 51 | t.Errorf("len(hits) is %d\n%v", len(hits), hits) 52 | } 53 | 54 | var stats goatcounter.HitLists 55 | display, more, err := stats.List(ctx, 56 | ztime.NewRange(past.Add(-1*24*time.Hour)).To(now), 57 | nil, nil, 10, false) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | out := fmt.Sprintf("%d %t %v", display, more, err) 63 | want := `1 false ` 64 | if out != want { 65 | t.Errorf("\ngot: %s\nwant: %s", out, want) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tpl/help/privacy.md: -------------------------------------------------------------------------------- 1 | *8 March 2021* 2 | 3 | The following information can be stored: 4 | 5 | - URL of the visited page. 6 | - `Referer` header. 7 | - Browser and system information (derived from `User-Agent` header or HTTP 8 | client hints; the original headers are not stored). 9 | - Screen size. 10 | - Country and region name derived from the IP address. 11 | - The browser language derived from the `Accept-Language` header. 12 | 13 | There is a setting to disable collecting any of this data and the collected data 14 | may differ per hosted site, but the default is to collect all of the above 15 | except the language. 16 | 17 | No personal information (such as IP address) is collected; a hash of the IP 18 | address, User-Agent, and a random number (“salt”) is kept in the process memory 19 | for 8 hours to identify a browsing session, and is never stored to disk. 20 | 21 | There is no information stored in the browser with cookies, localStorage, or 22 | other methods. 23 | 24 | Also see the [GDPR consent notices]. 25 | 26 | [GDPR consent notices]: /help/gdpr.html 27 | 28 | Sharing with third parties 29 | -------------------------- 30 | No information is shared with third parties. 31 | 32 | Using the GoatCounter.com service 33 | --------------------------------- 34 | An email address is required to use the GoatCounter.com service. GoatCounter 35 | also use cookies to: 36 | 37 | - remember that you’re logged in to your account between visits; 38 | - store short-lived informational messages (“flash messages”), for example to 39 | inform that an operation was completed or that there was an error. 40 | 41 | Email [support@goatcounter.com] to request all information collected about you, 42 | or to request removal of all information. 43 | 44 | [support@goatcounter.com]: mailto:support@goatcounter.coms 45 | 46 | Changes to the policy 47 | --------------------- 48 | We may make changes to this policy with at least two weeks notice. Notice will 49 | be sent to the email on file for your account. 50 | -------------------------------------------------------------------------------- /cron/browser_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateBrowserStats(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | type gt struct { 19 | count int 20 | day string 21 | browserID int64 22 | pathID int64 23 | } 24 | grouped := map[string]gt{} 25 | for _, h := range hits { 26 | if h.Bot > 0 { 27 | continue 28 | } 29 | if h.BrowserID == 0 { 30 | continue 31 | } 32 | 33 | day := h.CreatedAt.Format("2006-01-02") 34 | k := day + strconv.FormatInt(h.BrowserID, 10) + strconv.FormatInt(h.PathID, 10) 35 | v := grouped[k] 36 | if v.count == 0 { 37 | v.day = day 38 | v.browserID = h.BrowserID 39 | v.pathID = h.PathID 40 | } 41 | 42 | if h.FirstVisit { 43 | v.count += 1 44 | } 45 | grouped[k] = v 46 | } 47 | 48 | siteID := goatcounter.MustGetSite(ctx).ID 49 | ins := zdb.NewBulkInsert(ctx, "browser_stats", []string{"site_id", "day", 50 | "path_id", "browser_id", "count"}) 51 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 52 | ins.OnConflict(`on conflict on constraint "browser_stats#site_id#path_id#day#browser_id" do update set 53 | count = browser_stats.count + excluded.count`) 54 | } else { 55 | ins.OnConflict(`on conflict(site_id, path_id, day, browser_id) do update set 56 | count = browser_stats.count + excluded.count`) 57 | } 58 | 59 | for _, v := range grouped { 60 | if v.count > 0 { 61 | ins.Values(siteID, v.day, v.pathID, v.browserID, v.count) 62 | } 63 | } 64 | return ins.Finish() 65 | }), "cron.updateBrowserStats") 66 | } 67 | -------------------------------------------------------------------------------- /public/int-logo/write-as.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /cron/campaign_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateCampaignStats(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | type gt struct { 19 | count int 20 | day string 21 | campaignID int64 22 | ref string 23 | pathID int64 24 | } 25 | grouped := map[string]gt{} 26 | for _, h := range hits { 27 | if h.Bot > 0 || h.CampaignID == nil || *h.CampaignID == 0 { 28 | continue 29 | } 30 | 31 | day := h.CreatedAt.Format("2006-01-02") 32 | k := day + strconv.FormatInt(*h.CampaignID, 10) + h.Ref + strconv.FormatInt(h.PathID, 10) 33 | v := grouped[k] 34 | if v.count == 0 { 35 | v.day = day 36 | v.campaignID = *h.CampaignID 37 | v.ref = h.Ref 38 | v.pathID = h.PathID 39 | } 40 | 41 | if h.FirstVisit { 42 | v.count += 1 43 | } 44 | grouped[k] = v 45 | } 46 | 47 | siteID := goatcounter.MustGetSite(ctx).ID 48 | ins := zdb.NewBulkInsert(ctx, "campaign_stats", []string{"site_id", "day", 49 | "path_id", "campaign_id", "ref", "count"}) 50 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 51 | ins.OnConflict(`on conflict on constraint "campaign_stats#site_id#path_id#campaign_id#ref#day" do update set 52 | count = campaign_stats.count + excluded.count`) 53 | } else { 54 | ins.OnConflict(`on conflict(site_id, path_id, campaign_id, ref, day) do update set 55 | count = campaign_stats.count + excluded.count`) 56 | } 57 | 58 | for _, v := range grouped { 59 | if v.count > 0 { 60 | ins.Values(siteID, v.day, v.pathID, v.campaignID, v.ref, v.count) 61 | } 62 | } 63 | return ins.Finish() 64 | }), "cron.updateCampaignStats") 65 | } 66 | -------------------------------------------------------------------------------- /tpl/email_report.gohtml: -------------------------------------------------------------------------------- 1 | 2 |

Hi there!

3 | 4 |

This is your GoatCounter report for {{.DisplayDate}} for the site {{.Site.URL .Context}}.

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {{range $i, $p := .Pages}} 16 | 17 | 18 | 19 | {{end}} 20 | 21 |
Top 10 pages
PathVisitsGrowth
{{$p.Path}}{{if $p.Event}} event{{end}}{{nformat $p.Count $.User}}{{index $.Diffs $i}}
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {{range $r := .Refs.Stats}} 31 | 32 | 33 | {{end}} 34 | 35 |
Top 10 referrers
ReferrerVisits
{{if $r.Name}}{{$r.Name}}{{else}}(no data){{end}}{{nformat $r.Count $.User}}
36 | 37 |

38 | This email is sent because it’s enabled in your settings. 39 | Disable it in your settings if you want to stop receiving it. 40 |

41 | 42 | {{template "_email_bottom.gohtml" .}} 43 | 44 | -------------------------------------------------------------------------------- /acme/acme_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package acme_test 6 | 7 | import ( 8 | "testing" 9 | 10 | . "zgo.at/goatcounter/v2/acme" 11 | "zgo.at/goatcounter/v2/gctest" 12 | "zgo.at/zdb" 13 | "zgo.at/zhttp" 14 | "zgo.at/zstd/zruntime" 15 | ) 16 | 17 | func TestSetup(t *testing.T) { 18 | tests := []struct { 19 | flag string 20 | 21 | wantTLS bool 22 | wantACME bool 23 | wantFlag uint8 24 | wantSecure bool 25 | }{ 26 | // No TLS. 27 | {"", false, false, 0, false}, 28 | {"http", false, false, 0, false}, 29 | 30 | //flagTLS = map[bool]string{true: "none", false: "acme"}[dev] 31 | 32 | {"acme,http", false, true, 0, false}, // saas default 33 | {"acme,proxy", false, true, 0, true}, 34 | {"acme,rdr", true, true, zhttp.ServeRedirect, true}, // serve default 35 | 36 | {"acme:some/dir,rdr", true, true, zhttp.ServeRedirect, true}, 37 | 38 | {"acme,testdata/test.pem", true, true, 0, true}, 39 | {"testdata/test.pem", true, false, 0, true}, 40 | {"rdr,testdata/test.pem", true, false, zhttp.ServeRedirect, true}, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.flag, func(t *testing.T) { 45 | defer Reset() 46 | ctx := gctest.DB(t) 47 | 48 | tlsC, acmeH, haveFlag, haveSecure := Setup(zdb.MustGetDB(ctx), tt.flag, true) 49 | haveTLS := tlsC != nil 50 | haveACME := acmeH != nil 51 | 52 | if tlsC != nil { 53 | t.Logf(zruntime.FuncName(tlsC.GetCertificate)) 54 | } 55 | if haveTLS != tt.wantTLS { 56 | t.Errorf("have TLS %t; want %t", haveTLS, tt.wantTLS) 57 | } 58 | if haveACME != tt.wantACME { 59 | t.Errorf("have ACME %t; want %t", haveACME, tt.wantACME) 60 | } 61 | if haveFlag != tt.wantFlag { 62 | t.Errorf("have flag %d; want %d", haveFlag, tt.wantFlag) 63 | } 64 | if haveSecure != tt.wantSecure { 65 | t.Errorf("secure %v; want %v", haveSecure, tt.wantSecure) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cron/location_stat_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron_test 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/goatcounter/v2/gctest" 14 | "zgo.at/zstd/ztime" 15 | ) 16 | 17 | func TestLocationStats(t *testing.T) { 18 | ctx := gctest.DB(t) 19 | 20 | site := goatcounter.MustGetSite(ctx) 21 | now := time.Date(2019, 8, 31, 14, 42, 0, 0, time.UTC) 22 | 23 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 24 | {Site: site.ID, CreatedAt: now, Location: "ID"}, 25 | {Site: site.ID, CreatedAt: now, Location: "ID"}, 26 | {Site: site.ID, CreatedAt: now, Location: "ET", FirstVisit: true}, 27 | }...) 28 | 29 | var stats goatcounter.HitStats 30 | err := stats.ListLocations(ctx, ztime.NewRange(now).To(now), nil, 10, 0) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | want := `{false [{ET Ethiopia 1 }]}` 36 | out := fmt.Sprintf("%v", stats) 37 | if want != out { 38 | t.Errorf("\nwant: %s\nout: %s", want, out) 39 | } 40 | 41 | // Update existing. 42 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 43 | {Site: site.ID, CreatedAt: now, Location: "ID"}, 44 | {Site: site.ID, CreatedAt: now, Location: "ID", FirstVisit: true}, 45 | {Site: site.ID, CreatedAt: now, Location: "ET"}, 46 | {Site: site.ID, CreatedAt: now, Location: "ET", FirstVisit: true}, 47 | {Site: site.ID, CreatedAt: now, Location: "ET", FirstVisit: true}, 48 | {Site: site.ID, CreatedAt: now, Location: "ET"}, 49 | {Site: site.ID, CreatedAt: now, Location: "NZ", FirstVisit: true}, 50 | }...) 51 | 52 | stats = goatcounter.HitStats{} 53 | err = stats.ListLocations(ctx, ztime.NewRange(now).To(now), nil, 10, 0) 54 | if err != nil { 55 | t.Fatal(err) 56 | } 57 | 58 | want = `{false [{ET Ethiopia 3 } {ID Indonesia 1 } {NZ New Zealand 1 }]}` 59 | out = fmt.Sprintf("%v", stats) 60 | if want != out { 61 | t.Errorf("\nwant: %s\nout: %s", want, out) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /locations_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter_test 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | 11 | . "zgo.at/goatcounter/v2" 12 | "zgo.at/goatcounter/v2/gctest" 13 | "zgo.at/zdb" 14 | "zgo.at/zstd/ztest" 15 | ) 16 | 17 | func TestLocations(t *testing.T) { 18 | ctx := gctest.DB(t) 19 | 20 | run := func() { 21 | { 22 | var l Location 23 | err := l.Lookup(ctx, "51.171.91.33") 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | out := fmt.Sprintf("%#v", l) 29 | want := `goatcounter.Location{ID:2, Country:"IE", Region:"", CountryName:"Ireland", RegionName:"", ISO3166_2:"IE"}` 30 | if out != want { 31 | t.Error(out) 32 | } 33 | } 34 | { 35 | var l Location 36 | err := l.ByCode(ctx, "US-TX") 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | out := fmt.Sprintf("%#v", l) 42 | want := `goatcounter.Location{ID:3, Country:"US", Region:"TX", CountryName:"United States", RegionName:"", ISO3166_2:"US-TX"}` 43 | if out != want { 44 | t.Error(out) 45 | } 46 | } 47 | 48 | out := zdb.DumpString(ctx, `select * from locations`) 49 | want := ` 50 | location_id iso_3166_2 country region country_name region_name 51 | 1 (unknown) 52 | 2 IE IE Ireland 53 | 3 US-TX US TX United States 54 | 4 US US United States` 55 | if d := ztest.Diff(out, want, ztest.DiffNormalizeWhitespace); d != "" { 56 | t.Error(d) 57 | } 58 | } 59 | 60 | // Run it multiple times, since it should always give the same resuts. 61 | run() 62 | run() 63 | ctx = NewContext(zdb.MustGetDB(ctx)) // Reset cache 64 | run() 65 | } 66 | 67 | func BenchmarkLocationsByCode(b *testing.B) { 68 | ctx := gctest.DB(b) 69 | 70 | b.ReportAllocs() 71 | b.ResetTimer() 72 | for n := 0; n < b.N; n++ { 73 | (&Location{}).ByCode(ctx, "US-TX") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /cmd/goatcounter/db_migrate.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "slices" 10 | "strings" 11 | 12 | "zgo.at/errors" 13 | "zgo.at/goatcounter/v2" 14 | "zgo.at/goatcounter/v2/db/migrate/gomig" 15 | "zgo.at/zdb" 16 | "zgo.at/zli" 17 | "zgo.at/zlog" 18 | "zgo.at/zstd/zfs" 19 | "zgo.at/zstd/zslice" 20 | ) 21 | 22 | func cmdDBMigrate(f zli.Flags, dbConnect, debug *string, createdb *bool) error { 23 | var ( 24 | dev = f.Bool(false, "dev") 25 | test = f.Bool(false, "test") 26 | show = f.Bool(false, "show") 27 | ) 28 | err := f.Parse() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if len(f.Args) == 0 { 34 | return errors.New("need a migration or command") 35 | } 36 | 37 | zlog.Config.SetDebug(*debug) 38 | 39 | db, _, err := connectDB(*dbConnect, "", nil, *createdb, false) 40 | if err != nil { 41 | return err 42 | } 43 | defer db.Close() 44 | 45 | fsys, err := zfs.EmbedOrDir(goatcounter.DB, "", dev.Bool()) 46 | if err != nil { 47 | return err 48 | } 49 | m, err := zdb.NewMigrate(db, fsys, gomig.Migrations) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | m.Test(test.Bool()) 55 | m.Show(show.Set()) 56 | 57 | if zslice.ContainsAny(f.Args, "pending", "list") { 58 | have, ran, err := m.List() 59 | if err != nil { 60 | return err 61 | } 62 | diff := zslice.Difference(have, ran) 63 | pending := "no pending migrations" 64 | if len(diff) > 0 { 65 | pending = fmt.Sprintf("pending migrations:\n\t%s", strings.Join(diff, "\n\t")) 66 | } 67 | 68 | if slices.Contains(f.Args, "list") { 69 | for i := range have { 70 | if slices.Contains(diff, have[i]) { 71 | have[i] = "pending: " + have[i] 72 | } 73 | } 74 | fmt.Fprintln(zli.Stdout, strings.Join(have, "\n")) 75 | return nil 76 | } 77 | 78 | if len(diff) > 0 { 79 | return errors.New(pending) 80 | } 81 | fmt.Fprintln(zli.Stdout, pending) 82 | return nil 83 | } 84 | 85 | return m.Run(f.Args...) 86 | } 87 | -------------------------------------------------------------------------------- /cron/location_stat.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "zgo.at/errors" 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func updateLocationStats(ctx context.Context, hits []goatcounter.Hit) error { 17 | return errors.Wrap(zdb.TX(ctx, func(ctx context.Context) error { 18 | type gt struct { 19 | count int 20 | day string 21 | location string 22 | pathID int64 23 | } 24 | grouped := map[string]gt{} 25 | for _, h := range hits { 26 | if h.Bot > 0 { 27 | continue 28 | } 29 | 30 | day := h.CreatedAt.Format("2006-01-02") 31 | k := day + h.Location + strconv.FormatInt(h.PathID, 10) 32 | v := grouped[k] 33 | if v.count == 0 { 34 | v.day = day 35 | v.location = h.Location 36 | v.pathID = h.PathID 37 | } 38 | 39 | // Call this for the side-effect of creating the rows in the 40 | // locations table. Should be the case in almost all codepaths, but 41 | // just to be sure. This is all cached, so there's very little 42 | // overhead. 43 | (&goatcounter.Location{}).ByCode(ctx, h.Location) 44 | 45 | if h.FirstVisit { 46 | v.count += 1 47 | } 48 | grouped[k] = v 49 | } 50 | 51 | siteID := goatcounter.MustGetSite(ctx).ID 52 | ins := zdb.NewBulkInsert(ctx, "location_stats", []string{"site_id", "day", 53 | "path_id", "location", "count"}) 54 | if zdb.SQLDialect(ctx) == zdb.DialectPostgreSQL { 55 | ins.OnConflict(`on conflict on constraint "location_stats#site_id#path_id#day#location" do update set 56 | count = location_stats.count + excluded.count`) 57 | } else { 58 | ins.OnConflict(`on conflict(site_id, path_id, day, location) do update set 59 | count = location_stats.count + excluded.count`) 60 | } 61 | 62 | for _, v := range grouped { 63 | if v.count > 0 { 64 | ins.Values(siteID, v.day, v.pathID, v.location, v.count) 65 | } 66 | } 67 | return ins.Finish() 68 | }), "cron.updateLocationStats") 69 | } 70 | -------------------------------------------------------------------------------- /public/i18n.css: -------------------------------------------------------------------------------- 1 | #i18n-header { padding: .5em 1em; background-color: #f8f8d9; border: 1px solid #dede89; border-radius: 2px; margin-bottom: 1em; } 2 | #i18n-controls label { margin-right: 1em; } 3 | 4 | #i18n-syntax { padding: .2em .5em; font-size: 14px; color: #000; background-color: #fff; box-shadow: 0 0 2px #aaa; } 5 | #i18n-syntax p { margin: 0; } 6 | #i18n-syntax ul { margin: 0; } 7 | #i18n-syntax li { margin-bottom: .5em; } 8 | 9 | .i18n-message { position: relative; margin-bottom: 1em; } 10 | .i18n-strings { clear: both; display: flex; align-items: center; } 11 | .i18n-original { position: relative; width: 35%; padding-right: .5em; white-space: pre-wrap; } 12 | .i18n-translate { position: relative; width: 65%; } 13 | .i18n-message textarea { height: 42px; } 14 | .has-plural textarea { margin: 0; margin-bottom: -1px; } 15 | 16 | .i18n-plural { margin-bottom: .5em; } 17 | .i18n-plural >div { display: flex; align-items: center; margin-left: 2em; } 18 | .i18n-plural label { width: 6em; } 19 | 20 | .i18n-id { position: absolute; font-size: 14px; right: .3em; top: -1.1em; background-color: #fff; } 21 | .has-plural .i18n-id { top: 0; } 22 | 23 | .i18n-info { display: none; z-index: 10; position: absolute; right: -1px; bottom: 100%; width: 30em; padding: .5em; 24 | background-color: #f6f6f6; outline: 1px solid #00f; box-shadow: 0 0 .2em #00f; border-radius: 2px; } 25 | .i18n-info p { margin: 0; } 26 | .i18n-info ul { list-style: none; margin: 0; padding: 0; } 27 | 28 | .i18n-status { display: none; position: absolute; left: -.7em; top: -.7em; } 29 | .i18n-untrans { color: #00f; } 30 | .i18n-unused { color: #00f; } 31 | .i18n-err { color: #f00; } 32 | .i18n-changed { color: #00f; } 33 | .i18n-ok { color: #0f0; } 34 | .i18n-message[data-status="untrans"] .i18n-untrans { display: block; } 35 | .i18n-message[data-status="unused"] .i18n-unused { display: block; } 36 | .i18n-message[data-status="err"] .i18n-err { display: block; } 37 | .i18n-message[data-status="changed"] .i18n-changed { display: block; } 38 | .i18n-message[data-status="ok"] .i18n-ok { display: block; } 39 | -------------------------------------------------------------------------------- /tpl/bosmang_bgrun.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | 3 |

Background jobs

4 | 5 |

Registered tasks

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{range $i, $t := .Tasks}} 15 | 16 | 17 | 18 | 19 | 23 | 24 | {{end}} 25 | 26 |
Run everyIDDescription
{{$t.Period}}{{$t.ID}}{{$t.Desc}}
20 | 21 | 22 |
27 | 28 |

Currently running

29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {{range $j := .Jobs}} 37 | 38 | 39 | 40 | 41 | 42 | {{else}} 43 | 44 | {{end}} 45 | 46 |
JobStarted fromStarted at
{{$j.Task}}{{$j.From}}{{$j.Started | ago}} ago
No jobs currently running.
47 | 48 |

Performance

49 | {{range $k, $v := .Metrics}} 50 |
{{$k}} (over last {{$v.Len}} invocations)
51 | 	Total:  {{$v.Sum | round_duration}}
52 | 	Min:    {{$v.Min | round_duration}}
53 | 	Max:    {{$v.Max | round_duration}}
54 | 	Median: {{$v.Median | round_duration}}
55 | 	Mean:   {{$v.Mean | round_duration}}
56 |     {{distribute_durations $v 4}}
57 | {{end}} 58 | 59 | 60 |

History

61 |

Last 10,000, most recent first.

62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | {{range $i, $_ := .History}} 73 | {{$j := index $.History (int (sub (len $.History) $i 1))}} 74 | 75 | 76 | 77 | 78 | 79 | 80 | {{else}} 81 | 82 | {{end}} 83 | 84 |
JobStarted fromNo duplicatesStarted atRun time
{{$j.Task}}{{$j.From}}{{$j.Started.Format "2006-01-02 15:04:05"}}{{round_duration $j.Took}}
No history.
85 | 86 | {{template "_backend_bottom.gohtml" .}} 87 | -------------------------------------------------------------------------------- /cmd/goatcounter/main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "fmt" 11 | "os" 12 | "strings" 13 | "sync" 14 | "testing" 15 | 16 | "zgo.at/blackmail" 17 | "zgo.at/goatcounter/v2" 18 | "zgo.at/goatcounter/v2/cron" 19 | "zgo.at/goatcounter/v2/gctest" 20 | "zgo.at/zli" 21 | "zgo.at/zlog" 22 | ) 23 | 24 | var pgSQL = false 25 | 26 | // Make sure usage doesn't contain tabs, as that will mess up formatting in 27 | // terminals. 28 | func TestUsageTabs(t *testing.T) { 29 | for k, v := range usage { 30 | if strings.Contains(v, "\t") { 31 | t.Errorf("%q contains tabs", k) 32 | } 33 | } 34 | } 35 | 36 | var mu sync.Mutex 37 | 38 | func startTest(t *testing.T) ( 39 | exit *zli.TestExit, in *bytes.Buffer, out *bytes.Buffer, 40 | ctx context.Context, dbc string, 41 | ) { 42 | t.Helper() 43 | 44 | blackmail.DefaultMailer = blackmail.NewMailer(blackmail.ConnectWriter) 45 | 46 | // TODO: should really have helper function in zlog. 47 | mu.Lock() 48 | logout := zli.Stdout 49 | zlog.Config.SetOutputs(func(l zlog.Log) { 50 | fmt.Fprintln(logout, zlog.Config.Format(l)) 51 | }) 52 | mu.Unlock() 53 | 54 | goatcounter.Memstore.Reset() 55 | 56 | ctx = gctest.DBFile(t) 57 | 58 | exit, in, out = zli.Test(t) 59 | return exit, in, out, ctx, os.Getenv("GCTEST_CONNECT") 60 | } 61 | 62 | func runCmdStop(t *testing.T, exit *zli.TestExit, ready chan<- struct{}, stop chan struct{}, cmd string, args ...string) { 63 | defer exit.Recover() 64 | defer cron.Stop() 65 | cmdMain(zli.NewFlags(append([]string{"goatcounter", cmd}, args...)), ready, stop) 66 | } 67 | 68 | func runCmd(t *testing.T, exit *zli.TestExit, cmd string, args ...string) { 69 | ready := make(chan struct{}, 1) 70 | stop := make(chan struct{}) 71 | runCmdStop(t, exit, ready, stop, cmd, args...) 72 | <-ready 73 | } 74 | 75 | func wantExit(t *testing.T, exit *zli.TestExit, out *bytes.Buffer, want int) { 76 | t.Helper() 77 | if int(*exit) != want { 78 | t.Errorf("wrong exit: %d; want: %d\n%s", *exit, want, out.String()) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /widgets/languages.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | "zgo.at/z18n" 13 | ) 14 | 15 | type Languages struct { 16 | id int 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | Limit int 23 | Stats goatcounter.HitStats 24 | } 25 | 26 | func (w Languages) Name() string { return "languages" } 27 | func (w Languages) Type() string { return "hchart" } 28 | func (w Languages) Label(ctx context.Context) string { 29 | return z18n.T(ctx, "label/language-stats|Language stats") 30 | } 31 | func (w *Languages) SetHTML(h template.HTML) { w.html = h } 32 | func (w Languages) HTML() template.HTML { return w.html } 33 | func (w *Languages) SetErr(h error) { w.err = h } 34 | func (w Languages) Err() error { return w.err } 35 | func (w Languages) ID() int { return w.id } 36 | func (w Languages) Settings() goatcounter.WidgetSettings { return w.s } 37 | 38 | func (w *Languages) SetSettings(s goatcounter.WidgetSettings) { 39 | w.s = s 40 | if x := s["limit"].Value; x != nil { 41 | w.Limit = int(x.(float64)) 42 | } 43 | } 44 | 45 | func (w *Languages) GetData(ctx context.Context, a Args) (more bool, err error) { 46 | err = w.Stats.ListLanguages(ctx, a.Rng, a.PathFilter, w.Limit, a.Offset) 47 | w.loaded = true 48 | return w.Stats.More, err 49 | } 50 | 51 | func (w Languages) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 52 | header := z18n.T(ctx, "header/languages|Languages") 53 | 54 | return "_dashboard_hchart.gohtml", struct { 55 | Context context.Context 56 | ID int 57 | RowsOnly bool 58 | HasSubMenu bool 59 | Loaded bool 60 | Err error 61 | IsCollected bool 62 | Header string 63 | TotalUTC int 64 | Stats goatcounter.HitStats 65 | }{ctx, w.id, shared.RowsOnly, false, w.loaded, w.err, isCol(ctx, goatcounter.CollectLanguage), 66 | header, shared.TotalUTC, w.Stats} 67 | } 68 | -------------------------------------------------------------------------------- /tpl/i18n_list.gohtml: -------------------------------------------------------------------------------- 1 | {{- template "_backend_top.gohtml" . -}} 2 | 3 | 4 |

GoatCounter translations

5 |

Instructions

6 |

Anyone can help translate GoatCounter; how this works:

7 |
    8 |
  • To test a new translation you can set it as active in the list below. If 9 | you edit an existing language changes will immediately show up.
  • 10 |
  • Feel free to edit this as much as you want, changes will show up for you 11 | only; nothing is changed for anyone else.
  • 12 |
  • To push changes click “send changes”, this will email me the file you’ve 13 | edited. e.g. it’s like sending a pull request.
  • 14 |
  • You can’t view what other people are working on at the moment; to 15 | prevent duplicate work especially for new translations or other large 16 | changes I encourage people to either 17 | create a tracking issue 18 | and/or email support@goatcounter.com 19 | first to coördinate things.
  • 20 |
21 | 22 |

Edit translations

23 |
    24 | {{range $f := .Files}} 25 |
  • {{index $f 1}} – 26 |
    27 | 28 | 29 | 30 |
    31 |
  • 32 | {{end}} 33 |
34 | 35 |

Create new

36 |
37 | 38 | 39 | 40 | 41 | As country code (i.e. 'de' for Germany) with optional region tag (i.e. 'de-DE' or 'de-AT' for Germany or Austria 42 |

43 | 44 |
45 | 46 | 47 | 48 | {{- template "_backend_bottom.gohtml" . }} 49 | -------------------------------------------------------------------------------- /public/script.js: -------------------------------------------------------------------------------- 1 | // Copyright © 2019 Martin Tournoij – This file is part of GoatCounter and 2 | // published under the terms of a slightly modified EUPL v1.2 license, which can 3 | // be found in the LICENSE file or at https://license.goatcounter.com 4 | 5 | (function() { 6 | var init = function() { 7 | setup_imgzoom() 8 | fill_code() 9 | fill_tz() 10 | } 11 | 12 | var setup_imgzoom = function() { 13 | var img = document.querySelectorAll('img.zoom'); 14 | for (var i=0; i 0) 43 | return; 44 | 45 | code.value = this.value. 46 | replace(/^www\./, ''). // www. 47 | replace(/\./g, '_'). // . -> _ 48 | replace(/[^a-zA-Z0-9_]/g, ''). // Remove anything else 49 | toLowerCase(); 50 | }, false); 51 | 52 | code.addEventListener('blur', function() { 53 | this.value = this.value.toLowerCase(); 54 | }, false); 55 | }; 56 | 57 | // Parse all query parameters from string to {k: v} object. 58 | var split_query = function(s) { 59 | s = s.substr(s.indexOf('?') + 1); 60 | if (s.length === 0) 61 | return {}; 62 | 63 | var split = s.split('&'), 64 | obj = {}; 65 | for (var i = 0; i < split.length; i++) { 66 | var item = split[i].split('='); 67 | obj[item[0]] = decodeURIComponent(item[1]); 68 | } 69 | return obj; 70 | }; 71 | 72 | if (document.readyState === 'complete') 73 | init(); 74 | else 75 | window.addEventListener('load', init, false); 76 | })(); 77 | -------------------------------------------------------------------------------- /tpl_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter_test 6 | 7 | import ( 8 | "io/fs" 9 | "os" 10 | "strings" 11 | "testing" 12 | 13 | "zgo.at/errors" 14 | . "zgo.at/goatcounter/v2" 15 | "zgo.at/goatcounter/v2/gctest" 16 | "zgo.at/zstd/zgo" 17 | "zgo.at/ztpl" 18 | ) 19 | 20 | func TestTpl(t *testing.T) { 21 | sp := func(s string) *string { return &s } 22 | ip := func(i int) *int { return &i } 23 | i64p := func(i int64) *int64 { return &i } 24 | 25 | ctx := gctest.Context(nil) 26 | site := Site{Code: "example"} 27 | user := User{Email: "a@example.com", EmailToken: sp("T-EMAIL"), LoginRequest: sp("T-LOGIN-REQ")} 28 | 29 | files, _ := fs.Sub(os.DirFS(zgo.ModuleRoot()), "tpl") 30 | err := ztpl.Init(files) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | errs := errors.NewGroup(4) 36 | errs.Append(errors.New("err: <1>")) 37 | errs.Append(errors.New("err: <2>")) 38 | errs.Append(errors.New("err: <3>")) 39 | errs.Append(errors.New("err: <4>")) 40 | errs.Append(errors.New("err: <5>")) 41 | 42 | tests := []struct { 43 | t interface{ Render() ([]byte, error) } 44 | }{ 45 | {TplEmailWelcome{ctx, site, user, "count.example.com"}}, 46 | {TplEmailForgotSite{ctx, []Site{site}, "test@example.com"}}, 47 | {TplEmailForgotSite{ctx, []Site{}, "test@example.com"}}, 48 | {TplEmailPasswordReset{ctx, site, user}}, 49 | {TplEmailVerify{ctx, site, user}}, 50 | {TplEmailImportError{ctx, errors.Unwrap(errors.New("oh noes"))}}, 51 | {TplEmailImportDone{ctx, site, 42, errors.NewGroup(10)}}, 52 | {TplEmailImportDone{ctx, site, 42, errs}}, 53 | {TplEmailAddUser{ctx, site, user, "foo@example.com"}}, 54 | 55 | {TplEmailExportDone{ctx, site, user, Export{ 56 | ID: 2, 57 | NumRows: ip(42), 58 | Size: sp("42"), 59 | LastHitID: i64p(642051), 60 | Hash: sp("sha256-AAA"), 61 | }}}, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run("", func(t *testing.T) { 66 | got, err := tt.t.Render() 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | want := "Cheers,\nMartin\n" 72 | if !strings.Contains(string(got), want) { 73 | t.Errorf("didn't contain %q", want) 74 | } 75 | 76 | t.Log("\n" + string(got)) 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /widgets/sizes.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | "zgo.at/z18n" 13 | ) 14 | 15 | type Sizes struct { 16 | id int 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | Limit int 23 | Detail string 24 | Stats goatcounter.HitStats 25 | } 26 | 27 | func (w Sizes) Name() string { return "sizes" } 28 | func (w Sizes) Type() string { return "hchart" } 29 | func (w Sizes) Label(ctx context.Context) string { return z18n.T(ctx, "label/size-stats|Size stats") } 30 | func (w *Sizes) SetHTML(h template.HTML) { w.html = h } 31 | func (w Sizes) HTML() template.HTML { return w.html } 32 | func (w *Sizes) SetErr(h error) { w.err = h } 33 | func (w Sizes) Err() error { return w.err } 34 | func (w Sizes) ID() int { return w.id } 35 | func (w Sizes) Settings() goatcounter.WidgetSettings { return w.s } 36 | 37 | func (w *Sizes) SetSettings(s goatcounter.WidgetSettings) { 38 | if x := s["key"].Value; x != nil { 39 | w.Detail = x.(string) 40 | } 41 | w.s = s 42 | } 43 | 44 | func (w *Sizes) GetData(ctx context.Context, a Args) (more bool, err error) { 45 | if w.Detail != "" { 46 | err = w.Stats.ListSize(ctx, w.Detail, a.Rng, a.PathFilter, 6, a.Offset) 47 | } else { 48 | err = w.Stats.ListSizes(ctx, a.Rng, a.PathFilter) 49 | } 50 | w.loaded = true 51 | return w.Stats.More, err 52 | } 53 | 54 | func (w Sizes) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 55 | return "_dashboard_hchart.gohtml", struct { 56 | Context context.Context 57 | ID int 58 | RowsOnly bool 59 | HasSubMenu bool 60 | Loaded bool 61 | Err error 62 | IsCollected bool 63 | Header string 64 | TotalUTC int 65 | Stats goatcounter.HitStats 66 | Detail string 67 | }{ctx, w.id, shared.RowsOnly, w.Detail == "", w.loaded, w.err, isCol(ctx, goatcounter.CollectScreenSize), 68 | z18n.T(ctx, "header/sizes|Sizes"), 69 | shared.TotalUTC, w.Stats, w.Detail} 70 | } 71 | -------------------------------------------------------------------------------- /handlers/dashboard_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package handlers 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "zgo.at/zstd/ztime" 12 | ) 13 | 14 | func TestDashboard(t *testing.T) { 15 | tests := []handlerTest{ 16 | { 17 | name: "no-data", 18 | router: newBackend, 19 | auth: true, 20 | wantCode: 200, 21 | wantBody: "No data received", 22 | }, 23 | } 24 | 25 | for _, tt := range tests { 26 | runTest(t, tt, nil) 27 | } 28 | } 29 | 30 | func TestTimeRange(t *testing.T) { 31 | tests := []struct { 32 | rng, now, wantStart, wantEnd string 33 | }{ 34 | {"week", "2020-12-02", 35 | "2020-11-25 00:00:00", "2020-12-02 23:59:59"}, 36 | {"month", "2020-01-18", 37 | "2019-12-18 00:00:00", "2020-01-18 23:59:59"}, 38 | {"quarter", "2020-01-18", 39 | "2019-10-18 00:00:00", "2020-01-18 23:59:59"}, 40 | {"half-year", "2020-01-18", 41 | "2019-07-18 00:00:00", "2020-01-18 23:59:59"}, 42 | {"year", "2020-01-18", 43 | "2019-01-18 00:00:00", "2020-01-18 23:59:59"}, 44 | 45 | // TODO: also test with sundayStartsWeek 46 | {"week-cur", "2020-01-01", 47 | "2019-12-30 00:00:00", "2020-01-05 23:59:59"}, 48 | 49 | {"month-cur", "2020-01-01", 50 | "2020-01-01 00:00:00", "2020-01-31 23:59:59"}, 51 | {"month-cur", "2020-01-31", 52 | "2020-01-01 00:00:00", "2020-01-31 23:59:59"}, 53 | 54 | {"0", "2020-06-18", 55 | "2020-06-18 00:00:00", "2020-06-18 23:59:59"}, 56 | {"1", "2020-06-18", 57 | "2020-06-17 00:00:00", "2020-06-18 23:59:59"}, 58 | {"42", "2020-06-18", 59 | "2020-05-07 00:00:00", "2020-06-18 23:59:59"}, 60 | } 61 | 62 | for _, tt := range tests { 63 | t.Run(tt.rng+"-"+tt.now, func(t *testing.T) { 64 | ztime.SetNow(t, tt.now) 65 | 66 | t.Run("UTC", func(t *testing.T) { 67 | rng := timeRange(tt.rng, time.UTC, false) 68 | gotStart := rng.Start.Format("2006-01-02 15:04:05") 69 | gotEnd := rng.End.Format("2006-01-02 15:04:05") 70 | 71 | if gotStart != tt.wantStart || gotEnd != tt.wantEnd { 72 | t.Errorf("\ngot: %q, %q\nwant: %q, %q", 73 | gotStart, gotEnd, tt.wantStart, tt.wantEnd) 74 | } 75 | }) 76 | 77 | // t.Run("Asia/Makassar", func(t *testing.T) { 78 | // }) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /widgets/toprefs.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | "zgo.at/z18n" 13 | ) 14 | 15 | type TopRefs struct { 16 | id int 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | Limit int 23 | Ref string 24 | TopRefs goatcounter.HitStats 25 | } 26 | 27 | func (w TopRefs) Name() string { return "toprefs" } 28 | func (w TopRefs) Type() string { return "hchart" } 29 | func (w TopRefs) Label(ctx context.Context) string { return z18n.T(ctx, "label/topref|Top referrals") } 30 | func (w *TopRefs) SetHTML(h template.HTML) { w.html = h } 31 | func (w TopRefs) HTML() template.HTML { return w.html } 32 | func (w *TopRefs) SetErr(h error) { w.err = h } 33 | func (w TopRefs) Err() error { return w.err } 34 | func (w TopRefs) ID() int { return w.id } 35 | func (w TopRefs) Settings() goatcounter.WidgetSettings { return w.s } 36 | 37 | func (w *TopRefs) SetSettings(s goatcounter.WidgetSettings) { 38 | if x := s["limit"].Value; x != nil { 39 | w.Limit = int(x.(float64)) 40 | } 41 | if x := s["key"].Value; x != nil { 42 | w.Ref = x.(string) 43 | } 44 | w.s = s 45 | } 46 | 47 | func (w *TopRefs) GetData(ctx context.Context, a Args) (more bool, err error) { 48 | if w.Ref != "" { 49 | err = w.TopRefs.ListTopRef(ctx, w.Ref, a.Rng, a.PathFilter, w.Limit, a.Offset) 50 | } else { 51 | err = w.TopRefs.ListTopRefs(ctx, a.Rng, a.PathFilter, w.Limit, a.Offset) 52 | } 53 | w.loaded = true 54 | return w.TopRefs.More, err 55 | } 56 | 57 | func (w TopRefs) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 58 | return "_dashboard_toprefs.gohtml", struct { 59 | Context context.Context 60 | ID int 61 | RowsOnly bool 62 | HasSubMenu bool 63 | Loaded bool 64 | Err error 65 | IsCollected bool 66 | Total int 67 | Stats goatcounter.HitStats 68 | Ref string 69 | }{ctx, w.id, shared.RowsOnly, w.Ref == "", w.loaded, w.err, isCol(ctx, goatcounter.CollectReferrer), 70 | shared.Total, w.TopRefs, w.Ref} 71 | } 72 | -------------------------------------------------------------------------------- /widgets/systems.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | "zgo.at/z18n" 13 | ) 14 | 15 | type Systems struct { 16 | id int 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | Limit int 23 | Detail string 24 | Stats goatcounter.HitStats 25 | } 26 | 27 | func (w Systems) Name() string { return "systems" } 28 | func (w Systems) Type() string { return "hchart" } 29 | func (w Systems) Label(ctx context.Context) string { 30 | return z18n.T(ctx, "label/system-stats|System stats") 31 | } 32 | func (w *Systems) SetHTML(h template.HTML) { w.html = h } 33 | func (w Systems) HTML() template.HTML { return w.html } 34 | func (w *Systems) SetErr(h error) { w.err = h } 35 | func (w Systems) Err() error { return w.err } 36 | func (w Systems) ID() int { return w.id } 37 | func (w Systems) Settings() goatcounter.WidgetSettings { return w.s } 38 | 39 | func (w *Systems) SetSettings(s goatcounter.WidgetSettings) { 40 | if x := s["limit"].Value; x != nil { 41 | w.Limit = int(x.(float64)) 42 | } 43 | if x := s["key"].Value; x != nil { 44 | w.Detail = x.(string) 45 | } 46 | w.s = s 47 | } 48 | 49 | func (w *Systems) GetData(ctx context.Context, a Args) (more bool, err error) { 50 | if w.Detail != "" { 51 | err = w.Stats.ListSystem(ctx, w.Detail, a.Rng, a.PathFilter, w.Limit, a.Offset) 52 | } else { 53 | err = w.Stats.ListSystems(ctx, a.Rng, a.PathFilter, w.Limit, a.Offset) 54 | } 55 | w.loaded = true 56 | return w.Stats.More, err 57 | } 58 | 59 | func (w Systems) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 60 | return "_dashboard_hchart.gohtml", struct { 61 | Context context.Context 62 | ID int 63 | RowsOnly bool 64 | HasSubMenu bool 65 | Loaded bool 66 | Err error 67 | IsCollected bool 68 | Header string 69 | TotalUTC int 70 | Stats goatcounter.HitStats 71 | Detail string 72 | }{ctx, w.id, shared.RowsOnly, w.Detail == "", w.loaded, w.err, isCol(ctx, goatcounter.CollectUserAgent), 73 | z18n.T(ctx, "header/systems|Systems"), 74 | shared.TotalUTC, w.Stats, w.Detail} 75 | } 76 | -------------------------------------------------------------------------------- /tpl/signup.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_top.gohtml" .}} 2 | 3 |

Sign up for GoatCounter

4 | 5 |

You can use an account on any number of sites/domains; see Settings 6 | → sites for separating them out.

7 | 8 |
9 |
10 |
11 |
12 |
13 | 14 | 15 | {{validate "site.code" .Validate}} 16 | You will access your account at https://[my-code].{{.Domain}}. 17 |
18 |
19 | 20 | 21 | {{validate "site.link_domain" .Validate}} 22 | Your site’s domain, used for display/linking; optional. 23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 | 33 | {{validate "user.email" .Validate}} 34 | For password resets, important announcements, invoices. 35 |
36 |
37 | 38 | 39 | {{validate "user.password" .Validate}} 40 | Needs at least 8 characters. 41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 | 49 | 50 | {{validate "turing_test" .Validate}} 51 | Just a little verification that you’re human :-) 52 |
53 |
54 |
55 | 56 | 57 | 58 |
59 | 60 | {{if has_errors .Validate}} 61 |
63 | {{.T "p/additional-errors|Additional errors"}}:{{.Validate.HTML}}
64 | {{end}} 65 |
66 | 67 | {{template "_bottom.gohtml" .}} 68 | -------------------------------------------------------------------------------- /widgets/browsers.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | 11 | "zgo.at/goatcounter/v2" 12 | "zgo.at/z18n" 13 | ) 14 | 15 | type Browsers struct { 16 | id int 17 | loaded bool 18 | err error 19 | html template.HTML 20 | s goatcounter.WidgetSettings 21 | 22 | Limit int 23 | Detail string 24 | Stats goatcounter.HitStats 25 | } 26 | 27 | func (w Browsers) Name() string { return "browsers" } 28 | func (w Browsers) Type() string { return "hchart" } 29 | func (w Browsers) Label(ctx context.Context) string { 30 | return z18n.T(ctx, "label/browser-stats|Browser stats") 31 | } 32 | func (w *Browsers) SetHTML(h template.HTML) { w.html = h } 33 | func (w Browsers) HTML() template.HTML { return w.html } 34 | func (w *Browsers) SetErr(h error) { w.err = h } 35 | func (w Browsers) Err() error { return w.err } 36 | func (w Browsers) ID() int { return w.id } 37 | func (w Browsers) Settings() goatcounter.WidgetSettings { return w.s } 38 | 39 | func (w *Browsers) SetSettings(s goatcounter.WidgetSettings) { 40 | if x := s["limit"].Value; x != nil { 41 | w.Limit = int(x.(float64)) 42 | } 43 | if x := s["key"].Value; x != nil { 44 | w.Detail = x.(string) 45 | } 46 | w.s = s 47 | } 48 | 49 | func (w *Browsers) GetData(ctx context.Context, a Args) (more bool, err error) { 50 | if w.Detail != "" { 51 | err = w.Stats.ListBrowser(ctx, w.Detail, a.Rng, a.PathFilter, w.Limit, a.Offset) 52 | } else { 53 | err = w.Stats.ListBrowsers(ctx, a.Rng, a.PathFilter, w.Limit, a.Offset) 54 | } 55 | w.loaded = true 56 | return w.Stats.More, err 57 | } 58 | 59 | func (w Browsers) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 60 | return "_dashboard_hchart.gohtml", struct { 61 | Context context.Context 62 | ID int 63 | RowsOnly bool 64 | HasSubMenu bool 65 | Loaded bool 66 | Err error 67 | IsCollected bool 68 | Header string 69 | TotalUTC int 70 | Stats goatcounter.HitStats 71 | Detail string 72 | }{ctx, w.id, shared.RowsOnly, w.Detail == "", w.loaded, w.err, isCol(ctx, goatcounter.CollectUserAgent), 73 | z18n.T(ctx, "header/browsers|Browsers"), 74 | shared.TotalUTC, w.Stats, w.Detail} 75 | } 76 | -------------------------------------------------------------------------------- /bosmang.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package goatcounter 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "time" 11 | 12 | "zgo.at/errors" 13 | "zgo.at/zcache" 14 | "zgo.at/zdb" 15 | "zgo.at/zstd/zjson" 16 | "zgo.at/zstd/zruntime" 17 | ) 18 | 19 | type BosmangStat struct { 20 | ID int64 `db:"site_id"` 21 | Codes string `db:"codes"` 22 | Email string `db:"email"` 23 | CreatedAt time.Time `db:"created_at"` 24 | LastMonth int `db:"last_month"` 25 | Total int `db:"total"` 26 | Avg int `db:"avg"` 27 | } 28 | 29 | type BosmangStats []BosmangStat 30 | 31 | // List stats for all sites, for all time. 32 | func (a *BosmangStats) List(ctx context.Context) error { 33 | err := zdb.Select(ctx, a, "load:bosmang.List") 34 | if err != nil { 35 | return errors.Wrap(err, "BosmangStats.List") 36 | } 37 | return nil 38 | } 39 | 40 | func ListCache(ctx context.Context) map[string]struct { 41 | Size int64 42 | Items map[string]string 43 | } { 44 | c := make(map[string]struct { 45 | Size int64 46 | Items map[string]string 47 | }) 48 | 49 | caches := map[string]func(context.Context) *zcache.Cache{ 50 | "sites": cacheSites, 51 | "ua": cacheUA, 52 | "browsers": cacheBrowsers, 53 | "systems": cacheSystems, 54 | "paths": cachePaths, 55 | "loc": cacheLoc, 56 | "changed_titles": cacheChangedTitles, 57 | //"loader": handler.loader.conns, 58 | } 59 | 60 | for name, f := range caches { 61 | var ( 62 | content = f(ctx).Items() 63 | s = zruntime.SizeOf(content) 64 | items = make(map[string]string) 65 | ) 66 | for k, v := range content { 67 | items[k] = fmt.Sprintf("%s\n", zjson.MustMarshalIndent(v.Object, "", " ")) 68 | s += c[name].Size + zruntime.SizeOf(v.Object) 69 | } 70 | c[name] = struct { 71 | Size int64 72 | Items map[string]string 73 | }{s / 1024, items} 74 | } 75 | 76 | { 77 | var ( 78 | name = "sites_host" 79 | content = cacheSitesHost(ctx).Items() 80 | s = zruntime.SizeOf(content) 81 | items = make(map[string]string) 82 | ) 83 | for k, v := range content { 84 | items[k] = v 85 | s += c[name].Size + zruntime.SizeOf(v) 86 | } 87 | c[name] = struct { 88 | Size int64 89 | Items map[string]string 90 | }{s / 1024, items} 91 | } 92 | return c 93 | } 94 | -------------------------------------------------------------------------------- /cron/browser_stat_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package cron_test 6 | 7 | import ( 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/goatcounter/v2/gctest" 14 | "zgo.at/zstd/ztime" 15 | ) 16 | 17 | func TestBrowserStats(t *testing.T) { 18 | ctx := gctest.DB(t) 19 | 20 | site := goatcounter.MustGetSite(ctx) 21 | now := time.Date(2019, 8, 31, 14, 42, 0, 0, time.UTC) 22 | 23 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 24 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/68.0", FirstVisit: true}, 25 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Chrome/77.0.123.666"}, 26 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/69.0"}, 27 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/69.0"}, 28 | }...) 29 | 30 | var stats goatcounter.HitStats 31 | err := stats.ListBrowsers(ctx, ztime.NewRange(now).To(now), nil, 10, 0) 32 | if err != nil { 33 | t.Fatal(err) 34 | } 35 | 36 | want := `{false [{ Firefox 1 }]}` 37 | out := fmt.Sprintf("%v", stats) 38 | if want != out { 39 | t.Errorf("\nwant: %s\nout: %s", want, out) 40 | } 41 | 42 | // Update existing. 43 | gctest.StoreHits(ctx, t, false, []goatcounter.Hit{ 44 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/69.0", FirstVisit: true}, 45 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/69.0"}, 46 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/70.0"}, 47 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Firefox/70.0"}, 48 | {Site: site.ID, CreatedAt: now, UserAgentHeader: "Chrome/77.0.123.666", FirstVisit: true}, 49 | }...) 50 | 51 | stats = goatcounter.HitStats{} 52 | err = stats.ListBrowsers(ctx, ztime.NewRange(now).To(now), nil, 10, 0) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | want = `{false [{ Firefox 2 } { Chrome 1 }]}` 58 | out = fmt.Sprintf("%v", stats) 59 | if want != out { 60 | t.Errorf("\nwant: %s\nout: %s", want, out) 61 | } 62 | 63 | // List just Firefox. 64 | stats = goatcounter.HitStats{} 65 | err = stats.ListBrowser(ctx, "Firefox", ztime.NewRange(now).To(now), nil, 10, 0) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | want = `{false [{ Firefox 68 1 } { Firefox 69 1 }]}` 71 | out = fmt.Sprintf("%v", stats) 72 | if want != out { 73 | t.Errorf("\nwant: %s\nout: %s", want, out) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tpl/settings_delete.gohtml: -------------------------------------------------------------------------------- 1 | {{template "_backend_top.gohtml" .}} 2 | {{template "_settings_nav.gohtml" .}} 3 | 4 |

{{.T "header/delete-account|Delete account"}}

5 | 6 | {{if gt (len .Sites) 1}} 7 |

{{.T `p/delete-account-multi-site| 8 | The site and all associated sites will be marked as deleted and will no 9 | longer be accessible, but no data is removed. After 7 days all data will be 10 | permanently removed. 11 | `}}

12 | {{else}} 13 |

{{.T `p/delete-account-one-site| 14 | The site will be marked as deleted and will no longer be accessible, but no 15 | data is removed. After 7 days all data will be permanently removed. 16 | `}}

17 | {{end}} 18 | 19 | {{if gt (len .Sites) 1}} 20 |
21 |

{{.T "p/notify-site-deletion|%(number) sites will be deleted" (len .Sites)}}:

22 |
    {{range $s := .Sites}} 23 |
  • {{$s.Domain $.Context}}
  • 24 | {{end}}
25 |
26 | {{end}} 27 | 28 |
30 | 31 | 32 |
37 |

38 | 39 |
40 |
{{.T `label/delete-account-contact| 41 | I might contact you with some follow-up questions or commentary if you 42 | check this. I won’t try to convince you to stay (I’m not a telecom), but 43 | I might ask a question or two, or outline future plans if you’re missing 44 | a particular feature. 45 | `}}

46 | 47 | 48 |
49 | {{.T "p/request-data-recovery|%[Contact] within 7 days if you changed your mind and want to recover your data." 50 | (tag "a" `href="/contact"`) 51 | }}

52 | 53 | {{template "_backend_bottom.gohtml" .}} 54 | -------------------------------------------------------------------------------- /tpl/help/path.md: -------------------------------------------------------------------------------- 1 | Sometimes you want to send a different path to GoatCounter than what appears in 2 | the browser's URL bar; removing one or more query parameters are a common 3 | scenario. 4 | 5 | Using a canonical URL 6 | --------------------- 7 | The easiest way to ensure that `/path` always shows up as `/path` is to add a 8 | canonical URL in the ``: 9 | 10 | 11 | 12 | The `href` can also be relative (e.g. `/path`). 13 | 14 | This will only work if the canonical URL is on the same domain (with allowance 15 | for the `www` subdomain); for example setting the canonical URL to: 16 | 17 | 18 | 19 | And then loading this page as `https://example.com/path` will mean GoatCounter 20 | just ignore this value. This is because some people publish things on multiple 21 | sites and then point at one as "canonical". This can be good for SEO, but not 22 | good for tracking things in GoatCounter. 23 | 24 | Be sure to understand the potential SEO effects before adding a canonical URL; 25 | if you use query parameters for navigation then you probably *don’t* want to do 26 | this. 27 | 28 | Using data-goatcounter-settings 29 | -------------------------------- 30 | You can use `data-goatcounter-settings` on the script tag to set the path; this 31 | must be valid JSON: 32 | 33 | 36 | 37 | You can also set the `title`, `referrer`, and `event` in here. 38 | 39 | Using window.goatcounter 40 | ------------------------ 41 | Alternatively you can send a custom `path` by setting `window.goatcounter` 42 | *before* the `count.js` script loads: 43 | 44 | 49 | {{template "code" .}} 50 | 51 | This is useful if you want some more complex logic, for example to add some 52 | individual query parameters with `goatcounter.get_query()`: 53 | 54 | 61 | {{template "code" .}} 62 | 63 | Note this example uses a callback, since `goatcouner.get_query()` won't be 64 | defined yet if we just used an object. 65 | 66 | See the [JavaScript API](/code/js) page for more details JS API. 67 | -------------------------------------------------------------------------------- /tpl/help/countjs-versions.md: -------------------------------------------------------------------------------- 1 | For most people `{{.CountDomain}}/count.js` should be fine, but there are also 2 | stable versions if you want to use subresource integrity (SRI). This will verify 3 | the integrity of the script to ensure there are no changes, and browsers will 4 | refuse to run it if there are. 5 | 6 | You won’t get any updates, with this – the versioned script will always remain 7 | the same. Any existing version of `count.js` is guaranteed to remain compatible, 8 | but you may need to update it in the future for new features. 9 | 10 | Latest 11 | ------ 12 | - Only use `navigator.sendBeacon`, removing the ``-based fallback. 13 | 14 | v4 (8 Dec 2023) 15 | --------------- 16 | 20 | 21 | - Use `navigator.sendBeacon` when available. 22 | 23 | v3 (1 Dec 2021) 24 | --------------- 25 | 29 | 30 | - Support `start` and `end` in the visitor counter. 31 | - Update localhost filter to include `0.0.0.0` 32 | - Tabs opened in the background didn't always get accounted for (see #487 for 33 | details). 34 | - Remove the timeout; this was already increased from 3 to 10 seconds in v2. 35 | Tracking will now happen no matter how long it takes for the page to load. 36 | 37 | 38 | v2 (11 Mar 2021) 39 | ---------------- 40 | 44 | 45 | - Allow loading settings from `data-goatcounter-settings` on the `script` tag. 46 | - Increase timeout from 3 seconds to 10 seconds. 47 | - Add braces around `if` since some minifiers can't deal with "dangling else" 48 | well (the code is correct, it's the minifier that's broken). 49 | 50 | 51 | v1 (25 Dec 2020) 52 | ---------------- 53 | 57 | 58 | - Initial stable version. 59 | -------------------------------------------------------------------------------- /widgets/campaigns.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package widgets 6 | 7 | import ( 8 | "context" 9 | "html/template" 10 | "strconv" 11 | 12 | "zgo.at/goatcounter/v2" 13 | "zgo.at/z18n" 14 | ) 15 | 16 | type Campaigns struct { 17 | id int 18 | loaded bool 19 | err error 20 | html template.HTML 21 | s goatcounter.WidgetSettings 22 | 23 | Limit int 24 | Campaign int64 25 | Stats goatcounter.HitStats 26 | } 27 | 28 | func (w Campaigns) Name() string { return "campaigns" } 29 | func (w Campaigns) Type() string { return "hchart" } 30 | func (w Campaigns) Label(ctx context.Context) string { return z18n.T(ctx, "label/campaigns|Campaigns") } 31 | func (w *Campaigns) SetHTML(h template.HTML) { w.html = h } 32 | func (w Campaigns) HTML() template.HTML { return w.html } 33 | func (w *Campaigns) SetErr(h error) { w.err = h } 34 | func (w Campaigns) Err() error { return w.err } 35 | func (w Campaigns) ID() int { return w.id } 36 | func (w Campaigns) Settings() goatcounter.WidgetSettings { return w.s } 37 | 38 | func (w *Campaigns) SetSettings(s goatcounter.WidgetSettings) { 39 | w.s = s 40 | if x := s["limit"].Value; x != nil { 41 | w.Limit = int(x.(float64)) 42 | } 43 | if x := s["key"].Value; x != nil { 44 | w.Campaign, _ = strconv.ParseInt(x.(string), 10, 64) 45 | } 46 | } 47 | 48 | func (w *Campaigns) GetData(ctx context.Context, a Args) (more bool, err error) { 49 | if w.Campaign > 0 { 50 | err = w.Stats.ListCampaign(ctx, w.Campaign, a.Rng, a.PathFilter, w.Limit, a.Offset) 51 | } else { 52 | err = w.Stats.ListCampaigns(ctx, a.Rng, a.PathFilter, w.Limit, a.Offset) 53 | } 54 | w.loaded = true 55 | return w.Stats.More, err 56 | } 57 | 58 | func (w Campaigns) RenderHTML(ctx context.Context, shared SharedData) (string, any) { 59 | //return "_dashboard_campaigns.gohtml", struct { 60 | return "_dashboard_hchart.gohtml", struct { 61 | Context context.Context 62 | ID int 63 | RowsOnly bool 64 | HasSubMenu bool 65 | Loaded bool 66 | Err error 67 | IsCollected bool 68 | Header string 69 | TotalUTC int 70 | 71 | Stats goatcounter.HitStats 72 | Campaign int64 73 | }{ctx, w.id, shared.RowsOnly, w.Campaign == 0, w.loaded, w.err, isCol(ctx, goatcounter.CollectReferrer), w.Label(ctx), 74 | shared.TotalUTC, w.Stats, w.Campaign} 75 | } 76 | -------------------------------------------------------------------------------- /handlers/website_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © Martin Tournoij – This file is part of GoatCounter and published 2 | // under the terms of a slightly modified EUPL v1.2 license, which can be found 3 | // in the LICENSE file or at https://license.goatcounter.com 4 | 5 | package handlers 6 | 7 | import ( 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/go-chi/chi/v5" 13 | "zgo.at/zdb" 14 | ) 15 | 16 | func newWebsite(db zdb.DB) chi.Router { return NewWebsite(db, true) } 17 | 18 | func TestWebsiteTpl(t *testing.T) { 19 | tests := []struct { 20 | path, want string 21 | }{ 22 | {"/", "doesn’t track users with"}, 23 | {"/help/privacy", "Screen size"}, 24 | {"/help/terms", "The “services” are any software, application, product, or service"}, 25 | {"/why", "Footnotes"}, 26 | {"/design", "Firefox on iOS is just displayed as Safari"}, 27 | {"/help/translating", "translate GoatCounter"}, 28 | {"/status", "uptime"}, 29 | {"/signup", ``}, 30 | {"/user/forgot", "Forgot domain"}, 31 | 32 | {"/help/start", "Getting started"}, 33 | 34 | // Shared 35 | 36 | // rdr 37 | // {"/api", "Backend integration"}, 38 | 39 | //{"/help", "I don’t see my pageviews?"}, 40 | {"/help/gdpr", "consult a lawyer"}, 41 | {"/contact", "Send message"}, 42 | {"/contribute", "Contribute"}, 43 | {"/api.html", "Endpoints"}, 44 | {"/api2.html", "