├── Dockerfile ├── Dockerfile.build ├── LICENSE ├── README.md ├── config.go ├── email ├── footer.html ├── forgot.html └── header.html ├── entrypoint.sh ├── event.go ├── event_easyjson.go ├── fileman.go ├── frequency-linux-amd64 ├── handlers.go ├── mailer.go ├── main.go ├── screenshot1.png ├── stat.go ├── static ├── Chart.min.js ├── favicon.png ├── fonts │ ├── B612-Regular.ttf │ ├── Roboto-Black.ttf │ ├── Roboto-BlackItalic.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-BoldItalic.ttf │ ├── Roboto-Italic.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-LightItalic.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-MediumItalic.ttf │ ├── Roboto-Regular.ttf │ ├── Roboto-Thin.ttf │ └── Roboto-ThinItalic.ttf ├── jquery.min.js ├── moment.min.js ├── roboto.css ├── script.js ├── semantic │ ├── semantic.min.css │ ├── semantic.min.js │ └── themes │ │ └── default │ │ └── assets │ │ ├── fonts │ │ ├── brand-icons.eot │ │ ├── brand-icons.svg │ │ ├── brand-icons.ttf │ │ ├── brand-icons.woff │ │ ├── brand-icons.woff2 │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ ├── icons.woff2 │ │ ├── outline-icons.eot │ │ ├── outline-icons.svg │ │ ├── outline-icons.ttf │ │ ├── outline-icons.woff │ │ └── outline-icons.woff2 │ │ └── images │ │ └── flags.png └── style.css ├── templates ├── configure.html ├── domain.html ├── footer.html ├── forgot.html ├── header.html ├── help.html ├── index.html ├── property │ ├── dashboard.html │ ├── delete.html │ ├── events.html │ ├── pages.html │ ├── platforms.html │ ├── referrers.html │ ├── settings.html │ ├── snippet.html │ └── sources.html ├── settings.html └── signin.html ├── utils.go └── web.go /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM phusion/baseimage:0.11 2 | MAINTAINER github.com/frequencyanalytics/frequency 3 | 4 | COPY frequency-linux-amd64 /usr/bin/frequency 5 | COPY entrypoint.sh /usr/local/bin/entrypoint.sh 6 | 7 | ENV DEBIAN_FRONTEND noninteractive 8 | 9 | RUN apt-get update && apt-get install -y tzdata 10 | 11 | RUN chmod +x /usr/bin/frequency /usr/local/bin/entrypoint.sh 12 | 13 | ENTRYPOINT [ "/usr/local/bin/entrypoint.sh" ] 14 | 15 | CMD [ "/sbin/my_init" ] 16 | -------------------------------------------------------------------------------- /Dockerfile.build: -------------------------------------------------------------------------------- 1 | FROM golang:1.12.5 2 | MAINTAINER github.com/frequencyanalytics/frequency 3 | 4 | RUN apt-get update \ 5 | && apt-get install -y git \ 6 | && rm -rf /var/lib/apt/lists/* 7 | 8 | WORKDIR /go/src/github.com/frequencyanalytics/frequency 9 | 10 | RUN go get -v \ 11 | github.com/jteeuwen/go-bindata/... \ 12 | github.com/dustin/go-humanize \ 13 | github.com/julienschmidt/httprouter \ 14 | github.com/Sirupsen/logrus \ 15 | github.com/gorilla/securecookie \ 16 | golang.org/x/crypto/acme/autocert \ 17 | golang.org/x/time/rate \ 18 | golang.org/x/crypto/bcrypt \ 19 | go.uber.org/zap \ 20 | gopkg.in/gomail.v2 \ 21 | golang.org/x/net/publicsuffix \ 22 | github.com/oschwald/geoip2-golang \ 23 | github.com/mssola/user_agent \ 24 | github.com/mailru/easyjson \ 25 | github.com/mailru/easyjson/... \ 26 | github.com/crewjam/saml \ 27 | github.com/dgrijalva/jwt-go 28 | 29 | COPY *.go ./ 30 | COPY static ./static 31 | COPY templates ./templates 32 | COPY email ./email 33 | 34 | ARG BUILD_VERSION=unknown 35 | 36 | ENV GODEBUG="netdns=go http2server=0" 37 | ENV GOPATH="/go" 38 | 39 | RUN go-bindata --pkg main static/... templates/... email/... \ 40 | && go fmt \ 41 | && go vet --all 42 | 43 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 44 | go build -v --compiler gc --ldflags "-extldflags -static -s -w -X main.version=${BUILD_VERSION}" -o /usr/bin/frequency-linux-amd64 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 viewscreen Cloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frequency Analytics - Open source private web analytics server 2 | 3 | Frequency Analytics is an open source web analytics tool that tracks and reports website traffic to help you measure visits, referrals, and trends for your website. After installing Frequency Analytics, just add a snippet of javascript to every page of your website to enable tracking. The javascript tracking code runs when a user browses the page and sends visitor data to your private Frequency Analytics server. 4 | 5 | ![Screenshot - Dashboard](https://raw.githubusercontent.com/frequencyanalytics/frequency/master/screenshot1.png) 6 | 7 | ## Features 8 | 9 | * **User Privacy** 10 | * Host your own web analytics as an alternative to sharing your user data with third-party analytics services. 11 | * **No Browser Cookies** 12 | * The javascript tracking code does not rely on browser cookies. 13 | * **No Data Limits** 14 | * There are no artificial pageview limits. Track as many pageviews from as many websites as you want. 15 | * **Daily Visitors** 16 | * Daily visitors to your site over time. 17 | * **Traffic Sources** 18 | * Sources of traffic to your site by category: direct, search, social, and other. 19 | * **Pageviews** 20 | * Hits to each page on your site. 21 | * **Referrers** 22 | * Which websites are sending you the most traffic. 23 | * **Platforms** 24 | * Pageviews by user operating system. 25 | * **Events** 26 | * Detailed list of site events. 27 | * **Single Sign-On (SSO) with SAML** 28 | * Support for SAML providers like G Suite and Okta. 29 | 30 | ## Run Frequency Analytics on Portal Cloud 31 | 32 | Portal Cloud is a hosting service that enables anyone to run open source cloud applications. 33 | 34 | [Sign up for Portal Cloud](https://portal.cloud/) and get $15 free credit. 35 | 36 | ## Run Frequency Analytics on a VPS 37 | 38 | Running Frequency Analytics on a VPS is designed to be as simple as possible. 39 | 40 | * Public Docker image 41 | * Single static Go binary with assets bundled 42 | * Automatic TLS using Let's Encrypt 43 | * Redirects http to https 44 | * No database required 45 | 46 | ### 1. Get a server 47 | 48 | **Recommended Specs** 49 | 50 | * Type: VPS or dedicated 51 | * Distribution: Ubuntu 16.04 (Xenial) 52 | * Memory: 512MB or greater 53 | 54 | ### 2. Add a DNS record 55 | 56 | Create a DNS record for your domain that points to your server's IP address. 57 | 58 | **Example:** `frequency.example.com A 172.x.x.x` 59 | 60 | ### 3. Enable Let's Encrypt 61 | 62 | Frequency Analytics runs a TLS ("SSL") https server on port 443/tcp. It also runs a standard web server on port 80/tcp to redirect clients to the secure server. Port 80/tcp is required for Let's Encrypt verification. 63 | 64 | **Requirements** 65 | 66 | * Your server must have a publicly resolvable DNS record. 67 | * Your server must be reachable over the internet on ports 80/tcp and 443/tcp. 68 | 69 | ### Usage 70 | 71 | **Example usage:** 72 | 73 | ```bash 74 | # Download the frequency binary. 75 | $ sudo wget -O /usr/bin/frequency https://github.com/frequencyanalytics/frequency/raw/master/frequency-linux-amd64 76 | 77 | # Make it executable. 78 | $ sudo chmod +x /usr/bin/frequency 79 | 80 | # Allow it to bind to privileged ports 80 and 443. 81 | $ sudo setcap cap_net_bind_service=+ep /usr/bin/frequency 82 | 83 | $ frequency --http-host frequency.example.com 84 | ``` 85 | 86 | ### Arguments 87 | 88 | ```bash 89 | -backlink string 90 | backlink (optional) 91 | -compress-old-files 92 | compress files for past days 93 | -cpuprofile file 94 | write cpu profile to file 95 | -datadir string 96 | data dir (default "/data") 97 | -debug 98 | debug mode 99 | -delete-old-files 100 | delete oldest files when storage exceeds 95% full (default true) 101 | -help 102 | display help and exit 103 | -http-host string 104 | HTTP host 105 | -memprofile file 106 | write mem profile to file 107 | -version 108 | display version and exit 109 | 110 | 111 | ``` 112 | ### Run as a Docker container 113 | 114 | The official image is `frequencyanalytics/frequency`. 115 | 116 | Follow the official Docker install instructions: [Get Docker CE for Ubuntu](https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/) 117 | 118 | Make sure to change the `--env FREQUENCY_HTTP_HOST` to your publicly accessible domain name. 119 | 120 | ```bash 121 | 122 | # Your data directory must be bind-mounted as `/data` inside the container using the `--volume` flag. 123 | # Create a data directoy 124 | $ mkdir /data 125 | 126 | docker create \ 127 | --name frequency \ 128 | --restart always \ 129 | --volume /data:/data \ 130 | --network host \ 131 | --env FREQUENCY_HTTP_HOST=frequency.example.com \ 132 | frequencyanalytics/frequency:latest 133 | 134 | $ sudo docker start frequency 135 | 136 | $ sudo docker logs frequency 137 | 138 | 139 | 140 | ``` 141 | 142 | #### Updating the container image 143 | 144 | Pull the latest image, remove the container, and re-create the container as explained above. 145 | 146 | ```bash 147 | # Pull the latest image 148 | $ sudo docker pull frequencyanalytics/frequency 149 | 150 | # Stop the container 151 | $ sudo docker stop frequency 152 | 153 | # Remove the container (data is stored on the mounted volume) 154 | $ sudo docker rm frequency 155 | 156 | # Re-create and start the container 157 | $ sudo docker create ... (see above) 158 | ``` 159 | 160 | ## Help / Reporting Bugs 161 | 162 | Email support@portal.cloud 163 | 164 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/json" 9 | "encoding/pem" 10 | "errors" 11 | "fmt" 12 | "io/ioutil" 13 | "math/big" 14 | "os" 15 | "path/filepath" 16 | "sort" 17 | "sync" 18 | "time" 19 | ) 20 | 21 | var ( 22 | ErrPropertyNotFound = errors.New("property not found") 23 | ) 24 | 25 | type Property struct { 26 | ID string `json:"id"` 27 | Name string `json:"name"` 28 | Domain string `json:"domain"` 29 | Created time.Time `json:"created"` 30 | } 31 | 32 | type Info struct { 33 | Email string `json:"email"` 34 | Password []byte `json:"password"` 35 | Secret string `json:"secret"` 36 | Configured bool `json:"configure"` 37 | Domain string `json:"domain"` 38 | HashKey string `json:"hash_key"` 39 | BlockKey string `json:"block_key"` 40 | Location string `json:"location"` 41 | SAML struct { 42 | IDPMetadata string `json:"idp_metadata"` 43 | PrivateKey []byte `json:"private_key"` 44 | Certificate []byte `json:"certificate"` 45 | } `json:"saml"` 46 | Mail struct { 47 | From string `json:"from"` 48 | Server string `json:"server"` 49 | Port int `json:"port"` 50 | Username string `json:"username"` 51 | Password string `json:"password"` 52 | } `json:"mail"` 53 | } 54 | 55 | type Config struct { 56 | mu sync.RWMutex 57 | filename string 58 | 59 | Info *Info `json:"info"` 60 | 61 | Properties []*Property `json:"properties"` 62 | 63 | Modified time.Time `json:"modified"` 64 | } 65 | 66 | func NewConfig(filename string) (*Config, error) { 67 | filename = filepath.Join(datadir, filename) 68 | c := &Config{filename: filename} 69 | b, err := ioutil.ReadFile(filename) 70 | 71 | // Create new config with defaults 72 | if os.IsNotExist(err) { 73 | c.Info = &Info{ 74 | HashKey: randomString(32), 75 | BlockKey: randomString(32), 76 | Location: "UTC", 77 | } 78 | return c, c.generateSAMLKeyPair() 79 | } 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | // Open existing config 85 | if err := json.Unmarshal(b, c); err != nil { 86 | return nil, fmt.Errorf("invalid config %q: %s", filename, err) 87 | } 88 | 89 | if c.Info.Location == "" { 90 | c.Info.Location = "UTC" 91 | } 92 | if len(c.Info.SAML.PrivateKey) == 0 || len(c.Info.SAML.Certificate) == 0 { 93 | if err := c.generateSAMLKeyPair(); err != nil { 94 | return nil, err 95 | } 96 | } 97 | 98 | return c, nil 99 | } 100 | 101 | func (c *Config) Lock() { 102 | c.mu.Lock() 103 | } 104 | 105 | func (c *Config) Unlock() { 106 | c.mu.Unlock() 107 | } 108 | 109 | func (c *Config) RLock() { 110 | c.mu.RLock() 111 | } 112 | 113 | func (c *Config) RUnlock() { 114 | c.mu.RUnlock() 115 | } 116 | 117 | func (c *Config) DeleteProperty(id string) error { 118 | c.Lock() 119 | defer c.Unlock() 120 | 121 | var properties []*Property 122 | for _, p := range c.Properties { 123 | if p.ID == id { 124 | continue 125 | } 126 | properties = append(properties, p) 127 | } 128 | c.Properties = properties 129 | return c.save() 130 | } 131 | 132 | func (c *Config) UpdateProperty(id string, fn func(*Property) error) error { 133 | c.Lock() 134 | defer c.Unlock() 135 | p, err := c.findProperty(id) 136 | if err != nil { 137 | return err 138 | } 139 | if err := fn(p); err != nil { 140 | return err 141 | } 142 | return c.save() 143 | } 144 | 145 | func (c *Config) AddProperty(name, domain string) (Property, error) { 146 | c.Lock() 147 | defer c.Unlock() 148 | 149 | var id string 150 | for { 151 | n := randomString(16) 152 | if _, err := c.findProperty(n); err == ErrPropertyNotFound { 153 | id = n 154 | break 155 | } 156 | } 157 | property := Property{ 158 | ID: id, 159 | Name: name, 160 | Domain: domain, 161 | Created: time.Now(), 162 | } 163 | c.Properties = append(c.Properties, &property) 164 | return property, c.save() 165 | } 166 | 167 | func (c *Config) FindProperty(id string) (Property, error) { 168 | c.RLock() 169 | defer c.RUnlock() 170 | u, err := c.findProperty(id) 171 | if err != nil { 172 | return Property{}, err 173 | } 174 | return *u, nil 175 | } 176 | 177 | func (c *Config) findProperty(id string) (*Property, error) { 178 | for _, u := range c.Properties { 179 | if u.ID == id { 180 | return u, nil 181 | } 182 | } 183 | return nil, ErrPropertyNotFound 184 | } 185 | 186 | func (c *Config) ListProperties() (properties []Property) { 187 | c.RLock() 188 | defer c.RUnlock() 189 | for _, p := range c.listProperties() { 190 | properties = append(properties, *p) 191 | } 192 | return 193 | } 194 | 195 | func (c *Config) listProperties() (properties []*Property) { 196 | for _, p := range c.Properties { 197 | properties = append(properties, p) 198 | } 199 | sort.Slice(properties, func(i, j int) bool { return properties[i].Created.After(properties[j].Created) }) 200 | return 201 | } 202 | 203 | func (c *Config) FindInfo() Info { 204 | c.RLock() 205 | defer c.RUnlock() 206 | return *c.Info 207 | } 208 | 209 | func (c *Config) UpdateInfo(fn func(*Info) error) error { 210 | c.Lock() 211 | defer c.Unlock() 212 | if err := fn(c.Info); err != nil { 213 | return err 214 | } 215 | return c.save() 216 | } 217 | 218 | func (c *Config) save() error { 219 | b, err := json.MarshalIndent(c, "", " ") 220 | if err != nil { 221 | return err 222 | } 223 | return overwrite(c.filename, b, 0644) 224 | } 225 | 226 | func (c *Config) generateSAMLKeyPair() error { 227 | // Generate private key. 228 | key, err := rsa.GenerateKey(rand.Reader, 2048) 229 | if err != nil { 230 | return err 231 | } 232 | 233 | // Generate the certificate. 234 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 235 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 236 | if err != nil { 237 | return err 238 | } 239 | 240 | tmpl := x509.Certificate{ 241 | NotBefore: time.Now(), 242 | NotAfter: time.Now().AddDate(5, 0, 0), 243 | SerialNumber: serialNumber, 244 | Subject: pkix.Name{ 245 | CommonName: httpHost, 246 | Organization: []string{"Frequency"}, 247 | }, 248 | BasicConstraintsValid: true, 249 | } 250 | 251 | cert, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) 252 | if err != nil { 253 | return err 254 | } 255 | 256 | // Generate private key PEM block. 257 | c.Info.SAML.PrivateKey = pem.EncodeToMemory(&pem.Block{ 258 | Type: "RSA PRIVATE KEY", 259 | Bytes: x509.MarshalPKCS1PrivateKey(key), 260 | }) 261 | 262 | // Generate certificate PEM block. 263 | c.Info.SAML.Certificate = pem.EncodeToMemory(&pem.Block{ 264 | Type: "CERTIFICATE", 265 | Bytes: cert, 266 | }) 267 | return c.save() 268 | } 269 | -------------------------------------------------------------------------------- /email/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /email/forgot.html: -------------------------------------------------------------------------------- 1 | {{template "header.html" .}} 2 | 3 |

4 | Password Reset Link 5 |

6 | 7 |
8 | Reset Your Password 9 |
10 | 11 |

12 | NOTE: If you did not request a password reset, simply ignore this email. 13 |

14 | 15 | {{template "footer.html" .}} 16 | -------------------------------------------------------------------------------- /email/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Frequency Analytics 7 | 77 | 78 | 79 | 80 | 81 | 82 |
83 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -o errexit 3 | set -o nounset 4 | set -o pipefail 5 | set -o xtrace 6 | 7 | # Require variables. 8 | if [ -z "${FREQUENCY_HTTP_HOST-}" ] ; then 9 | echo "Environment variable FREQUENCY_HTTP_HOST required. Exiting." 10 | exit 1 11 | fi 12 | 13 | # Allow optional variables. 14 | if [ -z "${FREQUENCY_BACKLINK-}" ] ; then 15 | export FREQUENCY_BACKLINK="" 16 | fi 17 | 18 | if [ -z "${FREQUENCY_LETSENCRYPT-}" ] ; then 19 | export FREQUENCY_LETSENCRYPT="true" 20 | fi 21 | 22 | if [ -z "${FREQUENCY_HTTP_ADDR-}" ] ; then 23 | export FREQUENCY_HTTP_ADDR=":80" 24 | fi 25 | 26 | if [ -z "${FREQUENCY_HTTP_INSECURE-}" ] ; then 27 | export FREQUENCY_HTTP_INSECURE="false" 28 | fi 29 | 30 | # geoip service 31 | if ! test -d /etc/sv/geoip ; then 32 | mkdir /etc/sv/geoip 33 | cat </etc/sv/geoip/run 34 | #!/bin/bash 35 | ### set -o errexit 36 | set -o nounset 37 | set -o xtrace 38 | set -o pipefail 39 | 40 | cd /tmp 41 | 42 | while true ; do 43 | curl --silent --output GeoLite2-Country.tar.gz https://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz 44 | tar xfz GeoLite2-Country.tar.gz 45 | cp GeoLite2-Country*/GeoLite2-Country.mmdb /data/geoip.tmp 46 | mv /data/geoip.tmp /data/GeoLite2-Country.mmdb 47 | rm -rf GeoLite2* 48 | 49 | if test -e /data/GeoLite2-Country.mmdb ; then 50 | sleep 30d # Update in ~1 month. 51 | else 52 | sleep 5m # failed, so try again in five. 53 | fi 54 | done 55 | RUNIT 56 | chmod +x /etc/sv/geoip/run 57 | 58 | # geoip service log 59 | mkdir /etc/sv/geoip/log 60 | mkdir /etc/sv/geoip/log/main 61 | cat </etc/sv/geoip/log/run 62 | #!/bin/sh 63 | exec svlogd -tt ./main 64 | RUNIT 65 | chmod +x /etc/sv/geoip/log/run 66 | ln -s /etc/sv/geoip /etc/service/geoip 67 | fi 68 | 69 | # frequency service 70 | if ! test -d /etc/sv/frequency ; then 71 | mkdir /etc/sv/frequency 72 | cat </etc/sv/frequency/run 73 | #!/bin/sh 74 | exec /usr/bin/frequency \ 75 | "--http-host=${FREQUENCY_HTTP_HOST}" \ 76 | "--http-addr=${FREQUENCY_HTTP_ADDR}" \ 77 | "--http-insecure=${FREQUENCY_HTTP_INSECURE}" \ 78 | "--backlink=${FREQUENCY_BACKLINK}" \ 79 | "--letsencrypt=${FREQUENCY_LETSENCRYPT}" 80 | RUNIT 81 | chmod +x /etc/sv/frequency/run 82 | 83 | # frequency service log 84 | mkdir /etc/sv/frequency/log 85 | mkdir /etc/sv/frequency/log/main 86 | cat </etc/sv/frequency/log/run 87 | #!/bin/sh 88 | exec svlogd -tt ./main 89 | RUNIT 90 | chmod +x /etc/sv/frequency/log/run 91 | ln -s /etc/sv/frequency /etc/service/frequency 92 | fi 93 | 94 | exec $@ 95 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "compress/gzip" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/mailru/easyjson" 17 | ) 18 | 19 | var MaxThreadsafeAppend = 4096 20 | 21 | //easyjson:json 22 | type Event struct { 23 | Property string `json:"property"` 24 | Host string `json:"host"` 25 | Path string `json:"path"` 26 | Referrer string `json:"referrer"` 27 | IPAddress string `json:"ip_address"` 28 | UserAgent string `json:"user_agent"` 29 | ScreenWidth int `json:"screen_width"` 30 | ScreenHeight int `json:"screen_height"` 31 | Timezone int `json:"timezone"` 32 | Language string `json:"language"` 33 | Timestamp int64 `json:"timestamp"` 34 | } 35 | 36 | func (e Event) String() string { 37 | return fmt.Sprintf("Property: %s\tHost: %s\tPath: %s\tReferrer: %s\tIP Address: %s\nUser Agent: %s\tScreen Width: %d\tScreen Height: %d\tTimestamp: %d", e.Property, e.Host, e.Path, e.Referrer, e.IPAddress, e.UserAgent, e.ScreenWidth, e.ScreenHeight, e.Timestamp) 38 | } 39 | 40 | func (e Event) Time() time.Time { 41 | return time.Unix(e.Timestamp, 0).In(getTimezone()) 42 | } 43 | 44 | func (e *Event) Save() { 45 | timestamp := time.Unix(e.Timestamp, 0).In(getTimezone()) 46 | 47 | y, m, d := timestamp.Date() 48 | unix := time.Date(y, m, d, timestamp.Hour(), 0, 0, 0, timestamp.Location()).Unix() 49 | 50 | dirname := filepath.Join(datadir, "event", e.Property, fmt.Sprintf("%d", y), fmt.Sprintf("%02d", m), fmt.Sprintf("%02d", d)) 51 | filename := filepath.Join(dirname, fmt.Sprintf("%d.%s.events", unix, e.Property)) 52 | 53 | if err := os.MkdirAll(dirname, 0755); err != nil { 54 | logger.Error(err) 55 | return 56 | } 57 | 58 | b, err := json.Marshal(e) 59 | if err != nil { 60 | logger.Error(err) 61 | return 62 | } 63 | b = append(b, []byte("\n")...) 64 | 65 | // TODO: look into this issue further. 66 | // https://stackoverflow.com/questions/1154446/is-file-append-atomic-in-unix 67 | if len(b) > MaxThreadsafeAppend { 68 | logger.Error(fmt.Errorf("event exceeded threadsafe append %d > %d", len(b), MaxThreadsafeAppend)) 69 | return 70 | } 71 | 72 | f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 73 | if err != nil { 74 | logger.Error(err) 75 | return 76 | } 77 | if _, err := f.Write(b); err != nil { 78 | logger.Error(err) 79 | return 80 | } 81 | if err := f.Close(); err != nil { 82 | logger.Error(err) 83 | return 84 | } 85 | } 86 | 87 | func eventWalk(propertyID string, start, end int64, fn func(*Event)) { 88 | files := eventFiles(propertyID, start, end) 89 | 90 | for _, filename := range files { 91 | f, err := os.Open(filename) 92 | if err != nil { 93 | logger.Error(err) 94 | continue 95 | } 96 | compressed := filepath.Ext(filename) == ".gz" 97 | 98 | var scanner *bufio.Scanner 99 | var fz *gzip.Reader 100 | 101 | if compressed { 102 | fz, err = gzip.NewReader(f) 103 | if err != nil { 104 | logger.Error(err) 105 | continue 106 | } 107 | scanner = bufio.NewScanner(fz) 108 | } else { 109 | scanner = bufio.NewScanner(f) 110 | } 111 | 112 | for scanner.Scan() { 113 | e := &Event{} 114 | if err := easyjson.Unmarshal(scanner.Bytes(), e); err != nil { 115 | logger.Error(err) 116 | continue 117 | } 118 | fn(e) 119 | } 120 | 121 | if err := scanner.Err(); err != nil { 122 | logger.Error(err) 123 | } 124 | 125 | if err := f.Close(); err != nil { 126 | logger.Error(err) 127 | } 128 | 129 | if compressed { 130 | if err := fz.Close(); err != nil { 131 | logger.Error(err) 132 | } 133 | } 134 | } 135 | } 136 | 137 | func eventList(propertyID string, start, end int64, limit, page int) []*Event { 138 | if limit < 0 || page < 0 { 139 | return nil 140 | } 141 | 142 | type nfilevalue struct { 143 | First int 144 | Last int 145 | Count int 146 | } 147 | 148 | files := eventFiles(propertyID, start, end) 149 | nfile := make(map[string]*nfilevalue) 150 | 151 | total := 0 152 | for _, filename := range files { 153 | count, err := lines(filename) 154 | if err != nil { 155 | logger.Error(err) 156 | continue 157 | } 158 | if count == 0 { 159 | continue 160 | } 161 | first := total + 1 162 | last := total + count 163 | nfile[filename] = &nfilevalue{first, last, count} 164 | total += count 165 | } 166 | 167 | pagelimit := limit * page 168 | 169 | wantFirst := (total - pagelimit) + 1 170 | if wantFirst < 0 { 171 | wantFirst = 0 172 | } 173 | 174 | wantLast := (total - pagelimit) + limit 175 | if wantLast < 0 { 176 | return nil 177 | } 178 | 179 | seen := 0 180 | 181 | var events []*Event 182 | for _, filename := range files { 183 | nfv, ok := nfile[filename] 184 | if !ok { 185 | continue 186 | } 187 | 188 | if nfv.Last < wantFirst { 189 | seen += nfv.Count 190 | continue 191 | } 192 | compressed := filepath.Ext(filename) == ".gz" 193 | 194 | f, err := os.Open(filename) 195 | if err != nil { 196 | logger.Error(err) 197 | continue 198 | } 199 | 200 | var scanner *bufio.Scanner 201 | var fz *gzip.Reader 202 | 203 | if compressed { 204 | fz, err = gzip.NewReader(f) 205 | if err != nil { 206 | logger.Error(err) 207 | continue 208 | } 209 | scanner = bufio.NewScanner(fz) 210 | } else { 211 | scanner = bufio.NewScanner(f) 212 | } 213 | 214 | for scanner.Scan() { 215 | seen += 1 216 | 217 | if seen < wantFirst { 218 | continue 219 | } 220 | if seen > wantLast { 221 | continue 222 | } 223 | 224 | e := &Event{} 225 | if err := easyjson.Unmarshal(scanner.Bytes(), e); err != nil { 226 | logger.Error(err) 227 | continue 228 | } 229 | cp := *e 230 | events = append(events, &cp) 231 | } 232 | 233 | if err := scanner.Err(); err != nil { 234 | logger.Error(err) 235 | } 236 | 237 | if err := f.Close(); err != nil { 238 | logger.Error(err) 239 | } 240 | 241 | if compressed { 242 | if err := fz.Close(); err != nil { 243 | logger.Error(err) 244 | } 245 | } 246 | } 247 | return events 248 | } 249 | 250 | func eventFiles(propertyID string, start, end int64) []string { 251 | type eventFileEntry struct { 252 | Path string 253 | Timestamp int64 254 | } 255 | 256 | now := time.Now().In(getTimezone()) 257 | 258 | sy, sm, sd := time.Unix(start, 0).In(now.Location()).Date() 259 | ey, em, ed := time.Unix(end, 0).In(now.Location()).Date() 260 | 261 | startDate := time.Date(sy, sm, sd, 0, 0, 0, 0, now.Location()) 262 | endDate := time.Date(ey, em, ed, 0, 0, 0, 0, now.Location()) 263 | 264 | dirname := filepath.Join(datadir, "event", propertyID) 265 | 266 | var entries []*eventFileEntry 267 | if err := filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error { 268 | if err != nil { 269 | logger.Debug(err) 270 | return nil 271 | } 272 | 273 | if info.IsDir() { 274 | return nil 275 | } 276 | if !strings.HasSuffix(path, ".events") && !strings.HasSuffix(path, ".events.gz") { 277 | return nil 278 | } 279 | parts := strings.Split(info.Name(), ".") 280 | if len(parts) == 0 { 281 | return nil 282 | } 283 | timestamp, err := strconv.ParseInt(parts[0], 10, 64) 284 | if err != nil { 285 | return nil 286 | } 287 | tsy, tsm, tsd := time.Unix(timestamp, 0).In(now.Location()).Date() 288 | tsDate := time.Date(tsy, tsm, tsd, 0, 0, 0, 0, now.Location()) 289 | 290 | if tsDate.Before(startDate) { 291 | return nil 292 | } 293 | if tsDate.After(endDate) { 294 | return nil 295 | } 296 | 297 | entries = append(entries, &eventFileEntry{path, timestamp}) 298 | return nil 299 | }); err != nil { 300 | logger.Error(err) 301 | return nil 302 | } 303 | 304 | // oldest to newest 305 | sort.Slice(entries, func(i, j int) bool { 306 | return entries[i].Timestamp < entries[j].Timestamp 307 | }) 308 | 309 | var files []string 310 | for _, entry := range entries { 311 | files = append(files, entry.Path) 312 | } 313 | return files 314 | } 315 | 316 | /* 317 | func eventFilesOld(propertyID string, start, end int64) []string { 318 | now := time.Now().In(getTimezone()) 319 | 320 | sy, sm, sd := time.Unix(start, 0).In(now.Location()).Date() 321 | ey, em, ed := time.Unix(end, 0).In(now.Location()).Date() 322 | 323 | endDate := time.Date(ey, em, ed, 0, 0, 0, 0, now.Location()) 324 | currentDate := time.Date(sy, sm, sd, 0, 0, 0, 0, now.Location()) 325 | 326 | var dirs []string 327 | for { 328 | if currentDate.After(endDate) { 329 | break 330 | } 331 | y, m, d := currentDate.Date() 332 | dirname := filepath.Join(datadir, "event", propertyID, fmt.Sprintf("%d", y), fmt.Sprintf("%02d", m), fmt.Sprintf("%02d", d)) 333 | dirs = append(dirs, dirname) 334 | currentDate = currentDate.AddDate(0, 0, 1) 335 | } 336 | 337 | var files []string 338 | for _, dirname := range dirs { 339 | err := filepath.Walk(dirname, func(path string, _ os.FileInfo, err error) error { 340 | if !strings.HasSuffix(path, ".events") && !strings.HasSuffix(path, ".events.gz") { 341 | return nil 342 | } 343 | files = append(files, path) 344 | return nil 345 | }) 346 | if err != nil { 347 | logger.Error(err) 348 | continue 349 | } 350 | } 351 | sort.Strings(files) // oldest to newest 352 | return files 353 | } 354 | */ 355 | 356 | func eventPurge(propertyID string) error { 357 | if propertyID == "" { 358 | return fmt.Errorf("missing property ID") 359 | } 360 | dirname := filepath.Join(datadir, "event", propertyID) 361 | return os.RemoveAll(dirname) 362 | } 363 | 364 | func eventPropertyIDs() []string { 365 | ids := []string{} 366 | files, err := ioutil.ReadDir(filepath.Join(datadir, "event")) 367 | if err != nil { 368 | return ids 369 | } 370 | for _, file := range files { 371 | ids = append(ids, file.Name()) 372 | } 373 | return ids 374 | } 375 | -------------------------------------------------------------------------------- /event_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. 2 | 3 | package main 4 | 5 | import ( 6 | json "encoding/json" 7 | easyjson "github.com/mailru/easyjson" 8 | jlexer "github.com/mailru/easyjson/jlexer" 9 | jwriter "github.com/mailru/easyjson/jwriter" 10 | ) 11 | 12 | // suppress unused package warning 13 | var ( 14 | _ *json.RawMessage 15 | _ *jlexer.Lexer 16 | _ *jwriter.Writer 17 | _ easyjson.Marshaler 18 | ) 19 | 20 | func easyjsonF642ad3eDecodeGithubComFrequencyanalyticsFrequency(in *jlexer.Lexer, out *Event) { 21 | isTopLevel := in.IsStart() 22 | if in.IsNull() { 23 | if isTopLevel { 24 | in.Consumed() 25 | } 26 | in.Skip() 27 | return 28 | } 29 | in.Delim('{') 30 | for !in.IsDelim('}') { 31 | key := in.UnsafeString() 32 | in.WantColon() 33 | if in.IsNull() { 34 | in.Skip() 35 | in.WantComma() 36 | continue 37 | } 38 | switch key { 39 | case "property": 40 | out.Property = string(in.String()) 41 | case "host": 42 | out.Host = string(in.String()) 43 | case "path": 44 | out.Path = string(in.String()) 45 | case "referrer": 46 | out.Referrer = string(in.String()) 47 | case "ip_address": 48 | out.IPAddress = string(in.String()) 49 | case "user_agent": 50 | out.UserAgent = string(in.String()) 51 | case "screen_width": 52 | out.ScreenWidth = int(in.Int()) 53 | case "screen_height": 54 | out.ScreenHeight = int(in.Int()) 55 | case "timezone": 56 | out.Timezone = int(in.Int()) 57 | case "language": 58 | out.Language = string(in.String()) 59 | case "timestamp": 60 | out.Timestamp = int64(in.Int64()) 61 | default: 62 | in.SkipRecursive() 63 | } 64 | in.WantComma() 65 | } 66 | in.Delim('}') 67 | if isTopLevel { 68 | in.Consumed() 69 | } 70 | } 71 | func easyjsonF642ad3eEncodeGithubComFrequencyanalyticsFrequency(out *jwriter.Writer, in Event) { 72 | out.RawByte('{') 73 | first := true 74 | _ = first 75 | { 76 | const prefix string = ",\"property\":" 77 | if first { 78 | first = false 79 | out.RawString(prefix[1:]) 80 | } else { 81 | out.RawString(prefix) 82 | } 83 | out.String(string(in.Property)) 84 | } 85 | { 86 | const prefix string = ",\"host\":" 87 | if first { 88 | first = false 89 | out.RawString(prefix[1:]) 90 | } else { 91 | out.RawString(prefix) 92 | } 93 | out.String(string(in.Host)) 94 | } 95 | { 96 | const prefix string = ",\"path\":" 97 | if first { 98 | first = false 99 | out.RawString(prefix[1:]) 100 | } else { 101 | out.RawString(prefix) 102 | } 103 | out.String(string(in.Path)) 104 | } 105 | { 106 | const prefix string = ",\"referrer\":" 107 | if first { 108 | first = false 109 | out.RawString(prefix[1:]) 110 | } else { 111 | out.RawString(prefix) 112 | } 113 | out.String(string(in.Referrer)) 114 | } 115 | { 116 | const prefix string = ",\"ip_address\":" 117 | if first { 118 | first = false 119 | out.RawString(prefix[1:]) 120 | } else { 121 | out.RawString(prefix) 122 | } 123 | out.String(string(in.IPAddress)) 124 | } 125 | { 126 | const prefix string = ",\"user_agent\":" 127 | if first { 128 | first = false 129 | out.RawString(prefix[1:]) 130 | } else { 131 | out.RawString(prefix) 132 | } 133 | out.String(string(in.UserAgent)) 134 | } 135 | { 136 | const prefix string = ",\"screen_width\":" 137 | if first { 138 | first = false 139 | out.RawString(prefix[1:]) 140 | } else { 141 | out.RawString(prefix) 142 | } 143 | out.Int(int(in.ScreenWidth)) 144 | } 145 | { 146 | const prefix string = ",\"screen_height\":" 147 | if first { 148 | first = false 149 | out.RawString(prefix[1:]) 150 | } else { 151 | out.RawString(prefix) 152 | } 153 | out.Int(int(in.ScreenHeight)) 154 | } 155 | { 156 | const prefix string = ",\"timezone\":" 157 | if first { 158 | first = false 159 | out.RawString(prefix[1:]) 160 | } else { 161 | out.RawString(prefix) 162 | } 163 | out.Int(int(in.Timezone)) 164 | } 165 | { 166 | const prefix string = ",\"language\":" 167 | if first { 168 | first = false 169 | out.RawString(prefix[1:]) 170 | } else { 171 | out.RawString(prefix) 172 | } 173 | out.String(string(in.Language)) 174 | } 175 | { 176 | const prefix string = ",\"timestamp\":" 177 | if first { 178 | first = false 179 | out.RawString(prefix[1:]) 180 | } else { 181 | out.RawString(prefix) 182 | } 183 | out.Int64(int64(in.Timestamp)) 184 | } 185 | out.RawByte('}') 186 | } 187 | 188 | // MarshalJSON supports json.Marshaler interface 189 | func (v Event) MarshalJSON() ([]byte, error) { 190 | w := jwriter.Writer{} 191 | easyjsonF642ad3eEncodeGithubComFrequencyanalyticsFrequency(&w, v) 192 | return w.Buffer.BuildBytes(), w.Error 193 | } 194 | 195 | // MarshalEasyJSON supports easyjson.Marshaler interface 196 | func (v Event) MarshalEasyJSON(w *jwriter.Writer) { 197 | easyjsonF642ad3eEncodeGithubComFrequencyanalyticsFrequency(w, v) 198 | } 199 | 200 | // UnmarshalJSON supports json.Unmarshaler interface 201 | func (v *Event) UnmarshalJSON(data []byte) error { 202 | r := jlexer.Lexer{Data: data} 203 | easyjsonF642ad3eDecodeGithubComFrequencyanalyticsFrequency(&r, v) 204 | return r.Error() 205 | } 206 | 207 | // UnmarshalEasyJSON supports easyjson.Unmarshaler interface 208 | func (v *Event) UnmarshalEasyJSON(l *jlexer.Lexer) { 209 | easyjsonF642ad3eDecodeGithubComFrequencyanalyticsFrequency(l, v) 210 | } 211 | -------------------------------------------------------------------------------- /fileman.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func fileman() { 10 | const maxStorage = 95 11 | 12 | if !deleteOldFiles && !compressOldFiles { 13 | return 14 | } 15 | 16 | for { 17 | time.Sleep(2 * time.Minute) 18 | 19 | for _, propertyID := range eventPropertyIDs() { 20 | // All event files before today. 21 | files := eventFiles(propertyID, 1, time.Now().AddDate(0, 0, -1).Unix()) 22 | 23 | if len(files) == 0 { 24 | continue 25 | } 26 | 27 | // 28 | // Delete files to avoid filling up storage. 29 | // 30 | if deleteOldFiles { 31 | di, err := NewDiskInfo(datadir) 32 | if err == nil { 33 | if di.UsedPercent() >= maxStorage { 34 | oldest := files[0] 35 | logger.Infof("fileman: deleting oldest file %q because storage is >%d%% full (%.1f)", oldest, maxStorage, di.UsedPercent()) 36 | if err := os.Remove(oldest); err != nil { 37 | logger.Error(err) 38 | continue 39 | } 40 | files = files[1:] 41 | } 42 | } else if err != nil { 43 | logger.Error(err) 44 | } 45 | } 46 | 47 | // 48 | // Compress a completed file 49 | // 50 | if compressOldFiles { 51 | for _, filename := range files { 52 | if strings.HasSuffix(filename, ".events.gz") { 53 | continue 54 | } 55 | logger.Infof("gzipping %q", filename) 56 | if err := gzipit(filename); err != nil { 57 | logger.Error(err) 58 | continue 59 | } 60 | break // One per run 61 | } 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /frequency-linux-amd64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/frequency-linux-amd64 -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | useragent "github.com/mssola/user_agent" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | "golang.org/x/crypto/bcrypt" 17 | "golang.org/x/net/publicsuffix" 18 | ) 19 | 20 | var ( 21 | validEmail = regexp.MustCompile(`^[ -~]+@[ -~]+$`) 22 | validPassword = regexp.MustCompile(`^[ -~]{6,200}$`) 23 | validString = regexp.MustCompile(`^[ -~]{1,200}$`) 24 | 25 | // Base64 26 | transparentPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" 27 | ) 28 | 29 | func domainHandler(w *Web) { 30 | w.HTML() 31 | } 32 | 33 | func ssoHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 34 | if token := samlSP.GetAuthorizationToken(r); token != nil { 35 | http.Redirect(w, r, "/", http.StatusFound) 36 | return 37 | } 38 | logger.Debugf("SSO: require account handler") 39 | samlSP.RequireAccountHandler(w, r) 40 | return 41 | } 42 | 43 | func samlHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { 44 | if samlSP == nil { 45 | Error(w, fmt.Errorf("SAML is not configured")) 46 | return 47 | } 48 | logger.Debugf("SSO: samlSP.ServeHTTP") 49 | samlSP.ServeHTTP(w, r) 50 | } 51 | 52 | func pingHandler(w *Web) { 53 | property := w.r.FormValue("property") 54 | path := w.r.FormValue("path") 55 | host := w.r.FormValue("host") 56 | referrer := w.r.FormValue("referrer") 57 | 58 | ipAddress, _, _ := net.SplitHostPort(w.r.RemoteAddr) 59 | if octets := strings.Split(ipAddress, "."); len(octets) == 4 { 60 | ipAddress = fmt.Sprintf("%s.%s.%s.0", octets[0], octets[1], octets[2]) 61 | } 62 | 63 | userAgent := w.r.Header.Get("User-Agent") 64 | screenWidth, _ := strconv.Atoi(w.r.FormValue("width")) 65 | screenHeight, _ := strconv.Atoi(w.r.FormValue("height")) 66 | timezone, _ := strconv.Atoi(w.r.FormValue("timezone")) 67 | language := w.r.FormValue("language") 68 | timestamp := time.Now().In(getTimezone()).Unix() 69 | 70 | // Save events from non-bots. 71 | if !useragent.New(userAgent).Bot() { 72 | event := &Event{ 73 | Property: property, 74 | Host: host, 75 | Path: path, 76 | Referrer: referrer, 77 | IPAddress: ipAddress, 78 | UserAgent: userAgent, 79 | ScreenWidth: screenWidth, 80 | ScreenHeight: screenHeight, 81 | Timezone: timezone, 82 | Language: language, 83 | Timestamp: timestamp, 84 | } 85 | go event.Save() 86 | } 87 | 88 | png, err := base64.StdEncoding.DecodeString(transparentPNG) 89 | if err != nil { 90 | logger.Error(err) 91 | return 92 | } 93 | if _, err := w.w.Write(png); err != nil { 94 | logger.Error(err) 95 | return 96 | } 97 | } 98 | 99 | func configureHandler(w *Web) { 100 | if config.FindInfo().Configured { 101 | w.Redirect("/?error=configured") 102 | return 103 | } 104 | 105 | if w.r.Method == "GET" { 106 | w.HTML() 107 | return 108 | } 109 | 110 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 111 | emailConfirm := strings.ToLower(strings.TrimSpace(w.r.FormValue("email_confirm"))) 112 | password := w.r.FormValue("password") 113 | 114 | if !validEmail.MatchString(email) || !validPassword.MatchString(password) || email != emailConfirm { 115 | w.Redirect("/configure?error=invalid") 116 | return 117 | } 118 | 119 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 120 | if err != nil { 121 | w.Redirect("/forgot?error=bcrypt") 122 | return 123 | } 124 | config.UpdateInfo(func(i *Info) error { 125 | i.Email = email 126 | i.Password = hashedPassword 127 | i.Configured = true 128 | return nil 129 | }) 130 | 131 | if err := w.SigninSession(true); err != nil { 132 | Error(w.w, err) 133 | return 134 | } 135 | w.Redirect("/") 136 | return 137 | } 138 | 139 | func forgotHandler(w *Web) { 140 | if w.r.Method == "GET" { 141 | w.HTML() 142 | return 143 | } 144 | 145 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 146 | secret := w.r.FormValue("secret") 147 | password := w.r.FormValue("password") 148 | 149 | if email != "" && !validEmail.MatchString(email) { 150 | w.Redirect("/forgot?error=invalid") 151 | return 152 | } 153 | if secret != "" && !validString.MatchString(secret) { 154 | w.Redirect("/forgot?error=invalid") 155 | return 156 | } 157 | if email != "" && secret != "" && !validPassword.MatchString(password) { 158 | w.Redirect("/forgot?error=invalid&email=%s&secret=%s", email, secret) 159 | return 160 | } 161 | 162 | if email != config.FindInfo().Email { 163 | w.Redirect("/forgot?error=invalid") 164 | return 165 | } 166 | 167 | if secret == "" { 168 | secret = config.FindInfo().Secret 169 | if secret == "" { 170 | secret = randomString(32) 171 | config.UpdateInfo(func(i *Info) error { 172 | if i.Secret == "" { 173 | i.Secret = secret 174 | } 175 | return nil 176 | }) 177 | } 178 | 179 | go func() { 180 | if err := mailer.Forgot(email, secret); err != nil { 181 | logger.Error(err) 182 | } 183 | }() 184 | 185 | w.Redirect("/forgot?success=forgot") 186 | return 187 | } 188 | 189 | if secret != config.FindInfo().Secret { 190 | w.Redirect("/forgot?error=invalid") 191 | return 192 | } 193 | 194 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 195 | if err != nil { 196 | w.Redirect("/forgot?error=bcrypt") 197 | return 198 | } 199 | config.UpdateInfo(func(i *Info) error { 200 | i.Password = hashedPassword 201 | i.Secret = "" 202 | return nil 203 | }) 204 | 205 | if err := w.SigninSession(true); err != nil { 206 | Error(w.w, err) 207 | return 208 | } 209 | w.Redirect("/") 210 | return 211 | } 212 | 213 | func signoutHandler(w *Web) { 214 | w.SignoutSession() 215 | w.Redirect("/signin") 216 | } 217 | 218 | func signinHandler(w *Web) { 219 | if w.r.Method == "GET" { 220 | w.HTML() 221 | return 222 | } 223 | 224 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 225 | password := w.r.FormValue("password") 226 | 227 | if email != config.FindInfo().Email { 228 | w.Redirect("/signin?error=invalid") 229 | return 230 | } 231 | 232 | if err := bcrypt.CompareHashAndPassword(config.FindInfo().Password, []byte(password)); err != nil { 233 | w.Redirect("/signin?error=invalid") 234 | return 235 | } 236 | if err := w.SigninSession(true); err != nil { 237 | Error(w.w, err) 238 | return 239 | } 240 | 241 | w.Redirect("/") 242 | } 243 | func indexHandler(w *Web) { 244 | w.Properties = config.ListProperties() 245 | w.HTML() 246 | } 247 | 248 | func analyticsHandler(w *Web) { 249 | script := fmt.Sprintf(` 250 | (function() { 251 | if (window.fa.initialized) { 252 | return; 253 | } 254 | var queue = window.fa.queue.slice(); 255 | var property = ''; 256 | 257 | window.fa = function() { 258 | var command = arguments[0]; 259 | 260 | if (command === 'create') { 261 | property = arguments[1]; 262 | } else if (command === 'send') { 263 | if (arguments[1] === 'pageview') { 264 | var ping = document.createElement('img'); 265 | ping.src = 'https://%s/ping?property='+encodeURIComponent(property)+'&host='+encodeURIComponent(document.location.hostname)+'&path='+encodeURIComponent(window.location.pathname)+'&width='+window.screen.availWidth+'&height='+window.screen.availHeight+'&referrer='+encodeURIComponent(document.referrer)+'&timezone='+encodeURIComponent(new Date().getTimezoneOffset())+'&language='+encodeURIComponent(window.navigator.language); 266 | ping.width = 1; 267 | ping.height = 1; 268 | 269 | var script = document.scripts[document.scripts.length - 1]; 270 | script.parentElement.insertBefore(ping, script); 271 | } 272 | } 273 | } 274 | window.fa.initialized = true; 275 | 276 | for (var i = 0; i < queue.length; i++) { 277 | fa.apply(null, queue[i]); 278 | } 279 | })() 280 | `, w.r.Host) 281 | 282 | w.w.Header().Set("Content-Type", "text/javascript") 283 | w.w.Header().Set("Content-Length", fmt.Sprintf("%d", len(script))) 284 | w.w.Write([]byte(script)) 285 | } 286 | 287 | func settingsHandler(w *Web) { 288 | if w.r.Method == "GET" { 289 | w.HTML() 290 | return 291 | } 292 | 293 | email := strings.ToLower(strings.TrimSpace(w.r.FormValue("email"))) 294 | currentPassword := w.r.FormValue("current_password") 295 | newPassword := w.r.FormValue("new_password") 296 | domain := strings.TrimSpace(w.r.FormValue("domain")) 297 | location := w.r.FormValue("location") 298 | samlMetadata := strings.TrimSpace(w.r.FormValue("saml_metadata")) 299 | 300 | if currentPassword != "" || newPassword != "" { 301 | if !validPassword.MatchString(newPassword) { 302 | w.Redirect("/settings?error=invalid") 303 | return 304 | } 305 | 306 | if err := bcrypt.CompareHashAndPassword(config.FindInfo().Password, []byte(currentPassword)); err != nil { 307 | w.Redirect("/settings?error=invalid") 308 | return 309 | } 310 | 311 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) 312 | if err != nil { 313 | w.Redirect("/settings?error=bcrypt") 314 | return 315 | } 316 | 317 | config.UpdateInfo(func(i *Info) error { 318 | i.Email = email 319 | i.Password = hashedPassword 320 | return nil 321 | }) 322 | } 323 | 324 | // Timezone 325 | if location != "" { 326 | if err := setTimezone(location); err != nil { 327 | Error(w.w, err) 328 | return 329 | } 330 | } 331 | 332 | config.UpdateInfo(func(i *Info) error { 333 | i.SAML.IDPMetadata = samlMetadata 334 | i.Email = email 335 | i.Domain = domain 336 | i.Location = location 337 | return nil 338 | }) 339 | 340 | // Configure SAML if metadata is present. 341 | if len(samlMetadata) > 0 { 342 | if err := configureSAML(); err != nil { 343 | logger.Warnf("configuring SAML failed: %s", err) 344 | w.Redirect("/settings?error=saml") 345 | return 346 | } 347 | } else { 348 | samlSP = nil 349 | } 350 | 351 | w.Redirect("/settings?success=settings") 352 | } 353 | 354 | func helpHandler(w *Web) { 355 | w.HTML() 356 | } 357 | 358 | func addPropertyHandler(w *Web) { 359 | name := strings.TrimSpace(w.r.FormValue("name")) 360 | domain := strings.TrimSpace(w.r.FormValue("domain")) 361 | 362 | if name == "" { 363 | name = "Unnamed Property" 364 | } 365 | 366 | domain = strings.TrimPrefix(domain, "http://") 367 | domain = strings.TrimPrefix(domain, "https://") 368 | domain = strings.TrimPrefix(domain, "www.") 369 | domain = strings.TrimRight(domain, "/") 370 | 371 | if _, icann := publicsuffix.PublicSuffix(domain); !icann { 372 | w.Redirect("/?error=adding") 373 | return 374 | } 375 | 376 | property, err := config.AddProperty(name, domain) 377 | if err != nil { 378 | logger.Warn(err) 379 | w.Redirect("/?error=adding") 380 | return 381 | } 382 | 383 | w.Redirect("/property/snippet/%s?success=added", property.ID) 384 | } 385 | 386 | func deletePropertyHandler(w *Web) { 387 | propertyID := w.ps.ByName("property") 388 | if propertyID == "" { 389 | propertyID = w.r.FormValue("property") 390 | } 391 | property, err := config.FindProperty(propertyID) 392 | if err != nil { 393 | http.NotFound(w.w, w.r) 394 | return 395 | } 396 | 397 | if w.r.Method == "GET" { 398 | w.Property = property 399 | w.HTML() 400 | return 401 | } 402 | 403 | // Purge data 404 | if err := eventPurge(property.ID); err != nil { 405 | w.Redirect("/property/delete/%s?error=purge", property.ID) 406 | } 407 | 408 | if err := config.DeleteProperty(property.ID); err != nil { 409 | panic(err) 410 | } 411 | w.Redirect("/?success=removed") 412 | } 413 | 414 | func dashboardPropertyHandler(w *Web) { 415 | propertyID := w.ps.ByName("property") 416 | if propertyID == "" { 417 | propertyID = w.r.FormValue("property") 418 | } 419 | property, err := config.FindProperty(propertyID) 420 | if err != nil { 421 | http.NotFound(w.w, w.r) 422 | return 423 | } 424 | 425 | now := time.Now().In(getTimezone()) 426 | 427 | if w.r.Method == "POST" { 428 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 429 | if start.IsZero() { 430 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 431 | } 432 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 433 | if end.IsZero() { 434 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 435 | } 436 | if start.IsZero() || end.IsZero() { 437 | w.Redirect("/property/dashboard/%s", property.ID) 438 | return 439 | } 440 | 441 | w.Redirect("/property/dashboard/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 442 | return 443 | } 444 | 445 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 446 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 447 | 448 | if start == 0 && end == 0 { 449 | w.Request.Form.Set("selected", "30days") 450 | } 451 | 452 | if start == 0 { 453 | start = now.AddDate(0, 0, -30).Unix() 454 | } 455 | if end == 0 || end > now.Unix() { 456 | end = now.Unix() 457 | } 458 | if start > end { 459 | w.Redirect("/property/dashboard/%s", property.ID) 460 | return 461 | } 462 | 463 | stat := NewStat(property.ID, start, end) 464 | 465 | //PagesChart: stat.PagesChart(""), 466 | w.Stats = Stats{ 467 | VisitorsChart: stat.VisitorsChart(), 468 | Sources: stat.Sources(), 469 | Pages: stat.Pages(4), 470 | Referrers: stat.Referrers(4), 471 | Platforms: stat.Platforms(4), 472 | Events: stat.Events(10, 1), 473 | } 474 | 475 | w.DaysAgo7 = now.AddDate(0, 0, -7).Unix() 476 | w.DaysAgo30 = now.AddDate(0, 0, -30).Unix() 477 | w.DaysAgo90 = now.AddDate(0, 0, -90).Unix() 478 | 479 | w.Property = property 480 | w.Start = start 481 | w.End = end 482 | w.HTML() 483 | return 484 | } 485 | 486 | func sourcesPropertyHandler(w *Web) { 487 | propertyID := w.ps.ByName("property") 488 | if propertyID == "" { 489 | propertyID = w.r.FormValue("property") 490 | } 491 | property, err := config.FindProperty(propertyID) 492 | if err != nil { 493 | http.NotFound(w.w, w.r) 494 | return 495 | } 496 | 497 | now := time.Now().In(getTimezone()) 498 | 499 | if w.r.Method == "POST" { 500 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 501 | if start.IsZero() { 502 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 503 | } 504 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 505 | if end.IsZero() { 506 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 507 | } 508 | if start.IsZero() || end.IsZero() { 509 | w.Redirect("/property/sources/%s", property.ID) 510 | return 511 | } 512 | 513 | w.Redirect("/property/sources/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 514 | return 515 | } 516 | 517 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 518 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 519 | 520 | if start == 0 { 521 | start = now.AddDate(0, 0, -30).Unix() 522 | } 523 | if end == 0 || end > now.Unix() { 524 | end = now.Unix() 525 | } 526 | if start > end { 527 | w.Redirect("/property/sources/%s", property.ID) 528 | return 529 | } 530 | 531 | stat := NewStat(property.ID, start, end) 532 | w.Stats = Stats{ 533 | Sources: stat.Sources(), 534 | } 535 | w.Property = property 536 | w.Start = start 537 | w.End = end 538 | w.HTML() 539 | return 540 | } 541 | 542 | func pagesPropertyHandler(w *Web) { 543 | propertyID := w.ps.ByName("property") 544 | if propertyID == "" { 545 | propertyID = w.r.FormValue("property") 546 | } 547 | property, err := config.FindProperty(propertyID) 548 | if err != nil { 549 | http.NotFound(w.w, w.r) 550 | return 551 | } 552 | 553 | now := time.Now().In(getTimezone()) 554 | 555 | if w.r.Method == "POST" { 556 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 557 | if start.IsZero() { 558 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 559 | } 560 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 561 | if end.IsZero() { 562 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 563 | } 564 | if start.IsZero() || end.IsZero() { 565 | w.Redirect("/property/pages/%s", property.ID) 566 | return 567 | } 568 | 569 | w.Redirect("/property/pages/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 570 | return 571 | } 572 | 573 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 574 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 575 | 576 | if start == 0 { 577 | start = now.AddDate(0, 0, -30).Unix() 578 | } 579 | if end == 0 || end > now.Unix() { 580 | end = now.Unix() 581 | } 582 | if start > end { 583 | w.Redirect("/property/pages/%s", property.ID) 584 | return 585 | } 586 | 587 | stat := NewStat(property.ID, start, end) 588 | w.Stats = Stats{ 589 | Pages: stat.Pages(100), 590 | } 591 | w.Property = property 592 | w.Start = start 593 | w.End = end 594 | w.HTML() 595 | return 596 | } 597 | 598 | func referrersPropertyHandler(w *Web) { 599 | propertyID := w.ps.ByName("property") 600 | if propertyID == "" { 601 | propertyID = w.r.FormValue("property") 602 | } 603 | property, err := config.FindProperty(propertyID) 604 | if err != nil { 605 | http.NotFound(w.w, w.r) 606 | return 607 | } 608 | 609 | now := time.Now().In(getTimezone()) 610 | 611 | if w.r.Method == "POST" { 612 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 613 | if start.IsZero() { 614 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 615 | } 616 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 617 | if end.IsZero() { 618 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 619 | } 620 | if start.IsZero() || end.IsZero() { 621 | w.Redirect("/property/referrers/%s", property.ID) 622 | return 623 | } 624 | 625 | w.Redirect("/property/referrers/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 626 | return 627 | } 628 | 629 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 630 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 631 | 632 | if start == 0 { 633 | start = now.AddDate(0, 0, -30).Unix() 634 | } 635 | if end == 0 || end > now.Unix() { 636 | end = now.Unix() 637 | } 638 | if start > end { 639 | w.Redirect("/property/referrers/%s", property.ID) 640 | return 641 | } 642 | 643 | stat := NewStat(property.ID, start, end) 644 | w.Stats = Stats{ 645 | Referrers: stat.Referrers(100), 646 | } 647 | w.Property = property 648 | w.Start = start 649 | w.End = end 650 | w.HTML() 651 | return 652 | } 653 | 654 | func platformsPropertyHandler(w *Web) { 655 | propertyID := w.ps.ByName("property") 656 | if propertyID == "" { 657 | propertyID = w.r.FormValue("property") 658 | } 659 | property, err := config.FindProperty(propertyID) 660 | if err != nil { 661 | http.NotFound(w.w, w.r) 662 | return 663 | } 664 | 665 | now := time.Now().In(getTimezone()) 666 | 667 | if w.r.Method == "POST" { 668 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 669 | if start.IsZero() { 670 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 671 | } 672 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 673 | if end.IsZero() { 674 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 675 | } 676 | if start.IsZero() || end.IsZero() { 677 | w.Redirect("/property/platforms/%s", property.ID) 678 | return 679 | } 680 | 681 | w.Redirect("/property/platforms/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 682 | return 683 | } 684 | 685 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 686 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 687 | 688 | if start == 0 { 689 | start = now.AddDate(0, 0, -30).Unix() 690 | } 691 | if end == 0 || end > now.Unix() { 692 | end = now.Unix() 693 | } 694 | if start > end { 695 | w.Redirect("/property/platforms/%s", property.ID) 696 | return 697 | } 698 | 699 | stat := NewStat(property.ID, start, end) 700 | w.Stats = Stats{ 701 | Platforms: stat.Platforms(100), 702 | } 703 | w.Property = property 704 | w.Start = start 705 | w.End = end 706 | w.HTML() 707 | return 708 | } 709 | 710 | func eventsPropertyHandler(w *Web) { 711 | propertyID := w.ps.ByName("property") 712 | if propertyID == "" { 713 | propertyID = w.r.FormValue("property") 714 | } 715 | property, err := config.FindProperty(propertyID) 716 | if err != nil { 717 | http.NotFound(w.w, w.r) 718 | return 719 | } 720 | 721 | now := time.Now().In(getTimezone()) 722 | 723 | if w.r.Method == "POST" { 724 | start, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("start"), now.Location()) 725 | if start.IsZero() { 726 | start, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("start"), now.Location()) 727 | } 728 | end, _ := time.ParseInLocation("2006/01/02", w.r.FormValue("end"), now.Location()) 729 | if end.IsZero() { 730 | end, _ = time.ParseInLocation("2006/1/2", w.r.FormValue("end"), now.Location()) 731 | } 732 | if start.IsZero() || end.IsZero() { 733 | w.Redirect("/property/events/%s", property.ID) 734 | return 735 | } 736 | 737 | w.Redirect("/property/events/%s?start=%d&end=%d", property.ID, start.Unix(), end.Unix()) 738 | return 739 | } 740 | 741 | limit := 20 742 | page, _ := strconv.Atoi(w.r.FormValue("page")) 743 | if page == 0 { 744 | page = 1 745 | } 746 | 747 | start, _ := strconv.ParseInt(w.r.FormValue("start"), 10, 64) 748 | end, _ := strconv.ParseInt(w.r.FormValue("end"), 10, 64) 749 | 750 | if start == 0 { 751 | start = now.AddDate(0, 0, -30).Unix() 752 | } 753 | if end == 0 || end > now.Unix() { 754 | end = now.Unix() 755 | } 756 | if start > end { 757 | w.Redirect("/property/events/%s", property.ID) 758 | return 759 | } 760 | 761 | stat := NewStat(property.ID, start, end) 762 | w.Stats = Stats{ 763 | Events: stat.Events(limit, page), 764 | } 765 | w.Page = page 766 | w.Property = property 767 | w.Start = start 768 | w.End = end 769 | w.HTML() 770 | return 771 | } 772 | 773 | func snippetPropertyHandler(w *Web) { 774 | property, err := config.FindProperty(w.ps.ByName("property")) 775 | if err != nil { 776 | http.NotFound(w.w, w.r) 777 | return 778 | } 779 | 780 | w.Property = property 781 | w.HTML() 782 | return 783 | } 784 | 785 | func settingsPropertyHandler(w *Web) { 786 | propertyID := w.r.FormValue("property") 787 | if propertyID == "" { 788 | propertyID = w.ps.ByName("property") 789 | } 790 | 791 | property, err := config.FindProperty(propertyID) 792 | if err != nil { 793 | http.NotFound(w.w, w.r) 794 | return 795 | } 796 | 797 | if w.r.Method == "GET" { 798 | w.Property = property 799 | w.HTML() 800 | return 801 | } 802 | 803 | name := strings.TrimSpace(w.r.FormValue("name")) 804 | domain := strings.TrimSpace(w.r.FormValue("domain")) 805 | 806 | if name == "" { 807 | name = "Unnamed Property" 808 | } 809 | 810 | domain = strings.TrimPrefix(domain, "http://") 811 | domain = strings.TrimPrefix(domain, "https://") 812 | domain = strings.TrimPrefix(domain, "www.") 813 | domain = strings.TrimRight(domain, "/") 814 | 815 | if _, icann := publicsuffix.PublicSuffix(domain); !icann { 816 | w.Redirect("/property/settings/%s", property.ID) 817 | return 818 | } 819 | 820 | config.UpdateProperty(property.ID, func(p *Property) error { 821 | p.Name = name 822 | p.Domain = domain 823 | return nil 824 | }) 825 | 826 | w.Redirect("/property/settings/%s?success=changes", property.ID) 827 | } 828 | -------------------------------------------------------------------------------- /mailer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/rand" 7 | "net" 8 | "strings" 9 | "text/template" 10 | "time" 11 | 12 | humanize "github.com/dustin/go-humanize" 13 | gomail "gopkg.in/gomail.v2" 14 | ) 15 | 16 | func init() { 17 | rand.Seed(time.Now().Unix()) 18 | } 19 | 20 | type Mailer struct{} 21 | 22 | func NewMailer() *Mailer { 23 | return &Mailer{} 24 | } 25 | 26 | func (m *Mailer) Forgot(email, secret string) error { 27 | subject := "Password reset link" 28 | 29 | params := struct { 30 | HTTPHost string 31 | Email string 32 | Secret string 33 | }{ 34 | httpHost, 35 | email, 36 | secret, 37 | } 38 | return m.sendmail("forgot.html", email, subject, params) 39 | } 40 | 41 | func (m *Mailer) sendmail(tmpl, to, subject string, data interface{}) error { 42 | body, err := m.Render(tmpl, data) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | cfg := config.FindInfo().Mail 48 | 49 | from := cfg.From 50 | server := cfg.Server 51 | port := cfg.Port 52 | username := cfg.Username 53 | password := cfg.Password 54 | 55 | if from == "" { 56 | from = fmt.Sprintf("Frequency Analytics ", httpHost) 57 | } 58 | 59 | if server == "" { 60 | addrs, err := net.LookupMX(strings.Split(to, "@")[1]) 61 | if err != nil || len(addrs) == 0 { 62 | return err 63 | } 64 | server = strings.TrimSuffix(addrs[rand.Intn(len(addrs))].Host, ".") 65 | port = 25 66 | } 67 | 68 | d := gomail.NewDialer(server, port, username, password) 69 | s, err := d.Dial() 70 | if err != nil { 71 | return err 72 | } 73 | logger.Infof("sendmail from %q to %q %q via %s:%d", from, to, subject, server, port) 74 | 75 | msg := gomail.NewMessage() 76 | msg.SetHeader("From", from) 77 | msg.SetHeader("To", to) 78 | msg.SetHeader("Subject", subject) 79 | msg.SetBody("text/html", body) 80 | 81 | if err := gomail.Send(s, msg); err != nil { 82 | return fmt.Errorf("failed sending email: %s", err) 83 | } 84 | return nil 85 | } 86 | 87 | func (m *Mailer) Render(target string, data interface{}) (string, error) { 88 | t := template.New(target).Funcs(template.FuncMap{ 89 | "time": humanize.Time, 90 | }) 91 | for _, filename := range AssetNames() { 92 | if !strings.HasPrefix(filename, "email/") { 93 | continue 94 | } 95 | name := strings.TrimPrefix(filename, "email/") 96 | b, err := Asset(filename) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | var tmpl *template.Template 102 | if name == t.Name() { 103 | tmpl = t 104 | } else { 105 | tmpl = t.New(name) 106 | } 107 | if _, err := tmpl.Parse(string(b)); err != nil { 108 | return "", err 109 | } 110 | } 111 | var b bytes.Buffer 112 | if err := t.Execute(&b, data); err != nil { 113 | return "", err 114 | } 115 | return b.String(), nil 116 | } 117 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "encoding/xml" 10 | "flag" 11 | "fmt" 12 | "net" 13 | "net/http" 14 | "net/url" 15 | "os" 16 | "os/signal" 17 | "path/filepath" 18 | "runtime" 19 | "runtime/pprof" 20 | "strings" 21 | "sync" 22 | "time" 23 | 24 | log "github.com/Sirupsen/logrus" 25 | 26 | "github.com/crewjam/saml" 27 | "github.com/crewjam/saml/samlsp" 28 | "github.com/gorilla/securecookie" 29 | "github.com/julienschmidt/httprouter" 30 | "golang.org/x/crypto/acme/autocert" 31 | ) 32 | 33 | var ( 34 | // Flags 35 | cli = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 36 | 37 | // datadir 38 | datadir string 39 | 40 | // The version is set by the build command. 41 | version string 42 | 43 | // httpd 44 | httpAddr string 45 | httpHost string 46 | 47 | // Insecure http cookies (only recommended for internal LANs/VPNs) 48 | httpInsecure bool 49 | 50 | // set based on httpAddr 51 | httpIP string 52 | httpPort string 53 | 54 | // backlink 55 | backlink string 56 | 57 | // show version 58 | showVersion bool 59 | 60 | // show help 61 | showHelp bool 62 | 63 | // debug logging 64 | debug bool 65 | 66 | // Let's Encrypt 67 | letsencrypt bool 68 | 69 | // delete old files 70 | deleteOldFiles bool 71 | 72 | // compress old files 73 | compressOldFiles bool 74 | 75 | // HTTP read limit 76 | httpReadLimit int64 = 2 * (1024 * 1024) 77 | 78 | // securetoken 79 | securetoken *securecookie.SecureCookie 80 | 81 | // logger 82 | logger = log.New() 83 | 84 | // config 85 | config *Config 86 | 87 | // mailer 88 | mailer = NewMailer() 89 | 90 | // SAML 91 | samlSP *samlsp.Middleware 92 | 93 | // Error page HTML 94 | errorPageHTML = `Error

An error has occurred

` 95 | 96 | // Profiling 97 | cpuprofile string 98 | memprofile string 99 | 100 | // Signals 101 | sigint chan os.Signal 102 | 103 | // Timezone 104 | timezoneLocation *time.Location 105 | timezoneLock sync.Mutex 106 | ) 107 | 108 | func init() { 109 | cli.StringVar(&datadir, "datadir", "/data", "data dir") 110 | cli.StringVar(&backlink, "backlink", "", "backlink (optional)") 111 | cli.StringVar(&httpHost, "http-host", "", "HTTP host") 112 | cli.StringVar(&httpAddr, "http-addr", ":80", "HTTP listen address") 113 | cli.BoolVar(&showVersion, "version", false, "display version and exit") 114 | cli.BoolVar(&showHelp, "help", false, "display help and exit") 115 | cli.BoolVar(&debug, "debug", false, "debug mode") 116 | cli.BoolVar(&deleteOldFiles, "delete-old-files", true, "delete oldest files when storage exceeds 95% full") 117 | cli.BoolVar(&compressOldFiles, "compress-old-files", false, "compress files for past days") 118 | cli.BoolVar(&httpInsecure, "http-insecure", false, "enable sessions cookies for http (no https) not recommended") 119 | cli.BoolVar(&letsencrypt, "letsencrypt", true, "enable TLS using Let's Encrypt on port 443") 120 | cli.StringVar(&cpuprofile, "cpuprofile", "", "write cpu profile to `file`") 121 | cli.StringVar(&memprofile, "memprofile", "", "write mem profile to `file`") 122 | } 123 | 124 | func main() { 125 | var err error 126 | 127 | cli.Parse(os.Args[1:]) 128 | usage := func(msg string) { 129 | if msg != "" { 130 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg) 131 | } 132 | fmt.Fprintf(os.Stderr, "Usage: %s --http-host analytics.example.com\n\n", os.Args[0]) 133 | cli.PrintDefaults() 134 | } 135 | 136 | if showHelp { 137 | usage("Help info") 138 | os.Exit(0) 139 | } 140 | 141 | if showVersion { 142 | fmt.Printf("Frequency Analytics %s\n", version) 143 | os.Exit(0) 144 | } 145 | 146 | // http host 147 | if httpHost == "" { 148 | usage("the --http-host flag is required") 149 | os.Exit(1) 150 | } 151 | 152 | // debug logging 153 | logger.Out = os.Stdout 154 | if debug { 155 | logger.SetLevel(log.DebugLevel) 156 | } 157 | logger.Debugf("debug logging is enabled") 158 | 159 | // http port 160 | httpIP, httpPort, err := net.SplitHostPort(httpAddr) 161 | if err != nil { 162 | usage("invalid --http-addr: " + err.Error()) 163 | } 164 | 165 | // Handle SIGINT 166 | sigint = make(chan os.Signal, 1) 167 | signal.Notify(sigint, os.Interrupt) 168 | go func() { 169 | <-sigint 170 | if cpuprofile != "" { 171 | pprof.StopCPUProfile() 172 | } 173 | os.Exit(0) 174 | }() 175 | 176 | // Run file manager. 177 | go fileman() 178 | 179 | if cpuprofile != "" { 180 | f, err := os.Create(cpuprofile) 181 | if err != nil { 182 | logger.Fatalf("could not create CPU profile: %s", err) 183 | } 184 | if err := pprof.StartCPUProfile(f); err != nil { 185 | logger.Fatalf("could not start CPU profile: %s", err) 186 | } 187 | defer pprof.StopCPUProfile() 188 | } 189 | 190 | if memprofile != "" { 191 | f, err := os.Create(memprofile) 192 | if err != nil { 193 | logger.Fatalf("could not create memory profile: %s", err) 194 | } 195 | runtime.GC() 196 | if err := pprof.WriteHeapProfile(f); err != nil { 197 | logger.Fatalf("could not write memory profile: %s", err) 198 | } 199 | f.Close() 200 | } 201 | 202 | // config 203 | config, err = NewConfig("config.json") 204 | if err != nil { 205 | logger.Fatal(err) 206 | } 207 | info := config.FindInfo() 208 | 209 | // Set timezone. 210 | if err := setTimezone(info.Location); err != nil { 211 | logger.Warnf("failed to set timezone (apt-get install tzdata?): %s", err) 212 | } 213 | 214 | // Secure token 215 | securetoken = securecookie.New([]byte(config.FindInfo().HashKey), []byte(config.FindInfo().BlockKey)) 216 | 217 | // Configure SAML if metadata is present. 218 | if len(config.FindInfo().SAML.IDPMetadata) > 0 { 219 | if err := configureSAML(); err != nil { 220 | logger.Warnf("configuring SAML failed: %s", err) 221 | } 222 | } 223 | 224 | // 225 | // Routes 226 | // 227 | r := &httprouter.Router{} 228 | r.GET("/", Log(WebHandler(indexHandler, "index"))) 229 | 230 | // SAML 231 | r.GET("/sso", Log(ssoHandler)) 232 | r.GET("/saml/metadata", Log(samlHandler)) 233 | r.POST("/saml/metadata", Log(samlHandler)) 234 | r.GET("/saml/acs", Log(samlHandler)) 235 | r.POST("/saml/acs", Log(samlHandler)) 236 | 237 | r.GET("/domain", Log(WebHandler(domainHandler, "domain"))) 238 | 239 | r.GET("/frequency.js", WebHandler(analyticsHandler, "analytics")) 240 | r.GET("/analytics.js", WebHandler(analyticsHandler, "analytics")) 241 | r.GET("/ping", WebHandler(pingHandler, "ping")) 242 | 243 | r.GET("/configure", Log(WebHandler(configureHandler, "configure"))) 244 | r.POST("/configure", Log(WebHandler(configureHandler, "configure"))) 245 | 246 | r.GET("/forgot", Log(WebHandler(forgotHandler, "forgot"))) 247 | r.POST("/forgot", Log(WebHandler(forgotHandler, "forgot"))) 248 | 249 | r.GET("/signin", Log(WebHandler(signinHandler, "signin"))) 250 | r.POST("/signin", Log(WebHandler(signinHandler, "signin"))) 251 | 252 | r.GET("/signout", Log(WebHandler(signoutHandler, "signout"))) 253 | 254 | r.GET("/settings", Log(WebHandler(settingsHandler, "settings"))) 255 | r.POST("/settings", Log(WebHandler(settingsHandler, "settings"))) 256 | 257 | r.GET("/help", Log(WebHandler(helpHandler, "help"))) 258 | 259 | // Properties 260 | r.GET("/property/add", Log(WebHandler(addPropertyHandler, "property/add"))) 261 | r.POST("/property/add", Log(WebHandler(addPropertyHandler, "property/add"))) 262 | r.GET("/property/dashboard/:property", Log(WebHandler(dashboardPropertyHandler, "property/dashboard"))) 263 | r.POST("/property/dashboard", Log(WebHandler(dashboardPropertyHandler, "property/dashboard"))) 264 | r.GET("/property/snippet/:property", Log(WebHandler(snippetPropertyHandler, "property/snippet"))) 265 | 266 | r.GET("/property/sources/:property", Log(WebHandler(sourcesPropertyHandler, "property/sources"))) 267 | r.POST("/property/sources", Log(WebHandler(sourcesPropertyHandler, "property/sources"))) 268 | 269 | r.GET("/property/pages/:property", Log(WebHandler(pagesPropertyHandler, "property/pages"))) 270 | r.POST("/property/pages", Log(WebHandler(pagesPropertyHandler, "property/pages"))) 271 | 272 | r.GET("/property/referrers/:property", Log(WebHandler(referrersPropertyHandler, "property/referrers"))) 273 | r.POST("/property/referrers", Log(WebHandler(referrersPropertyHandler, "property/referrers"))) 274 | 275 | r.GET("/property/platforms/:property", Log(WebHandler(platformsPropertyHandler, "property/platforms"))) 276 | r.POST("/property/platforms", Log(WebHandler(platformsPropertyHandler, "property/platforms"))) 277 | 278 | r.GET("/property/events/:property", Log(WebHandler(eventsPropertyHandler, "property/events"))) 279 | r.POST("/property/events", Log(WebHandler(eventsPropertyHandler, "property/events"))) 280 | 281 | /* r.GET("/property/chart/pageview/:property", Log(WebHandler(pageviewChartHandler, "property/chart/pageview"))) */ 282 | 283 | r.GET("/property/settings/:property", Log(WebHandler(settingsPropertyHandler, "property/settings"))) 284 | r.POST("/property/settings", Log(WebHandler(settingsPropertyHandler, "property/settings"))) 285 | 286 | r.GET("/property/delete/:property", Log(WebHandler(deletePropertyHandler, "property/delete"))) 287 | r.POST("/property/delete", Log(WebHandler(deletePropertyHandler, "property/delete"))) 288 | 289 | // Static assets 290 | r.GET("/static/*path", staticHandler) 291 | 292 | // 293 | // Server 294 | // 295 | httpTimeout := 1 * time.Hour 296 | maxHeaderBytes := 10 * (1024 * 1024) // 10 MB 297 | 298 | // Plain text web server for use behind a reverse proxy. 299 | if !letsencrypt { 300 | httpd := &http.Server{ 301 | Handler: r, 302 | Addr: net.JoinHostPort(httpIP, httpPort), 303 | WriteTimeout: httpTimeout, 304 | ReadTimeout: httpTimeout, 305 | MaxHeaderBytes: maxHeaderBytes, 306 | } 307 | hostport := net.JoinHostPort(httpHost, httpPort) 308 | if httpPort == "80" { 309 | hostport = httpHost 310 | } 311 | logger.Infof("Frequency Analytics version: %s %s", version, &url.URL{ 312 | Scheme: "http", 313 | Host: hostport, 314 | Path: "/", 315 | }) 316 | logger.Fatal(httpd.ListenAndServe()) 317 | } 318 | 319 | // Let's Encrypt TLS mode 320 | 321 | // autocert 322 | certmanager := autocert.Manager{ 323 | Prompt: autocert.AcceptTOS, 324 | Cache: autocert.DirCache(filepath.Join(datadir, "letsencrypt")), 325 | HostPolicy: func(_ context.Context, host string) error { 326 | host = strings.TrimPrefix(host, "www.") 327 | if host == httpHost { 328 | return nil 329 | } 330 | if host == config.FindInfo().Domain { 331 | return nil 332 | } 333 | return fmt.Errorf("acme/autocert: host %q not permitted by HostPolicy", host) 334 | }, 335 | } 336 | 337 | // http redirect to https and Let's Encrypt auth 338 | go func() { 339 | redir := httprouter.New() 340 | redir.GET("/*path", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 341 | r.URL.Scheme = "https" 342 | r.URL.Host = net.JoinHostPort(httpHost, httpPort) 343 | http.Redirect(w, r, r.URL.String(), http.StatusFound) 344 | }) 345 | 346 | httpd := &http.Server{ 347 | Handler: certmanager.HTTPHandler(redir), 348 | Addr: net.JoinHostPort(httpIP, "80"), 349 | WriteTimeout: httpTimeout, 350 | ReadTimeout: httpTimeout, 351 | MaxHeaderBytes: maxHeaderBytes, 352 | } 353 | if err := httpd.ListenAndServe(); err != nil { 354 | logger.Fatalf("http server on port 80 failed: %s", err) 355 | } 356 | }() 357 | // TLS 358 | tlsConfig := tls.Config{ 359 | GetCertificate: certmanager.GetCertificate, 360 | NextProtos: []string{"http/1.1"}, 361 | Rand: rand.Reader, 362 | PreferServerCipherSuites: true, 363 | MinVersion: tls.VersionTLS12, 364 | CipherSuites: []uint16{ 365 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 366 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 367 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 368 | 369 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 370 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 371 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 372 | }, 373 | } 374 | 375 | // Override default for TLS. 376 | if httpPort == "80" { 377 | httpPort = "443" 378 | httpAddr = net.JoinHostPort(httpIP, httpPort) 379 | } 380 | 381 | httpsd := &http.Server{ 382 | Handler: r, 383 | Addr: httpAddr, 384 | WriteTimeout: httpTimeout, 385 | ReadTimeout: httpTimeout, 386 | MaxHeaderBytes: maxHeaderBytes, 387 | } 388 | 389 | // Enable TCP keep alives on the TLS connection. 390 | tcpListener, err := net.Listen("tcp", httpAddr) 391 | if err != nil { 392 | logger.Fatalf("listen failed: %s", err) 393 | return 394 | } 395 | tlsListener := tls.NewListener(tcpKeepAliveListener{tcpListener.(*net.TCPListener)}, &tlsConfig) 396 | 397 | hostport := net.JoinHostPort(httpHost, httpPort) 398 | if httpPort == "443" { 399 | hostport = httpHost 400 | } 401 | logger.Infof("Frequency Analytics version: %s %s", version, &url.URL{ 402 | Scheme: "https", 403 | Host: hostport, 404 | Path: "/", 405 | }) 406 | logger.Fatal(httpsd.Serve(tlsListener)) 407 | } 408 | 409 | type tcpKeepAliveListener struct { 410 | *net.TCPListener 411 | } 412 | 413 | func (l tcpKeepAliveListener) Accept() (c net.Conn, err error) { 414 | tc, err := l.AcceptTCP() 415 | if err != nil { 416 | return 417 | } 418 | tc.SetKeepAlive(true) 419 | tc.SetKeepAlivePeriod(10 * time.Minute) 420 | return tc, nil 421 | } 422 | 423 | func setTimezone(name string) error { 424 | timezoneLock.Lock() 425 | defer timezoneLock.Unlock() 426 | 427 | location, err := time.LoadLocation(name) 428 | if err != nil { 429 | timezoneLocation = time.UTC 430 | return err 431 | } 432 | timezoneLocation = location 433 | return nil 434 | } 435 | 436 | func getTimezone() *time.Location { 437 | timezoneLock.Lock() 438 | defer timezoneLock.Unlock() 439 | return timezoneLocation 440 | } 441 | 442 | func configureSAML() error { 443 | info := config.FindInfo() 444 | 445 | if len(info.SAML.IDPMetadata) == 0 { 446 | return fmt.Errorf("no IDP metadata") 447 | } 448 | entity := &saml.EntityDescriptor{} 449 | err := xml.Unmarshal([]byte(info.SAML.IDPMetadata), entity) 450 | 451 | if err != nil && err.Error() == "expected element type but have " { 452 | entities := &saml.EntitiesDescriptor{} 453 | if err := xml.Unmarshal([]byte(info.SAML.IDPMetadata), entities); err != nil { 454 | return err 455 | } 456 | 457 | err = fmt.Errorf("no entity found with IDPSSODescriptor") 458 | for i, e := range entities.EntityDescriptors { 459 | if len(e.IDPSSODescriptors) > 0 { 460 | entity = &entities.EntityDescriptors[i] 461 | err = nil 462 | } 463 | } 464 | } 465 | if err != nil { 466 | return err 467 | } 468 | 469 | keyPair, err := tls.X509KeyPair(info.SAML.Certificate, info.SAML.PrivateKey) 470 | if err != nil { 471 | return fmt.Errorf("failed to load SAML keypair: %s", err) 472 | } 473 | 474 | keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]) 475 | if err != nil { 476 | return fmt.Errorf("failed to parse SAML certificate: %s", err) 477 | } 478 | 479 | rootURL := url.URL{ 480 | Scheme: "https", 481 | Host: BestDomain(), 482 | Path: "/", 483 | } 484 | 485 | newsp, err := samlsp.New(samlsp.Options{ 486 | URL: rootURL, 487 | Key: keyPair.PrivateKey.(*rsa.PrivateKey), 488 | Certificate: keyPair.Leaf, 489 | IDPMetadata: entity, 490 | CookieName: SessionCookieNameSSO, 491 | CookieSecure: !httpInsecure, 492 | Logger: logger, 493 | AllowIDPInitiated: true, 494 | }) 495 | if err != nil { 496 | logger.Warnf("failed to configure SAML: %s", err) 497 | samlSP = nil 498 | return fmt.Errorf("failed to configure SAML: %s", err) 499 | } 500 | samlSP = newsp 501 | logger.Infof("successfully configured SAML") 502 | return nil 503 | } 504 | 505 | func BestDomain() string { 506 | domain := config.FindInfo().Domain 507 | if domain != "" { 508 | return domain 509 | } 510 | return httpHost 511 | } 512 | -------------------------------------------------------------------------------- /screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/screenshot1.png -------------------------------------------------------------------------------- /stat.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | useragent "github.com/mssola/user_agent" 11 | ) 12 | 13 | var ( 14 | seDomains = []string{ 15 | "google", 16 | "bing", 17 | "yahoo", 18 | "ask.com", 19 | "aol.com", 20 | "duckduckgo.com", 21 | "duck.com", 22 | } 23 | snsDomains = []string{ 24 | "reddit.com", 25 | "news.ycombinator.com", 26 | "facebook.com", 27 | "twitter.com", 28 | "instagram.com", 29 | "snapchat.com", 30 | "pinterest.com", 31 | "tumblr.com", 32 | } 33 | ) 34 | 35 | type Stats struct { 36 | Sources StatSources 37 | Pages StatPages 38 | PagesChart StatPagesChart 39 | VisitorsChart StatVisitorsChart 40 | Referrers StatReferrers 41 | Platforms StatPlatforms 42 | Events StatEvents 43 | } 44 | 45 | type Stat struct { 46 | Property string 47 | Start int64 48 | End int64 49 | } 50 | 51 | func NewStat(property string, start, end int64) *Stat { 52 | now := time.Now().In(getTimezone()) 53 | 54 | sy, sm, sd := time.Unix(start, 0).In(now.Location()).Date() 55 | ey, em, ed := time.Unix(end, 0).In(now.Location()).Date() 56 | 57 | start = time.Date(sy, sm, sd, 0, 0, 0, 0, now.Location()).Unix() 58 | end = time.Date(ey, em, ed, 0, 0, 0, 0, now.Location()).Unix() 59 | 60 | return &Stat{ 61 | Property: property, 62 | Start: start, 63 | End: end, 64 | } 65 | } 66 | 67 | // 68 | // StatPages 69 | // 70 | 71 | type StatPagesEntry struct { 72 | Path string 73 | Hits int 74 | } 75 | 76 | type StatPages []*StatPagesEntry 77 | 78 | func (pages StatPages) Len() int { return len(pages) } 79 | func (pages StatPages) Swap(i, j int) { pages[i], pages[j] = pages[j], pages[i] } 80 | func (pages StatPages) Less(i, j int) bool { 81 | if pages[i].Hits == pages[j].Hits { 82 | return pages[i].Path > pages[j].Path 83 | } 84 | return pages[i].Hits > pages[j].Hits 85 | } 86 | 87 | func (s *Stat) Pages(n int) StatPages { 88 | pagehit := make(map[string]int) 89 | 90 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 91 | pagehit[e.Path] += 1 92 | }) 93 | 94 | var pages StatPages 95 | 96 | for path, hits := range pagehit { 97 | pages = append(pages, &StatPagesEntry{path, hits}) 98 | } 99 | 100 | sort.Sort(pages) 101 | if len(pages) > n { 102 | pages = pages[:n] 103 | } 104 | return pages 105 | } 106 | 107 | // 108 | // StatSources 109 | // 110 | 111 | type StatSources struct { 112 | Direct float64 113 | Search float64 114 | Social float64 115 | Other float64 116 | } 117 | 118 | func (s *Stat) Sources() StatSources { 119 | var direct, search, social, other int 120 | 121 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 122 | // Direct 123 | if e.Referrer == "" { 124 | direct += 1 125 | return 126 | } 127 | 128 | referrer, err := url.Parse(e.Referrer) 129 | if err != nil { 130 | return 131 | } 132 | host := referrer.Host 133 | 134 | // Don't count ourselves as a referrer 135 | if e.Host == host { 136 | return 137 | } 138 | 139 | // Search 140 | for _, se := range seDomains { 141 | if strings.Contains(host, se) { 142 | search += 1 143 | return 144 | } 145 | } 146 | 147 | // Social 148 | for _, sns := range snsDomains { 149 | if strings.Contains(host, sns) { 150 | social += 1 151 | return 152 | } 153 | } 154 | 155 | // Other 156 | other += 1 157 | }) 158 | 159 | total := direct + search + social + other 160 | 161 | return StatSources{ 162 | Direct: (float64(direct) / float64(total)) * 100, 163 | Search: (float64(search) / float64(total)) * 100, 164 | Social: (float64(social) / float64(total)) * 100, 165 | Other: (float64(other) / float64(total)) * 100, 166 | } 167 | } 168 | 169 | // 170 | // StatReferrers 171 | // 172 | type StatReferrersEntry struct { 173 | Domain string 174 | Hits int 175 | } 176 | 177 | type StatReferrers []*StatReferrersEntry 178 | 179 | func (r StatReferrers) Len() int { return len(r) } 180 | func (r StatReferrers) Swap(i, j int) { r[i], r[j] = r[j], r[i] } 181 | func (r StatReferrers) Less(i, j int) bool { 182 | if r[i].Hits == r[j].Hits { 183 | return r[i].Domain > r[j].Domain 184 | } 185 | return r[i].Hits > r[j].Hits 186 | } 187 | 188 | func (s *Stat) Referrers(n int) StatReferrers { 189 | domainhit := make(map[string]int) 190 | 191 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 192 | referrer, err := url.Parse(e.Referrer) 193 | if err != nil { 194 | return 195 | } 196 | host := strings.TrimPrefix(referrer.Host, "www.") 197 | if host == "" { 198 | return 199 | } 200 | if host == strings.TrimPrefix(e.Host, "www.") { 201 | return 202 | } 203 | domainhit[host] += 1 204 | }) 205 | 206 | var r StatReferrers 207 | 208 | for domain, hits := range domainhit { 209 | r = append(r, &StatReferrersEntry{domain, hits}) 210 | } 211 | sort.Sort(r) 212 | if len(r) > n { 213 | r = r[:n] 214 | } 215 | return r 216 | } 217 | 218 | // 219 | // StatPlatforms 220 | // 221 | type StatPlatformsEntry struct { 222 | Name string 223 | Hits int 224 | } 225 | 226 | type StatPlatforms []*StatPlatformsEntry 227 | 228 | func (p StatPlatforms) Len() int { return len(p) } 229 | func (p StatPlatforms) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 230 | func (p StatPlatforms) Less(i, j int) bool { 231 | if p[i].Hits == p[j].Hits { 232 | return p[i].Name > p[j].Name 233 | } 234 | return p[i].Hits > p[j].Hits 235 | } 236 | 237 | func (s *Stat) Platforms(n int) StatPlatforms { 238 | browserhit := make(map[string]int) 239 | 240 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 241 | ua := useragent.New(e.UserAgent) 242 | name, _ := ua.Browser() 243 | name = normalizeBrowserName(name, ua.OS()) 244 | browserhit[name] += 1 245 | }) 246 | 247 | var p StatPlatforms 248 | 249 | for name, hits := range browserhit { 250 | p = append(p, &StatPlatformsEntry{name, hits}) 251 | } 252 | sort.Sort(p) 253 | if len(p) > n { 254 | p = p[:n] 255 | } 256 | return p 257 | } 258 | 259 | // 260 | // StatPagesChart 261 | // 262 | 263 | type StatPagesChartEntry struct { 264 | Time time.Time 265 | Hits int 266 | } 267 | 268 | type StatPagesChart []*StatPagesChartEntry 269 | 270 | func (p StatPagesChart) Len() int { return len(p) } 271 | func (p StatPagesChart) Swap(i, j int) { p[i], p[j] = p[j], p[i] } 272 | func (p StatPagesChart) Less(i, j int) bool { 273 | return p[i].Time.After(p[j].Time) 274 | } 275 | 276 | func (s *Stat) PagesChart(path string) StatPagesChart { 277 | th := make(map[int64]int) 278 | 279 | loc := getTimezone() 280 | 281 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 282 | if path != "" { 283 | if e.Path != path { 284 | return 285 | } 286 | } 287 | ts := time.Unix(e.Timestamp, 0).In(loc) 288 | y, m, d := ts.Date() 289 | key := time.Date(y, m, d, ts.Hour(), 0, 0, 0, ts.Location()).Unix() 290 | th[key] += 1 291 | }) 292 | 293 | var pages StatPagesChart 294 | for t, hits := range th { 295 | pages = append(pages, &StatPagesChartEntry{time.Unix(t, 0).In(loc), hits}) 296 | } 297 | sort.Sort(pages) 298 | return pages 299 | } 300 | 301 | // 302 | // StatEvents 303 | // 304 | type StatEvents []*Event 305 | 306 | func (e StatEvents) Len() int { return len(e) } 307 | func (e StatEvents) Swap(i, j int) { e[i], e[j] = e[j], e[i] } 308 | func (e StatEvents) Less(i, j int) bool { 309 | if e[i].Timestamp == e[j].Timestamp { 310 | return e[i].UserAgent < e[j].UserAgent 311 | } 312 | return e[i].Timestamp > e[j].Timestamp 313 | } 314 | 315 | func (s *Stat) Events(limit, page int) StatEvents { 316 | var events StatEvents 317 | for _, e := range eventList(s.Property, s.Start, s.End, limit, page) { 318 | events = append(events, e) 319 | } 320 | sort.Sort(events) 321 | return events 322 | } 323 | 324 | // 325 | // StatVisitorsChart 326 | // 327 | 328 | type StatVisitorsChartEntry struct { 329 | Time time.Time 330 | Count int 331 | } 332 | 333 | type StatVisitorsChart []*StatVisitorsChartEntry 334 | 335 | func (v StatVisitorsChart) Len() int { return len(v) } 336 | func (v StatVisitorsChart) Swap(i, j int) { v[i], v[j] = v[j], v[i] } 337 | func (v StatVisitorsChart) Less(i, j int) bool { 338 | return v[i].Time.After(v[j].Time) 339 | } 340 | 341 | func (s *Stat) VisitorsChart() StatVisitorsChart { 342 | tc := make(map[int64]map[string]int) 343 | 344 | loc := getTimezone() 345 | 346 | eventWalk(s.Property, s.Start, s.End, func(e *Event) { 347 | if e.UserAgent == "" { 348 | return 349 | } 350 | if e.IPAddress == "" { 351 | return 352 | } 353 | 354 | ts := time.Unix(e.Timestamp, 0).In(loc) 355 | y, m, d := ts.Date() 356 | tkey := time.Date(y, m, d, 0, 0, 0, 0, ts.Location()).Unix() 357 | if _, ok := tc[tkey]; !ok { 358 | tc[tkey] = make(map[string]int) 359 | } 360 | vkey := fmt.Sprintf("%s/%s", e.UserAgent, e.IPAddress) 361 | tc[tkey][vkey] = 1 362 | }) 363 | 364 | var visitors StatVisitorsChart 365 | for t, v := range tc { 366 | visitors = append(visitors, &StatVisitorsChartEntry{time.Unix(t, 0).In(loc), len(v)}) 367 | } 368 | sort.Sort(visitors) 369 | return visitors 370 | } 371 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/favicon.png -------------------------------------------------------------------------------- /static/fonts/B612-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/B612-Regular.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Italic.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /static/fonts/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frequencyanalytics/frequency/ef9bb032e7235f311fe9c4af47c84f7a1083b8ea/static/fonts/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /static/moment.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function d(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function h(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sDe(e)?(r=e+1,o-De(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(De(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),H("week","w"),H("isoWeek","W"),L("week",5),L("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=k(e)});I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),H("day","d"),H("weekday","e"),H("isoWeekday","E"),L("day",11),L("weekday",11),L("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=k(e)});var je="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var Ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var ze="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var $e=ae;var qe=ae;var Je=ae;function Be(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=de(o[t]),u[t]=de(u[t]),l[t]=de(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Qe(){return this.hours()%12||12}function Xe(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function Ke(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Qe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Qe.apply(this)+U(this.minutes(),2)+U(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+U(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+U(this.minutes(),2)+U(this.seconds(),2)}),Xe("a",!0),Xe("A",!1),H("hour","h"),L("hour",13),ue("a",Ke),ue("A",Ke),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=k(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=k(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=k(e.substr(0,s)),t[pe]=k(e.substr(s,2)),t[ve]=k(e.substr(i))});var et,tt=Te("Hours",!0),nt={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:He,monthsShort:Re,week:{dow:0,doy:6},weekdays:je,weekdaysMin:ze,weekdaysShort:Ze,meridiemParse:/[ap]\.?m?\.?/i},st={},it={};function rt(e){return e?e.toLowerCase().replace("_","-"):e}function at(e){var t=null;if(!st[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=et._abbr,require("./locale/"+e),ot(t)}catch(e){}return st[e]}function ot(e,t){var n;return e&&((n=l(t)?lt(e):ut(e,t))?et=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),et._abbr}function ut(e,t){if(null===t)return delete st[e],null;var n,s=nt;if(t.abbr=e,null!=st[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=st[e]._config;else if(null!=t.parentLocale)if(null!=st[t.parentLocale])s=st[t.parentLocale]._config;else{if(null==(n=at(t.parentLocale)))return it[t.parentLocale]||(it[t.parentLocale]=[]),it[t.parentLocale].push({name:e,config:t}),null;s=n._config}return st[e]=new P(b(s,t)),it[e]&&it[e].forEach(function(e){ut(e.name,e.config)}),ot(e),st[e]}function lt(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return et;if(!o(e)){if(t=at(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return et}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ht(e._a[me],s[me]),(e._dayOfYear>De(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[pe]&&0===e._a[ve]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o=new Date(e,t,n,s,i,r,a);return e<100&&0<=e&&isFinite(o.getFullYear())&&o.setFullYear(e),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var ft=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,mt=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/Z|[+-]\d\d(?::?\d\d)?/,yt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],gt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function vt(e){var t,n,s,i,r,a,o=e._i,u=ft.exec(o)||mt.exec(o);if(u){for(g(e).iso=!0,t=0,n=yt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},ln.isLocal=function(){return!!this.isValid()&&!this._isUTC},ln.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},ln.isUtc=Vt,ln.isUTC=Vt,ln.zoneAbbr=function(){return this._isUTC?"UTC":""},ln.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},ln.dates=n("dates accessor is deprecated. Use date instead.",nn),ln.months=n("months accessor is deprecated. Use month instead",Fe),ln.years=n("years accessor is deprecated. Use year instead",Oe),ln.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),ln.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Yt(e))._a){var t=e._isUTC?y(e._a):Tt(e._a);this._isDSTShifted=this.isValid()&&0