├── css ├── signin.scss ├── tracklog.scss ├── bootstrap.scss ├── logs.scss ├── layout.scss └── heartrate.scss ├── version.go ├── .babelrc ├── script ├── build-docker ├── fmt ├── build ├── build-aci └── build-release ├── doc ├── screenshots │ ├── log.jpg │ ├── logs.jpg │ ├── log-thumbnail.jpg │ └── logs-thumbnail.jpg └── config.md ├── js ├── Dispatcher.js ├── components │ ├── LogName.js │ ├── LogMap.js │ ├── LogCharts.js │ ├── LogHeartrateChart.js │ ├── LogElevationChart.js │ ├── LogSpeedChart.js │ ├── LogTags.js │ ├── LogHeartrateZones.js │ ├── LogDetails.js │ └── Log.js ├── stores │ └── LogStore.js ├── upload.js └── tracklog.js ├── .gitignore ├── pkg ├── geo │ ├── conversion.go │ └── equirectangular.go ├── models │ ├── user.go │ ├── track.go │ ├── point.go │ └── log.go ├── server │ ├── breadcrumb.go │ ├── dashboard.go │ ├── context.go │ ├── sessions.go │ ├── server.go │ └── logs.go ├── utils │ ├── haversine.go │ ├── utils.go │ └── utils_test.go ├── db │ ├── interface.go │ ├── postgres.sql │ └── postgres.go ├── rdp │ ├── rdp.go │ └── rdp_test.go ├── config │ ├── config.go │ └── config_test.go └── heartrate │ └── heartrate.go ├── templates ├── log.html ├── signin.html ├── dashboard.html ├── layout.html └── logs.html ├── config.toml.example ├── go.mod ├── Dockerfile ├── LICENSE ├── package.json ├── cmd ├── server │ └── main.go └── control │ ├── import.go │ ├── main.go │ └── user.go ├── README.md └── go.sum /css/signin.scss: -------------------------------------------------------------------------------- 1 | #signin { 2 | margin-top: 80px; 3 | } 4 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package tracklog 2 | 3 | const Version = "0.1.0" 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /script/build-docker: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | docker build -t thcyron/tracklog . 4 | -------------------------------------------------------------------------------- /doc/screenshots/log.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/log.jpg -------------------------------------------------------------------------------- /script/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | find pkg cmd -type f -name '*.go' -print0 | xargs -0 goimports -w 3 | -------------------------------------------------------------------------------- /doc/screenshots/logs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/logs.jpg -------------------------------------------------------------------------------- /doc/screenshots/log-thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/log-thumbnail.jpg -------------------------------------------------------------------------------- /js/Dispatcher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import {Dispatcher} from "flux"; 4 | 5 | export default new Dispatcher(); 6 | -------------------------------------------------------------------------------- /doc/screenshots/logs-thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thcyron/tracklog/HEAD/doc/screenshots/logs-thumbnail.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/control/control 2 | cmd/server/server 3 | node_modules/ 4 | npm-debug.log 5 | public/ 6 | dist/ 7 | config.toml 8 | *.aci 9 | -------------------------------------------------------------------------------- /pkg/geo/conversion.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import "math" 4 | 5 | func ToRad(deg float64) float64 { 6 | return deg * math.Pi / 180 7 | } 8 | -------------------------------------------------------------------------------- /templates/log.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 6 | -------------------------------------------------------------------------------- /pkg/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type User struct { 4 | ID int 5 | Username string 6 | Password string 7 | PasswordVersion int 8 | } 9 | -------------------------------------------------------------------------------- /pkg/geo/equirectangular.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import ( 4 | "math" 5 | ) 6 | 7 | func EquirectangularProjection(λ, φ, φ1 float64) (x, y float64) { 8 | return λ * math.Cos(φ1), φ 9 | } 10 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | export CGO_ENABLED=0 4 | (cd cmd/server && go build) 5 | (cd cmd/control && go build) 6 | 7 | if [ "$1" = "production" ]; then 8 | npm run production:build 9 | else 10 | npm run build 11 | fi 12 | -------------------------------------------------------------------------------- /pkg/models/track.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | type Track struct { 6 | ID int 7 | LogID int 8 | Name string 9 | Start time.Time 10 | End time.Time 11 | Duration uint 12 | Distance uint 13 | Points []*Point 14 | } 15 | -------------------------------------------------------------------------------- /css/tracklog.scss: -------------------------------------------------------------------------------- 1 | @import "./bootstrap"; 2 | 3 | @import "../node_modules/bootstrap-sass/assets/stylesheets/_bootstrap"; 4 | @import "../node_modules/leaflet/dist/leaflet"; 5 | 6 | @import "./layout"; 7 | @import "./signin"; 8 | @import "./logs"; 9 | @import "./heartrate"; 10 | -------------------------------------------------------------------------------- /config.toml.example: -------------------------------------------------------------------------------- 1 | [server] 2 | development = true 3 | listen_address = ":8080" 4 | csrf_auth_key = "SECRETSECRETSECRETSECRETSECRETSE" 5 | signing_key = "SECRETSECRETSECRETSECRETSECRETSECRET" 6 | mapbox_access_token = "" 7 | 8 | [db] 9 | driver = "postgres" 10 | dsn = "dbname=tracklog sslmode=disable" 11 | -------------------------------------------------------------------------------- /css/bootstrap.scss: -------------------------------------------------------------------------------- 1 | $brand-primary: rgb(30, 179, 0); 2 | $link-hover-color: $brand-primary; 3 | $input-border-focus: $brand-primary; 4 | 5 | $breadcrumb-bg: transparent; 6 | $breadcrumb-padding-vertical: 0; 7 | $breadcrumb-padding-horizontal: 0; 8 | 9 | $font-size-h1: 28px; 10 | $font-size-h2: 24px; 11 | $font-size-h3: 20px; 12 | -------------------------------------------------------------------------------- /pkg/server/breadcrumb.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | type BreadcrumbItem struct { 4 | Title string 5 | Path string 6 | Active bool 7 | } 8 | 9 | type Breadcrumb struct { 10 | items []BreadcrumbItem 11 | } 12 | 13 | func (b *Breadcrumb) Add(title, path string, active bool) { 14 | b.items = append(b.items, BreadcrumbItem{ 15 | Title: title, 16 | Path: path, 17 | Active: active, 18 | }) 19 | } 20 | 21 | func (b *Breadcrumb) Items() []BreadcrumbItem { 22 | return b.items 23 | } 24 | -------------------------------------------------------------------------------- /script/build-aci: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ACBUILD=${ACBUILD:-acbuild} 3 | 4 | set -e 5 | trap "$ACBUILD end" EXIT 6 | 7 | $ACBUILD begin 8 | $ACBUILD set-name thcyron.de/tracklog 9 | $ACBUILD copy cmd/server/server /bin/server 10 | $ACBUILD copy cmd/control/control /bin/control 11 | $ACBUILD copy templates /templates 12 | $ACBUILD copy public /public 13 | $ACBUILD set-exec /bin/server 14 | $ACBUILD mount add config /config.toml --read-only 15 | $ACBUILD write --overwrite tracklog-latest-linux-amd64.aci 16 | -------------------------------------------------------------------------------- /css/logs.scss: -------------------------------------------------------------------------------- 1 | .logs-years { 2 | .pagination { 3 | margin: 0 0 20px 0; 4 | } 5 | } 6 | 7 | .log-name { 8 | margin: 0 0 20px 0; 9 | } 10 | 11 | .log-name-edit-field { 12 | margin: 0 0 20px 0; 13 | } 14 | 15 | .log-map { 16 | height: 600px; 17 | margin-bottom: 20px; 18 | } 19 | 20 | .log-charts, .log-charts-tabs { 21 | margin-bottom: 20px; 22 | } 23 | 24 | .log-chart-chart { 25 | height: 300px; 26 | } 27 | 28 | .logs-upload-button-file { 29 | display: none !important; 30 | } 31 | -------------------------------------------------------------------------------- /pkg/utils/haversine.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "math" 4 | 5 | const earthRadius = 6371000 6 | 7 | func Haversine(lat1, lon1, lat2, lon2 float64) float64 { 8 | dLat := (lat2 - lat1) * (math.Pi / 180.0) 9 | dLon := (lon2 - lon1) * (math.Pi / 180.0) 10 | rlat1 := lat1 * (math.Pi / 180.0) 11 | rlat2 := lat2 * (math.Pi / 180.0) 12 | a1 := math.Sin(dLat/2) * math.Sin(dLat/2) 13 | a2 := math.Sin(dLon/2) * math.Sin(dLon/2) * math.Cos(rlat1) * math.Cos(rlat2) 14 | a := a1 + a2 15 | c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) 16 | return earthRadius * c 17 | } 18 | -------------------------------------------------------------------------------- /script/build-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [[ $# -ne 3 ]]; then 5 | echo "usage: build-release " >&2 6 | exit 2 7 | fi 8 | 9 | version=$1 10 | os=$2 11 | arch=$3 12 | 13 | GOOS=$os GOARCH=$arch ./script/build 14 | 15 | dir=$(mktemp -d ./tracklog.XXXXXXXX) 16 | trap "rm -rf $dir" EXIT 17 | 18 | dest=$dir/tracklog-$version 19 | mkdir $dest 20 | cp -r templates public config.toml.example $dest 21 | mkdir $dest/bin 22 | cp cmd/server/server $dest/bin/server 23 | cp cmd/control/control $dest/bin/control 24 | 25 | (cd $dir && tar czf - tracklog-$version) > tracklog-$version-$os-$arch.tar.gz 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/thcyron/tracklog 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/codegangsta/negroni v1.0.0 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/gorilla/context v1.1.1 9 | github.com/gorilla/csrf v1.6.0 10 | github.com/gorilla/handlers v1.4.2 11 | github.com/julienschmidt/httprouter v1.2.0 12 | github.com/kylelemons/godebug v1.1.0 // indirect 13 | github.com/lib/pq v1.2.0 14 | github.com/naoina/go-stringutil v0.1.0 // indirect 15 | github.com/naoina/toml v0.1.1 16 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc 17 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible 18 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 19 | ) 20 | -------------------------------------------------------------------------------- /js/components/LogName.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | import Dispatcher from "../Dispatcher"; 6 | 7 | export default class LogName extends React.Component { 8 | _onChange(event) { 9 | Dispatcher.dispatch({ 10 | type: "log-set-name", 11 | name: event.target.value, 12 | }); 13 | } 14 | 15 | render() { 16 | if (this.props.editing) { 17 | return ( 18 | 23 | ); 24 | } 25 | 26 | return ( 27 |

{this.props.log.get("name")}

28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as node-builder 2 | WORKDIR /tracklog 3 | COPY package.json .babelrc ./ 4 | RUN npm install 5 | COPY css css/ 6 | COPY js js/ 7 | RUN mkdir public && npm run production:build 8 | 9 | FROM golang:1.12 as go-builder 10 | WORKDIR /tracklog 11 | COPY go.mod go.sum ./ 12 | RUN go mod download 13 | COPY . . 14 | RUN CGO_ENABLED=0 go build -o /bin/tracklog-server ./cmd/server 15 | RUN CGO_ENABLED=0 go build -o /bin/tracklog-control ./cmd/control 16 | 17 | FROM alpine:3.10 18 | WORKDIR /tracklog 19 | COPY templates templates/ 20 | COPY --from=node-builder /tracklog/public public/ 21 | COPY --from=go-builder /bin/tracklog-server /bin/tracklog-server 22 | COPY --from=go-builder /bin/tracklog-control /bin/tracklog-control 23 | ENTRYPOINT ["/bin/tracklog-server"] 24 | -------------------------------------------------------------------------------- /js/stores/LogStore.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import {EventEmitter} from "events"; 4 | 5 | import Dispatcher from "../Dispatcher"; 6 | 7 | class LogStore extends EventEmitter { 8 | init(log) { 9 | this._log = log; 10 | } 11 | 12 | get log() { 13 | return this._log; 14 | } 15 | 16 | constructor() { 17 | super(); 18 | 19 | Dispatcher.register((action) => { 20 | switch (action.type) { 21 | case "log-set-name": 22 | this._log = this._log.set("name", action.name); 23 | break; 24 | case "log-set-tags": 25 | this._log = this._log.set("tags", action.tags); 26 | break; 27 | default: 28 | return; // do not emit change event if no action was triggered 29 | } 30 | 31 | this.emit("change"); 32 | }); 33 | } 34 | } 35 | 36 | export default new LogStore(); 37 | -------------------------------------------------------------------------------- /pkg/db/interface.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import "github.com/thcyron/tracklog/pkg/models" 4 | 5 | type DB interface { 6 | Open(dsn string) error 7 | 8 | UserByID(id int) (*models.User, error) 9 | UserByUsername(username string) (*models.User, error) 10 | AddUser(user *models.User) error 11 | UpdateUser(user *models.User) error 12 | DeleteUser(user *models.User) error 13 | 14 | RecentUserLogs(user *models.User, count int) ([]*models.Log, error) 15 | UserLogYears(user *models.User) ([]int, error) 16 | UserLogByID(user *models.User, id int) (*models.Log, error) 17 | UserLogsByYear(user *models.User, year int) ([]*models.Log, error) 18 | AddUserLog(user *models.User, log *models.Log) error 19 | UpdateLog(log *models.Log) error 20 | DeleteLog(log *models.Log) error 21 | } 22 | 23 | func Driver(name string) DB { 24 | switch name { 25 | case "postgres": 26 | return new(Postgres) 27 | default: 28 | return nil 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/rdp/rdp.go: -------------------------------------------------------------------------------- 1 | package rdp 2 | 3 | import "math" 4 | 5 | type Point struct { 6 | X, Y float64 7 | Data interface{} 8 | } 9 | 10 | // Reduce reduces points using the Ramer–Douglas–Peucker algorithm. 11 | func Reduce(points []Point, epsilon float64) []Point { 12 | if len(points) <= 2 || epsilon <= 0 { 13 | return points 14 | } 15 | 16 | var ( 17 | p, q = points[0], points[len(points)-1] 18 | dmax float64 19 | index int 20 | ) 21 | for i := 1; i <= len(points)-2; i++ { 22 | pp := points[i] 23 | d := distance(pp, p, q) 24 | if d > dmax { 25 | dmax = d 26 | index = i 27 | } 28 | } 29 | if index > 0 && dmax > epsilon { 30 | res1 := Reduce(points[0:index], epsilon) 31 | res2 := Reduce(points[index:], epsilon) 32 | return append(res1, res2...) 33 | } 34 | 35 | return []Point{p, q} 36 | } 37 | 38 | func distance(p0, p1, p2 Point) float64 { 39 | return math.Abs((p2.Y-p1.Y)*p0.X-(p2.X-p1.X)*p0.Y+p2.X*p1.Y-p2.Y*p1.X) / 40 | math.Sqrt(math.Pow(p2.Y-p1.Y, 2)+math.Pow(p2.X-p1.X, 2)) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | 7 | "github.com/naoina/toml" 8 | ) 9 | 10 | type Config struct { 11 | Server struct { 12 | Development bool `toml:"development"` 13 | ListenAddress string `toml:"listen_address"` 14 | CSRFAuthKey string `toml:"csrf_auth_key"` 15 | SigningKey string `toml:"signing_key"` 16 | MapboxAccessToken string `toml:"mapbox_access_token"` 17 | } `toml:"server"` 18 | DB struct { 19 | Driver string `toml:"driver"` 20 | DSN string `toml:"dsn"` 21 | } `toml:"db"` 22 | } 23 | 24 | func Read(r io.Reader) (*Config, error) { 25 | config := new(Config) 26 | if err := toml.NewDecoder(r).Decode(&config); err != nil { 27 | return nil, err 28 | } 29 | return config, nil 30 | } 31 | 32 | func Check(config *Config) error { 33 | if config.Server.CSRFAuthKey == "" { 34 | return errors.New("missing server.csrf_auth_key") 35 | } 36 | if config.Server.SigningKey == "" { 37 | return errors.New("missing server.signing_key") 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /css/layout.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding-bottom: 20px; 3 | } 4 | 5 | .navbar { 6 | margin-bottom: 0; 7 | } 8 | 9 | .navbar-brand { 10 | color: $brand-primary !important; 11 | } 12 | 13 | .navbar-upload-log-button { 14 | padding-left: 15px; 15 | } 16 | 17 | .breadcrumb-container { 18 | border-bottom: 1px solid $navbar-default-border; 19 | margin-bottom: 20px; 20 | padding: 10px 0; 21 | 22 | .breadcrumb { 23 | margin: 0; 24 | } 25 | } 26 | 27 | #footer { 28 | border-top: 1px solid $navbar-default-border; 29 | padding-top: 20px; 30 | margin: 20px 0 0 0; 31 | color: $text-muted; 32 | 33 | ul { 34 | margin-bottom: 0; 35 | } 36 | } 37 | 38 | $dl-horizontal-small-offset: 85px; 39 | 40 | .dl-horizontal-small { 41 | dt { 42 | float: left; 43 | width: ($dl-horizontal-small-offset - 10px); 44 | clear: left; 45 | text-align: right; 46 | @include text-overflow; 47 | } 48 | dd { 49 | margin-left: $dl-horizontal-small-offset; 50 | @include clearfix; 51 | } 52 | } 53 | 54 | .kill-bottom-margin > :last-child { 55 | margin-bottom: 0; 56 | } 57 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | type Duration uint // seconds 6 | 7 | func (d Duration) String() string { 8 | seconds := d % 60 9 | minutes := int(d/60) % 60 10 | hours := int(d / 60 / 60) 11 | 12 | if hours < 24 { 13 | return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) 14 | } 15 | 16 | days := int(hours / 24) 17 | hours %= 24 18 | return fmt.Sprintf("%dd %d:%02d:%02d", days, hours, minutes, seconds) 19 | } 20 | 21 | type Distance uint // meters 22 | 23 | func (d Distance) String() string { 24 | km := float64(d) / 1000.0 25 | return fmt.Sprintf("%.02f km", km) 26 | } 27 | 28 | type Speed float64 // meters per second 29 | 30 | func (s Speed) String() string { 31 | kmh := s / 1000.0 * 3600.0 32 | return fmt.Sprintf("%.02f km/h", kmh) 33 | } 34 | 35 | func (s Speed) Pace() Pace { 36 | return Pace(1 / s) 37 | } 38 | 39 | type Pace float64 // seconds per meter 40 | 41 | func (p Pace) String() string { 42 | secPerKM := p * 1000 43 | min := int(secPerKM / 60) 44 | sec := int(secPerKM) % 60 45 | return fmt.Sprintf("%d:%02d min/km", min, sec) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/models/point.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/thcyron/gpx" 8 | "github.com/thcyron/tracklog/pkg/utils" 9 | ) 10 | 11 | type Point struct { 12 | ID int 13 | TrackID int 14 | Latitude float64 15 | Longitude float64 16 | Time time.Time 17 | Elevation float64 18 | Heartrate uint 19 | } 20 | 21 | func NewPoint(point gpx.Point) *Point { 22 | p := &Point{ 23 | Latitude: point.Latitude, 24 | Longitude: point.Longitude, 25 | Time: point.Time, 26 | Elevation: point.Elevation, 27 | } 28 | 29 | ge, err := gpx.ParseGarminTrackPointExtension(point.Extensions) 30 | if err == nil { 31 | p.Heartrate = ge.HeartRate 32 | } 33 | 34 | return p 35 | } 36 | 37 | func (p *Point) DistanceTo(p2 *Point) float64 { 38 | return utils.Haversine(p.Latitude, p.Longitude, p2.Latitude, p2.Longitude) 39 | } 40 | 41 | func (p *Point) SpeedTo(p2 *Point) float64 { 42 | dist := p.DistanceTo(p2) 43 | dur := p.Time.Sub(p2.Time).Seconds() 44 | speed := float64(dist) / float64(dur) 45 | if math.IsInf(speed, 0) { 46 | speed = 0 47 | } 48 | return speed 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Thomas Cyron 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 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tracklog", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "build": "npm run build:css && npm run build:js", 6 | "build:css": "node-sass --output-style compressed css/tracklog.scss public/app.css", 7 | "build:js": "browserify -t babelify -d js/tracklog.js > public/app.js", 8 | "production:build": "npm run build:css & npm run production:build:js", 9 | "production:build:js": "NODE_ENV=production browserify -t babelify js/tracklog.js | uglifyjs -m -c > public/app.js" 10 | }, 11 | "devDependencies": { 12 | "@babel/cli": "^7.4.4", 13 | "@babel/core": "^7.4.4", 14 | "@babel/preset-env": "^7.4.4", 15 | "@babel/preset-react": "^7.0.0", 16 | "babelify": "^10.0.0", 17 | "browserify": "^16.2.3", 18 | "uglify-js": "^3.5.11", 19 | "node-sass": "^4.12.0" 20 | }, 21 | "dependencies": { 22 | "react": "^16.8.6", 23 | "react-dom": "^16.8.6", 24 | "flux": "^3.1.3", 25 | "bootstrap-sass": "^3.4.1", 26 | "leaflet": "^1.5.1", 27 | "whatwg-fetch": "^3.0.0", 28 | "immutable": "^3.8.2", 29 | "highcharts": "^7.1.1", 30 | "classnames": "^2.2.6" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | var data = []byte(`[server] 9 | development = true 10 | listen_address = ":8080" 11 | csrf_auth_key = "secr" 12 | signing_key = "secret" 13 | mapbox_access_token = "abc" 14 | 15 | [db] 16 | driver = "postgres" 17 | dsn = "dbname=tracklog" 18 | `) 19 | 20 | func TestRead(t *testing.T) { 21 | c, err := Read(bytes.NewReader(data)) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | if expected := true; c.Server.Development != expected { 27 | t.Errorf("expected Server.Development = %v; got %v", expected, c.Server.Development) 28 | } 29 | if expected := ":8080"; c.Server.ListenAddress != expected { 30 | t.Errorf("expected Server.ListenAddress = %q; got %q", expected, c.Server.ListenAddress) 31 | } 32 | if expected := "secr"; c.Server.CSRFAuthKey != expected { 33 | t.Errorf("expected Server.CSRFAuthKey = %q; got %q", expected, c.Server.CSRFAuthKey) 34 | } 35 | 36 | if expected := "postgres"; c.DB.Driver != expected { 37 | t.Errorf("expected DB.Driver = %q; got %q", expected, c.DB.Driver) 38 | } 39 | if expected := "dbname=tracklog"; c.DB.DSN != expected { 40 | t.Errorf("expected DB.DSN = %q; got %q", expected, c.DB.DSN) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/thcyron/tracklog/pkg/config" 11 | "github.com/thcyron/tracklog/pkg/db" 12 | "github.com/thcyron/tracklog/pkg/server" 13 | ) 14 | 15 | var ( 16 | configFile = flag.String("config", "config.toml", "config file") 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | 22 | f, err := os.Open(*configFile) 23 | if err != nil { 24 | die("cannot open config file: %s", err) 25 | } 26 | defer f.Close() 27 | conf, err := config.Read(f) 28 | if err != nil { 29 | die("cannot read config file: %s", err) 30 | } 31 | if err := config.Check(conf); err != nil { 32 | die("invalid config file: %s", err) 33 | } 34 | 35 | DB := db.Driver(conf.DB.Driver) 36 | if DB == nil { 37 | die("unknown database driver %s", conf.DB.Driver) 38 | } 39 | if err := DB.Open(conf.DB.DSN); err != nil { 40 | die("cannot open database: %s", err) 41 | } 42 | 43 | s, err := server.New(conf, DB) 44 | if err != nil { 45 | die("%s", err) 46 | } 47 | 48 | log.Fatalln(http.ListenAndServe(conf.Server.ListenAddress, s)) 49 | } 50 | 51 | func die(format string, args ...interface{}) { 52 | fmt.Fprintf(os.Stderr, "server: "+fmt.Sprintf(format, args...)+"\n") 53 | os.Exit(1) 54 | } 55 | -------------------------------------------------------------------------------- /js/upload.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | export function uploadLog({file}) { 4 | return new Promise((resolve, reject) => { 5 | const reader = new FileReader(); 6 | 7 | reader.onload = (event) => { 8 | window.fetch("/logs", { 9 | method: "POST", 10 | credentials: "same-origin", 11 | headers: { 12 | "Content-Type": "application/json; charset=utf-8", 13 | "X-CSRF-Token": Tracklog.csrfToken, 14 | }, 15 | body: JSON.stringify({ 16 | "filename": file.name, 17 | "gpx": reader.result, 18 | }), 19 | }) 20 | .then((data) => { 21 | return data.json(); 22 | }) 23 | .then((json) => { 24 | if (json.id) { 25 | resolve({ 26 | id: json.id, 27 | }); 28 | } else { 29 | reject(new Error("bad response from server")); 30 | } 31 | }) 32 | .catch((err) => { 33 | reject(err); 34 | }); 35 | }; 36 | 37 | reader.onerror = () => { 38 | reject(reader.error); 39 | }; 40 | 41 | reader.readAsText(file); 42 | }); 43 | } 44 | 45 | export function uploadLogs({files}) { 46 | return Promise.all(files.map((file) => { 47 | return uploadLog({ 48 | file: file, 49 | }); 50 | })); 51 | } 52 | -------------------------------------------------------------------------------- /doc/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | Tracklog is configured via a TOML file. 4 | 5 | ## `server` Section 6 | 7 | The `server` section configures the server. 8 | 9 | | Key | Required | Description | 10 | | --------------------- | -------- | ------------------------------------------------------ | 11 | | `development` | no | Enables development mode when set to `true` | 12 | | `listen_address` | yes | Listen address of the HTTP server | 13 | | `csrf_auth_key` | yes | Secret key used for CSRF protection (must be 32 bytes) | 14 | | `signing_key` | yes | Secret key used for signing tokens | 15 | | `mapbox_access_token` | no | Mapbox access token for Mapbox maps | 16 | 17 | ## `db` section 18 | 19 | The `db` section configures the database. 20 | 21 | | Key | Required | Description | 22 | | --------------------- | -------- | ------------------------------------------------------ | 23 | | `driver` | yes | Driver | 24 | | `dsn` | yes | Data source name | 25 | -------------------------------------------------------------------------------- /pkg/server/dashboard.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/thcyron/tracklog/pkg/utils" 7 | ) 8 | 9 | const dashboardRecentLogsCount = 5 10 | 11 | type dashboardData struct { 12 | Logs []dashboardLog 13 | } 14 | 15 | type dashboardLog struct { 16 | ID int 17 | Name string 18 | Date string 19 | Duration string 20 | Distance string 21 | Tags []string 22 | } 23 | 24 | func (s *Server) HandleDashboard(w http.ResponseWriter, r *http.Request) { 25 | ctx := NewContext(r, w) 26 | 27 | user := ctx.User() 28 | if user == nil { 29 | s.redirectToSignIn(w, r) 30 | return 31 | } 32 | 33 | logs, err := s.db.RecentUserLogs(user, dashboardRecentLogsCount) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | data := new(dashboardData) 39 | for _, log := range logs { 40 | data.Logs = append(data.Logs, dashboardLog{ 41 | ID: log.ID, 42 | Name: log.Name, 43 | Date: log.Start.Format(logTimeFormat), 44 | Duration: utils.Duration(log.Duration).String(), 45 | Distance: utils.Distance(log.Distance).String(), 46 | Tags: log.Tags, 47 | }) 48 | } 49 | 50 | ctx.SetTitle("Dashboard") 51 | ctx.Breadcrumb().Add("Dashboard", "", true) 52 | ctx.SetActiveTab("dashboard") 53 | ctx.SetData(data) 54 | 55 | s.render(w, r, "dashboard") 56 | } 57 | -------------------------------------------------------------------------------- /templates/signin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sign In – Tracklog 6 | 7 | 8 | 11 | 12 | 13 |
14 |
15 |
16 | {{if .Data.Alert}} 17 |
18 |

{{.Data.Alert}}

19 |
20 | {{end}} 21 |
22 |
23 |
24 |
25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 |
33 |
34 |
35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /css/heartrate.scss: -------------------------------------------------------------------------------- 1 | $heart-rate-red-color: $progress-bar-danger-bg; 2 | $heart-rate-anaerobic-color: $progress-bar-warning-bg; 3 | $heart-rate-aerobic-color: lighten($progress-bar-success-bg, 10%); 4 | $heart-rate-fatburning-color: darken($progress-bar-success-bg, 10%); 5 | $heart-rate-easy-color: $progress-bar-info-bg; 6 | $heart-rate-none-color: $gray-light; 7 | 8 | .text-heart-rate-red { 9 | color: $heart-rate-red-color; 10 | } 11 | 12 | .text-heart-rate-anaerobic { 13 | color: $heart-rate-anaerobic-color; 14 | } 15 | 16 | .text-heart-rate-aerobic { 17 | color: $heart-rate-aerobic-color; 18 | } 19 | 20 | .text-heart-rate-fatburning { 21 | color: $heart-rate-fatburning-color; 22 | } 23 | 24 | .text-heart-rate-easy { 25 | color: $heart-rate-easy-color; 26 | } 27 | 28 | .text-heart-rate-none { 29 | color: $heart-rate-none-color; 30 | } 31 | 32 | .heart-rate-red { 33 | @include progress-bar-variant($heart-rate-red-color); 34 | } 35 | 36 | .heart-rate-anaerobic { 37 | @include progress-bar-variant($heart-rate-anaerobic-color); 38 | } 39 | 40 | .heart-rate-aerobic { 41 | @include progress-bar-variant($heart-rate-aerobic-color); 42 | } 43 | 44 | .heart-rate-fatburning { 45 | @include progress-bar-variant($heart-rate-fatburning-color); 46 | } 47 | 48 | .heart-rate-easy { 49 | @include progress-bar-variant($heart-rate-easy-color); 50 | } 51 | 52 | .heart-rate-none { 53 | @include progress-bar-variant($heart-rate-none-color); 54 | } 55 | -------------------------------------------------------------------------------- /pkg/rdp/rdp_test.go: -------------------------------------------------------------------------------- 1 | package rdp 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestReduce(t *testing.T) { 9 | testCases := []struct { 10 | points []Point 11 | reduced []Point 12 | epsilon float64 13 | }{ 14 | { 15 | points: []Point{ 16 | Point{X: 0, Y: 0}, 17 | Point{X: 1, Y: 1}, 18 | }, 19 | reduced: []Point{ 20 | Point{X: 0, Y: 0}, 21 | Point{X: 1, Y: 1}, 22 | }, 23 | epsilon: 1, 24 | }, 25 | { 26 | points: []Point{ 27 | Point{X: 0, Y: 0}, 28 | Point{X: 1, Y: 1}, 29 | Point{X: 2, Y: 2}, 30 | }, 31 | reduced: []Point{ 32 | Point{X: 0, Y: 0}, 33 | Point{X: 2, Y: 2}, 34 | }, 35 | epsilon: 1, 36 | }, 37 | { 38 | points: []Point{ 39 | Point{X: 0, Y: 0}, 40 | Point{X: 1, Y: 1}, 41 | Point{X: 2, Y: 2}, 42 | }, 43 | reduced: []Point{ 44 | Point{X: 0, Y: 0}, 45 | Point{X: 1, Y: 1}, 46 | Point{X: 2, Y: 2}, 47 | }, 48 | epsilon: 0, 49 | }, 50 | { 51 | points: []Point{ 52 | Point{X: 0, Y: 0}, 53 | Point{X: 1, Y: 3}, 54 | Point{X: 2, Y: 2}, 55 | }, 56 | reduced: []Point{ 57 | Point{X: 0, Y: 0}, 58 | Point{X: 1, Y: 3}, 59 | Point{X: 2, Y: 2}, 60 | }, 61 | epsilon: 1, 62 | }, 63 | } 64 | 65 | for i, testCase := range testCases { 66 | r := Reduce(testCase.points, testCase.epsilon) 67 | if !reflect.DeepEqual(r, testCase.reduced) { 68 | t.Errorf("test case %d: reduced points do not match: %v <> %v", i, r, testCase.reduced) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/models/log.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | "math" 6 | "time" 7 | 8 | "github.com/thcyron/gpx" 9 | ) 10 | 11 | type Log struct { 12 | ID int 13 | UserID int 14 | Name string 15 | Start time.Time 16 | End time.Time 17 | Duration uint 18 | Distance uint 19 | GPX string 20 | Tracks []*Track 21 | Tags []string 22 | } 23 | 24 | func NewLog(name string, data []byte) (*Log, error) { 25 | doc, err := gpx.NewDecoder(bytes.NewReader(data)).Decode() 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | log := &Log{ 31 | Name: name, 32 | Start: doc.Start(), 33 | End: doc.End(), 34 | Duration: uint(doc.Duration().Seconds()), 35 | Distance: uint(doc.Distance()), 36 | GPX: string(data), 37 | } 38 | 39 | for _, track := range doc.Tracks { 40 | for _, segment := range track.Segments { 41 | t := &Track{ 42 | Name: track.Name, 43 | Start: segment.Start(), 44 | End: segment.End(), 45 | Duration: uint(segment.Duration().Seconds()), 46 | Distance: uint(segment.Distance()), 47 | } 48 | 49 | for _, point := range segment.Points { 50 | t.Points = append(t.Points, NewPoint(point)) 51 | } 52 | 53 | log.Tracks = append(log.Tracks, t) 54 | } 55 | } 56 | 57 | return log, nil 58 | } 59 | 60 | // Speed returns the speed in meters per second. 61 | func (log *Log) Speed() float64 { 62 | speed := float64(log.Distance) / float64(log.Duration) 63 | if math.IsInf(speed, 0) { 64 | speed = 0 65 | } 66 | return speed 67 | } 68 | -------------------------------------------------------------------------------- /js/components/LogMap.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import Leaflet from "leaflet"; 6 | 7 | export default class LogMap extends React.Component { 8 | componentDidMount() { 9 | this.map = Leaflet.map(ReactDOM.findDOMNode(this)); 10 | 11 | let layer; 12 | if (this.props.mapboxAccessToken) { 13 | layer = Leaflet.tileLayer(`https://api.mapbox.com/v4/mapbox.outdoors/{z}/{x}/{y}.png?access_token=${this.props.mapboxAccessToken}`, { 14 | attribution: `Terms & Feedback`, 15 | }); 16 | } else { 17 | layer = Leaflet.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"); 18 | } 19 | 20 | this.map.addLayer(layer); 21 | this.updateMap(); 22 | } 23 | 24 | componentWillUnmount() { 25 | this.map.remove(); 26 | this.map = null; 27 | } 28 | 29 | updateMap() { 30 | if (this.multiPolyline) { 31 | this.map.removeLayer(this.multiPolyline); 32 | } 33 | 34 | const latlngs = this.props.log.get("tracks").map((track) => { 35 | return track.map((point) => { 36 | return [point.get("lat"), point.get("lon")]; 37 | }); 38 | }).toJS(); 39 | 40 | this.multiPolyline = Leaflet.polyline(latlngs, { color: "red", weight: 3, opacity: 0.8 }); 41 | this.multiPolyline.addTo(this.map); 42 | this.map.fitBounds(this.multiPolyline.getBounds()); 43 | } 44 | 45 | render() { 46 | if (this.map) { 47 | this.updateMap(); 48 | } 49 | 50 | return
; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /cmd/control/import.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path" 10 | "strings" 11 | 12 | "github.com/thcyron/tracklog/pkg/models" 13 | ) 14 | 15 | func importUsage() { 16 | fmt.Fprint(os.Stderr, `usage: tracklog-control import [options] 17 | 18 | Options: 19 | 20 | -user User to import files for 21 | `) 22 | os.Exit(2) 23 | } 24 | 25 | func importCmd(args []string) { 26 | flags := flag.NewFlagSet("tracklog-control import", flag.ExitOnError) 27 | flags.Usage = usage 28 | username := flags.String("user", "", "username") 29 | flags.Parse(args) 30 | if *username == "" || flags.NArg() == 0 { 31 | usage() 32 | } 33 | 34 | user, err := database.UserByUsername(*username) 35 | if err != nil { 36 | log.Fatalf("cannot load user: %s\n", err) 37 | } 38 | if user == nil { 39 | log.Fatalf("no such user: %s\n", *username) 40 | } 41 | 42 | for _, fileName := range flags.Args() { 43 | if err := importFile(user, fileName); err != nil { 44 | fmt.Fprintf(os.Stderr, "%s: %s\n", fileName, err) 45 | } else { 46 | fmt.Println(fileName) 47 | } 48 | } 49 | } 50 | 51 | func importFile(user *models.User, fileName string) error { 52 | f, err := os.Open(fileName) 53 | if err != nil { 54 | return err 55 | } 56 | defer f.Close() 57 | 58 | data, err := ioutil.ReadAll(f) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | name := strings.TrimSuffix(path.Base(fileName), ".gpx") 64 | log, err := models.NewLog(name, data) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | return database.AddUserLog(user, log) 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tracklog 2 | 3 | **Tracklog** is a web application for managing GPX track files written in Go. 4 | 5 | 6 | 7 | 8 | ## Run Tracklog with Docker 9 | 10 | There’s a Docker image [thcyron/tracklog](https://hub.docker.com/r/thcyron/tracklog) 11 | for Tracklog. This image only contains the server and import binary, you have to 12 | bring your own Postgres server. You also have to provide a config file. 13 | 14 | docker run -v /path/to/config.toml:/config.toml -p 8080:8080 thcyron/tracklog 15 | 16 | ## Installation 17 | 18 | First, make sure you have Go and Node.js installed. 19 | 20 | To build the JavaScript and CSS assets, run: 21 | 22 | npm install 23 | npm run build 24 | 25 | Now, build the command line programs: 26 | 27 | ./script/build 28 | 29 | Create and initialize a new Postgres database: 30 | 31 | createdb tracklog 32 | psql tracklog < db/postgres.sql 33 | 34 | Add a new user: 35 | 36 | cmd/control/control -config config.toml user add 37 | 38 | Start the server and point your browser to http://localhost:8080/: 39 | 40 | cmd/server/server -config config.toml 41 | 42 | You can batch-import your GPX files with: 43 | 44 | cmd/control/control -config config.toml import -user /path/to/your/*.gpx 45 | 46 | ## License 47 | 48 | Tracklog is licensed under the MIT license. 49 | -------------------------------------------------------------------------------- /pkg/heartrate/heartrate.go: -------------------------------------------------------------------------------- 1 | package heartrate 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/thcyron/tracklog/pkg/models" 7 | ) 8 | 9 | type Summary struct { 10 | Average int 11 | Rates []Heartrate 12 | Zones map[Zone]int 13 | } 14 | 15 | type Heartrate struct { 16 | BPM int 17 | Time time.Time 18 | } 19 | 20 | type Zone string 21 | 22 | const ( 23 | ZoneRed Zone = "Red" 24 | ZoneAnaerobic Zone = "Anaerobic" 25 | ZoneAerobic Zone = "Aerobic" 26 | ZoneFatBurning Zone = "Fat Burning" 27 | ZoneEasy Zone = "Easy" 28 | ZoneNone Zone = "None" 29 | ) 30 | 31 | func (z Zone) String() string { 32 | return string(z) 33 | } 34 | 35 | func (hr Heartrate) Zone() Zone { 36 | switch { 37 | case hr.BPM >= 175: 38 | return ZoneRed 39 | case hr.BPM >= 164: 40 | return ZoneAnaerobic 41 | case hr.BPM >= 153: 42 | return ZoneAerobic 43 | case hr.BPM >= 142: 44 | return ZoneFatBurning 45 | case hr.BPM >= 131: 46 | return ZoneEasy 47 | default: 48 | return ZoneNone 49 | } 50 | } 51 | 52 | func SummaryForLog(log *models.Log) Summary { 53 | summary := Summary{ 54 | Zones: make(map[Zone]int), 55 | } 56 | sum := uint(0) 57 | 58 | for _, track := range log.Tracks { 59 | for _, point := range track.Points { 60 | if point.Heartrate > 0 { 61 | hr := Heartrate{ 62 | BPM: int(point.Heartrate), 63 | Time: point.Time, 64 | } 65 | 66 | summary.Rates = append(summary.Rates, hr) 67 | summary.Zones[hr.Zone()]++ 68 | sum += point.Heartrate 69 | } 70 | } 71 | } 72 | 73 | if sum > 0 { 74 | summary.Average = int(sum / uint(len(summary.Rates))) 75 | } 76 | return summary 77 | } 78 | -------------------------------------------------------------------------------- /cmd/control/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/thcyron/tracklog/pkg/config" 10 | "github.com/thcyron/tracklog/pkg/db" 11 | ) 12 | 13 | var ( 14 | conf *config.Config 15 | database db.DB 16 | ) 17 | 18 | func init() { 19 | log.SetFlags(0) 20 | } 21 | 22 | func main() { 23 | flags := flag.NewFlagSet("tracklog-control", flag.ExitOnError) 24 | flags.Usage = usage 25 | configFile := flags.String("config", "config.toml", "path to config file") 26 | flags.Parse(os.Args[1:]) 27 | if flags.NArg() == 0 { 28 | usage() 29 | } 30 | 31 | cnf, err := readConfig(*configFile) 32 | if err != nil { 33 | log.Fatalln(err) 34 | } 35 | conf = cnf 36 | 37 | dbase := db.Driver(conf.DB.Driver) 38 | if dbase == nil { 39 | log.Fatalf("unknown database driver %s\n", conf.DB.Driver) 40 | } 41 | if err := dbase.Open(conf.DB.DSN); err != nil { 42 | log.Fatalf("cannot open database: %s\n", err) 43 | } 44 | database = dbase 45 | 46 | args := flags.Args() 47 | switch args[0] { 48 | case "user": 49 | userCmd(args[1:]) 50 | case "import": 51 | importCmd(args[1:]) 52 | default: 53 | usage() 54 | } 55 | } 56 | 57 | func readConfig(path string) (*config.Config, error) { 58 | f, err := os.Open(path) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer f.Close() 63 | return config.Read(f) 64 | } 65 | 66 | func usage() { 67 | fmt.Fprint(os.Stderr, `usage: tracklog-control [options] [arg...] 68 | 69 | Options: 70 | 71 | -config Path to config file (default: config.toml) 72 | 73 | Commands: 74 | 75 | user Manage user accounts 76 | import Import .gpx files 77 | `) 78 | 79 | os.Exit(2) 80 | } 81 | -------------------------------------------------------------------------------- /templates/dashboard.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Recent Logs

6 |
7 | {{with .Data.Logs}} 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{range .}} 22 | 23 | 33 | 34 | 35 | 36 | 37 | {{end}} 38 | 39 |
12 | Name 13 | Tags 14 | DateDurationDistance
24 | {{.Name}} 25 | {{with .Tags}} 26 | 27 | {{range .}} 28 | {{.}} 29 | {{end}} 30 | 31 | {{end}} 32 | {{.Date}}{{.Duration}}{{.Distance}}
40 | {{else}} 41 |
42 |

43 | No logs 44 |

45 |
46 | {{end}} 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /js/components/LogCharts.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import classNames from "classnames"; 5 | 6 | import LogElevationChart from "./LogElevationChart"; 7 | import LogSpeedChart from "./LogSpeedChart"; 8 | import LogHeartrateChart from "./LogHeartrateChart"; 9 | 10 | export default class LogCharts extends React.Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { tab: "elevation" }; 14 | } 15 | 16 | get _chart() { 17 | switch (this.state.tab) { 18 | case "elevation": 19 | return ; 20 | case "speed": 21 | return ; 22 | case "heartrate": 23 | return ; 24 | default: 25 | return null; 26 | } 27 | } 28 | 29 | get _tabs() { 30 | let tabs = [ 31 | { name: "Elevation", key: "elevation" }, 32 | { name: "Speed", key: "speed" }, 33 | ]; 34 | if (this.props.log.get("hr")) { 35 | tabs.push({ name: "Heartrate", key: "heartrate" }); 36 | } 37 | return tabs; 38 | } 39 | 40 | _onTabClick(tab, event) { 41 | event.preventDefault(); 42 | 43 | if (this.state.tab != tab) { 44 | this.setState({ tab: tab }); 45 | } 46 | } 47 | 48 | render() { 49 | return ( 50 |
51 | 60 |
61 |
62 | {this._chart} 63 |
64 |
65 |
66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /js/components/LogHeartrateChart.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import Highcharts from "highcharts"; 5 | 6 | export default class LogHeartrateChart extends React.Component { 7 | _createChart(container) { 8 | if (container == null) { 9 | return; 10 | } 11 | Highcharts.chart(container, { 12 | chart: { 13 | animation: false, 14 | style: { 15 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`, 16 | fontSize: "12px", 17 | }, 18 | }, 19 | title: { 20 | text: null, 21 | }, 22 | tooltip: { 23 | formatter: function() { return `${this.y} bpm`; }, 24 | }, 25 | xAxis: { 26 | title: { 27 | text: "Distance", 28 | }, 29 | labels: { 30 | formatter: function() { return `${this.value / 1000} km`; }, 31 | }, 32 | }, 33 | yAxis: { 34 | title: { 35 | text: "Heartrate" 36 | }, 37 | labels: { 38 | format: "{value} bpm" 39 | }, 40 | }, 41 | legend: { 42 | enabled: false, 43 | }, 44 | series: [ 45 | { 46 | name: "Heartrate", 47 | color: "rgb(30, 179, 0)", 48 | data: this._dataFromLog(this.props.log), 49 | animation: false, 50 | }, 51 | ], 52 | }); 53 | } 54 | 55 | _dataFromLog(log) { 56 | let data = []; 57 | let distance = 0; 58 | 59 | log.get("tracks").forEach((track) => { 60 | track.forEach((point, i) => { 61 | const hr = point.get("hr"); 62 | if (hr) { 63 | const distance = point.get("cumulated_distance"); 64 | data.push([Math.round(distance), hr]); 65 | } 66 | }); 67 | }); 68 | 69 | return data; 70 | } 71 | 72 | render() { 73 | return ( 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /js/components/LogElevationChart.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import Highcharts from "highcharts"; 5 | 6 | export default class LogElevationChart extends React.Component { 7 | _createChart(container) { 8 | if (container == null) { 9 | return; 10 | } 11 | Highcharts.chart(container, { 12 | chart: { 13 | animation: false, 14 | style: { 15 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`, 16 | fontSize: "12px", 17 | }, 18 | }, 19 | title: { 20 | text: null, 21 | }, 22 | tooltip: { 23 | formatter: function() { return `${this.y} m`; }, 24 | }, 25 | xAxis: { 26 | title: { 27 | text: "Distance", 28 | }, 29 | labels: { 30 | formatter: function() { return `${this.value / 1000} km`; }, 31 | }, 32 | }, 33 | yAxis: { 34 | title: { 35 | text: "Elevation" 36 | }, 37 | labels: { 38 | format: "{value} m" 39 | }, 40 | }, 41 | legend: { 42 | enabled: false, 43 | }, 44 | series: [ 45 | { 46 | name: "Elevation", 47 | color: "rgb(30, 179, 0)", 48 | data: this._dataFromLog(this.props.log), 49 | animation: false, 50 | }, 51 | ], 52 | }); 53 | } 54 | 55 | _dataFromLog(log) { 56 | let data = []; 57 | let distance = 0; 58 | 59 | log.get("tracks").forEach((track) => { 60 | track.forEach((point, i) => { 61 | const ele = point.get("ele"); 62 | if (ele) { 63 | const distance = point.get("cumulated_distance"); 64 | data.push([Math.round(distance), Math.round(ele)]); 65 | } 66 | }); 67 | }); 68 | 69 | return data; 70 | } 71 | 72 | render() { 73 | return ( 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /js/components/LogSpeedChart.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import Highcharts from "highcharts"; 5 | import Leaflet from "leaflet"; 6 | 7 | export default class LogSpeedChart extends React.Component { 8 | _createChart(container) { 9 | if (container == null) { 10 | return; 11 | } 12 | Highcharts.chart(container, { 13 | chart: { 14 | animation: false, 15 | style: { 16 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`, 17 | fontSize: "12px", 18 | }, 19 | }, 20 | title: { 21 | text: null, 22 | }, 23 | tooltip: { 24 | formatter: function() { return `${this.y} km/h`; }, 25 | }, 26 | xAxis: { 27 | title: { 28 | text: "Distance", 29 | }, 30 | labels: { 31 | formatter: function() { return `${this.value / 1000} km`; }, 32 | }, 33 | }, 34 | yAxis: { 35 | title: { 36 | text: "Speed" 37 | }, 38 | labels: { 39 | format: "{value} km/h" 40 | }, 41 | }, 42 | legend: { 43 | enabled: false, 44 | }, 45 | series: [ 46 | { 47 | name: "Speed", 48 | color: "rgb(30, 179, 0)", 49 | data: this._dataFromLog(this.props.log), 50 | animation: false, 51 | }, 52 | ], 53 | }); 54 | } 55 | 56 | _dataFromLog(log) { 57 | let data = []; 58 | 59 | log.get("tracks").forEach((track) => { 60 | track.forEach((point, i) => { 61 | const speed = point.get("speed"); 62 | if (speed !== null) { 63 | const distance = point.get("cumulated_distance"); 64 | const kmh = speed / 1000 * 3600; 65 | data.push([Math.round(distance), Math.round(kmh * 10) / 10]); 66 | } 67 | }); 68 | }); 69 | 70 | return data; 71 | } 72 | 73 | render() { 74 | return ( 75 |
76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /js/components/LogTags.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | import Dispatcher from "../Dispatcher"; 6 | 7 | export default class LogTags extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = this._initialStateForProps(props); 11 | } 12 | 13 | componentWillReceiveProps(nextProps) { 14 | this.setState(this._initialStateForProps(nextProps)); 15 | } 16 | 17 | _initialStateForProps(props) { 18 | let tags = props.log.get("tags"); 19 | 20 | if (props.editing && tags.filter(tag => tag.length == 0).size == 0) { 21 | tags = tags.push(""); 22 | } 23 | 24 | return { 25 | tags: tags, 26 | }; 27 | } 28 | 29 | _onTagChange(index, tag) { 30 | this.setState({ 31 | tags: this.state.tags.set(index, tag), 32 | }, () => { 33 | Dispatcher.dispatch({ 34 | type: "log-set-tags", 35 | tags: this.state.tags.filter(tag => tag.length > 0), 36 | }); 37 | }); 38 | } 39 | 40 | render() { 41 | let items; 42 | 43 | if (this.props.editing) { 44 | items = this.state.tags.toJS().map((tag, i) => { 45 | const onChange = (event) => { 46 | return this._onTagChange(i, event.target.value); 47 | }; 48 | return ( 49 |
  • 50 | 51 |
  • 52 | ); 53 | }); 54 | } else { 55 | items = this.state.tags.toJS().map((tag, i) => { 56 | return ( 57 |
  • 58 | {tag} 59 |
  • 60 | ); 61 | }); 62 | } 63 | 64 | return ( 65 |
    66 |
    67 |

    Tags

    68 |
    69 |
      70 | {items} 71 |
    72 |
    73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /js/components/LogHeartrateZones.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import Highcharts from "highcharts"; 5 | 6 | export default class LogHeartrateZones extends React.Component { 7 | _createChart(container) { 8 | if (container == null) { 9 | return; 10 | } 11 | Highcharts.chart(container, { 12 | chart: { 13 | type: "pie", 14 | animation: false, 15 | style: { 16 | fontFamily: `"Helvetica Neue", Helvetica, Arial, sans-serif`, 17 | fontSize: "12px", 18 | }, 19 | }, 20 | title: { 21 | text: null, 22 | }, 23 | tooltip: { 24 | pointFormat: "{point.y}", 25 | valueSuffix: "%", 26 | }, 27 | legend: { 28 | enabled: false, 29 | }, 30 | series: [ 31 | { 32 | name: "Heartrate", 33 | color: "rgb(30, 179, 0)", 34 | data: this.data, 35 | dataLabels: { 36 | enabled: false, 37 | }, 38 | animation: false, 39 | }, 40 | ], 41 | }); 42 | } 43 | 44 | get data() { 45 | return [ 46 | { 47 | y: Math.round(this.props.zones.get("red")), 48 | name: "Red", 49 | color: "#d9534f", 50 | }, 51 | { 52 | y: Math.round(this.props.zones.get("anaerobic")), 53 | name: "Anaerobic", 54 | color: "#f0ad4e", 55 | }, 56 | { 57 | y: Math.round(this.props.zones.get("aerobic")), 58 | name: "Aerobic", 59 | color: "#80c780", 60 | }, 61 | { 62 | y: Math.round(this.props.zones.get("fatburning")), 63 | name: "Fat Burning", 64 | color: "#449d44", 65 | }, 66 | { 67 | y: Math.round(this.props.zones.get("easy")), 68 | name: "Easy", 69 | color: "#5bc0de", 70 | }, 71 | { 72 | y: Math.round(this.props.zones.get("none")), 73 | name: "None", 74 | color: "#777777", 75 | }, 76 | ]; 77 | } 78 | 79 | render() { 80 | return ( 81 |
    82 |
    83 |

    Heart Rate Zones

    84 |
    85 |
    86 |
    87 |
    88 |
    89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/db/postgres.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | 3 | CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; 4 | 5 | CREATE TABLE "user" ( 6 | "id" serial PRIMARY KEY, 7 | "username" citext NOT NULL, 8 | "password" text NOT NULL, 9 | "password_version" int NOT NULL DEFAULT 1 10 | ); 11 | 12 | CREATE UNIQUE INDEX "user_username_idx" ON "user" ("username"); 13 | 14 | CREATE TABLE "log" ( 15 | "id" serial PRIMARY KEY, 16 | "user_id" int NOT NULL, 17 | "name" text NOT NULL, 18 | "gpx" xml NOT NULL, 19 | "start" timestamptz NOT NULL, 20 | "end" timestamptz NOT NULL, 21 | "duration" int NOT NULL, 22 | "distance" int NOT NULL, 23 | "created" timestamptz NOT NULL DEFAULT now(), 24 | FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE 25 | ); 26 | 27 | CREATE INDEX "log_name_idx" ON "log" ("name"); 28 | CREATE INDEX "log_start_idx" ON "log" ("start"); 29 | CREATE INDEX "log_end_idx" ON "log" ("end"); 30 | CREATE INDEX "log_duration_idx" ON "log" ("duration"); 31 | CREATE INDEX "log_distance_idx" ON "log" ("distance"); 32 | 33 | CREATE TABLE "track" ( 34 | "id" serial PRIMARY KEY, 35 | "log_id" int NOT NULL, 36 | "name" text, 37 | "start" timestamptz NOT NULL, 38 | "end" timestamptz NOT NULL, 39 | "duration" int NOT NULL, 40 | "distance" int NOT NULL, 41 | FOREIGN KEY ("log_id") REFERENCES "log" ("id") ON UPDATE CASCADE ON DELETE CASCADE 42 | ); 43 | 44 | CREATE INDEX "track_start_idx" ON "track" ("start"); 45 | CREATE INDEX "track_end_idx" ON "track" ("end"); 46 | CREATE INDEX "track_duration_idx" ON "track" ("duration"); 47 | CREATE INDEX "track_distance_idx" ON "track" ("distance"); 48 | 49 | CREATE TABLE "trackpoint" ( 50 | "id" serial PRIMARY KEY, 51 | "track_id" int NOT NULL, 52 | "point" point NOT NULL, 53 | "time" timestamptz NOT NULL, 54 | "elevation" real, 55 | "heartrate" int, 56 | "deleted" boolean NOT NULL DEFAULT false, 57 | FOREIGN KEY ("track_id") REFERENCES "track" ("id") ON UPDATE CASCADE ON DELETE CASCADE 58 | ); 59 | 60 | CREATE INDEX "trackpoint_time_idx" ON "trackpoint" ("time"); 61 | CREATE INDEX "trackpoint_deleted_idx" ON "trackpoint" ("deleted"); 62 | 63 | CREATE TABLE "log_tag" ( 64 | "log_id" int NOT NULL, 65 | "tag" citext NOT NULL, 66 | PRIMARY KEY ("log_id", "tag"), 67 | FOREIGN KEY ("log_id") REFERENCES "log" ("id") ON UPDATE CASCADE ON DELETE CASCADE 68 | ); 69 | 70 | CREATE INDEX "log_tag_tag_idx" ON "log_tag" ("tag"); 71 | 72 | COMMIT; 73 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{if .Title}}{{.Title}} – Tracklog{{else}}Tracklog{{end}} 6 | 7 | 8 | 14 | 15 | 16 | 37 | {{with .Breadcrumb.Items}} 38 | 51 | {{end}} 52 |
    53 | {{.Content}} 54 |
    55 |
    56 |
    57 |
      58 |
    • Tracklog {{.Version}}
    • 59 | {{with .Runtime}} 60 |
    • Runtime: {{.}}
    • 61 | {{end}} 62 |
    63 |
    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /pkg/server/context.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gorilla/context" 8 | "github.com/julienschmidt/httprouter" 9 | "github.com/thcyron/tracklog/pkg/models" 10 | ) 11 | 12 | type contextKey int 13 | 14 | const ( 15 | ctxTitle contextKey = iota + 1 16 | ctxActiveTab 17 | ctxBreadcrumb 18 | ctxNoLayout 19 | ctxStart 20 | ctxData 21 | ctxUser 22 | ctxParams 23 | ) 24 | 25 | type Context struct { 26 | r *http.Request 27 | w http.ResponseWriter 28 | } 29 | 30 | func NewContext(r *http.Request, w http.ResponseWriter) *Context { 31 | return &Context{r: r, w: w} 32 | } 33 | 34 | func (c *Context) Breadcrumb() *Breadcrumb { 35 | br, ok := context.Get(c.r, ctxBreadcrumb).(*Breadcrumb) 36 | if !ok || br == nil { 37 | br = new(Breadcrumb) 38 | context.Set(c.r, ctxBreadcrumb, br) 39 | } 40 | return br 41 | } 42 | 43 | func (c *Context) Title() string { 44 | title, _ := context.Get(c.r, ctxTitle).(string) 45 | return title 46 | } 47 | 48 | func (c *Context) SetTitle(title string) { 49 | context.Set(c.r, ctxTitle, title) 50 | } 51 | 52 | func (c *Context) ActiveTab() string { 53 | activeTab, _ := context.Get(c.r, ctxActiveTab).(string) 54 | return activeTab 55 | } 56 | 57 | func (c *Context) SetActiveTab(activeTab string) { 58 | context.Set(c.r, ctxActiveTab, activeTab) 59 | } 60 | 61 | func (c *Context) NoLayout() bool { 62 | noLayout, _ := context.Get(c.r, ctxNoLayout).(bool) 63 | return noLayout 64 | } 65 | 66 | func (c *Context) SetNoLayout(noLayout bool) { 67 | context.Set(c.r, ctxNoLayout, noLayout) 68 | } 69 | 70 | func (c *Context) Data() interface{} { 71 | return context.Get(c.r, ctxData) 72 | } 73 | 74 | func (c *Context) SetData(data interface{}) { 75 | context.Set(c.r, ctxData, data) 76 | } 77 | 78 | func (c *Context) User() *models.User { 79 | user, _ := context.Get(c.r, ctxUser).(*models.User) 80 | return user 81 | } 82 | 83 | func (c *Context) SetUser(user *models.User) { 84 | context.Set(c.r, ctxUser, user) 85 | } 86 | 87 | func (c *Context) Params() httprouter.Params { 88 | params, _ := context.Get(c.r, ctxParams).(httprouter.Params) 89 | return params 90 | } 91 | 92 | func (c *Context) SetParams(ps httprouter.Params) { 93 | context.Set(c.r, ctxParams, ps) 94 | } 95 | 96 | func (c *Context) Start() time.Time { 97 | start, _ := context.Get(c.r, ctxStart).(time.Time) 98 | return start 99 | } 100 | 101 | func (c *Context) SetStart(start time.Time) { 102 | context.Set(c.r, ctxStart, start) 103 | } 104 | -------------------------------------------------------------------------------- /templates/logs.html: -------------------------------------------------------------------------------- 1 | {{if .Data.Years}} 2 |
    3 |
    4 | 15 |
    16 |
    17 | {{end}} 18 | 19 |
    20 |
    21 | {{with .Data.Groups}} 22 | {{range .}} 23 |
    24 |
    25 |

    {{.Title}}

    26 |
    27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | {{range .Logs}} 39 | 40 | 50 | 51 | 52 | 53 | 54 | {{end}} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
    30 | Name 31 | Tags 32 | DateDurationDistance
    41 | {{.Name}} 42 | {{with .Tags}} 43 | 44 | {{range .}} 45 | {{.}} 46 | {{end}} 47 | 48 | {{end}} 49 | {{.Start}}{{.Duration}}{{.Distance}}
    {{.Duration}}{{.Distance}}
    64 |
    65 | {{end}} 66 | {{else}} 67 |
    68 |
    69 |

    70 | No logs 71 |

    72 |
    73 |
    74 | {{end}} 75 |
    76 |
    77 | -------------------------------------------------------------------------------- /js/components/LogDetails.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | 5 | import LogTags from "./LogTags"; 6 | import LogHeartrateZones from "./LogHeartrateZones"; 7 | 8 | export default class LogDetails extends React.Component { 9 | onEditClick(event) { 10 | event.preventDefault(); 11 | 12 | if (this.props.onEdit) { 13 | this.props.onEdit(); 14 | } 15 | } 16 | 17 | render() { 18 | let tags = "", hrZones = ""; 19 | 20 | if (this.props.log.get("tags").size > 0 || this.props.editing) { 21 | tags = ; 22 | } 23 | 24 | if (this.props.log.get("hr")) { 25 | const zones = this.props.log.get("hrzones"); 26 | hrZones = ; 27 | } 28 | 29 | let details = [ 30 | ["Start", this.props.log.get("start")], 31 | ["End", this.props.log.get("end")], 32 | ["Duration", this.props.log.get("duration")], 33 | ["Distance", this.props.log.get("distance")], 34 | ["∅ Speed", this.props.log.get("speed")], 35 | ["∅ Pace", this.props.log.get("pace")], 36 | ] 37 | if (this.props.log.has("ascent")) { 38 | details.push(["Ascent", this.props.log.get("ascent")]); 39 | details.push(["Descent", this.props.log.get("descent")]); 40 | } 41 | 42 | if (this.props.log.get("hr")) { 43 | details.push(["∅ HR", this.props.log.get("hr")]); 44 | } 45 | 46 | let dlElements = []; 47 | details.forEach((detail, i) => { 48 | dlElements.push(
    {detail[0]}
    ); 49 | dlElements.push(
    {detail[1]}
    ); 50 | }); 51 | 52 | return ( 53 |
    54 |
    55 |
    56 |

    Details

    57 |
    58 |
    59 |
    60 | {dlElements} 61 |
    62 |
    63 |
    64 | {tags} 65 | {hrZones} 66 |
      67 |
    • Download .gpx file
    • 68 | {(() => { 69 | if (!this.props.editing) { 70 | return [ 71 |
    • Edit
    • , 72 |
    • Delete
    • , 73 | ]; 74 | } 75 | })()} 76 |
    77 |
    78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestDuration(t *testing.T) { 6 | testCases := []struct { 7 | seconds uint 8 | duration string 9 | }{ 10 | { 11 | seconds: 1, 12 | duration: "0:00:01", 13 | }, 14 | { 15 | seconds: 60, 16 | duration: "0:01:00", 17 | }, 18 | { 19 | seconds: 61, 20 | duration: "0:01:01", 21 | }, 22 | { 23 | seconds: 3600, 24 | duration: "1:00:00", 25 | }, 26 | { 27 | seconds: 36001, 28 | duration: "10:00:01", 29 | }, 30 | { 31 | seconds: 24 * 3600, 32 | duration: "1d 0:00:00", 33 | }, 34 | { 35 | seconds: 24*3600 + 61, 36 | duration: "1d 0:01:01", 37 | }, 38 | { 39 | seconds: 25*3600 + 61, 40 | duration: "1d 1:01:01", 41 | }, 42 | } 43 | 44 | for i, testCase := range testCases { 45 | s := Duration(testCase.seconds).String() 46 | if s != testCase.duration { 47 | t.Errorf("test case %d: expected %q; got %q", i, testCase.duration, s) 48 | } 49 | } 50 | } 51 | 52 | func TestDistance(t *testing.T) { 53 | testCases := []struct { 54 | meters uint 55 | distance string 56 | }{ 57 | { 58 | meters: 1, 59 | distance: "0.00 km", 60 | }, 61 | { 62 | meters: 10, 63 | distance: "0.01 km", 64 | }, 65 | { 66 | meters: 100, 67 | distance: "0.10 km", 68 | }, 69 | { 70 | meters: 1000, 71 | distance: "1.00 km", 72 | }, 73 | { 74 | meters: 1011, 75 | distance: "1.01 km", 76 | }, 77 | } 78 | 79 | for i, testCase := range testCases { 80 | s := Distance(testCase.meters).String() 81 | if s != testCase.distance { 82 | t.Errorf("test case %d: expected %q; got %q", i, testCase.distance, s) 83 | } 84 | } 85 | } 86 | 87 | func TestSpeed(t *testing.T) { 88 | testCases := []struct { 89 | mps float64 // meters per second 90 | speed string 91 | }{ 92 | { 93 | mps: 0.2777, 94 | speed: "1.00 km/h", 95 | }, 96 | { 97 | mps: 2.7777, 98 | speed: "10.00 km/h", 99 | }, 100 | { 101 | mps: 18.46145, 102 | speed: "66.46 km/h", 103 | }, 104 | } 105 | 106 | for i, testCase := range testCases { 107 | s := Speed(testCase.mps).String() 108 | if s != testCase.speed { 109 | t.Errorf("test case %d: expected %q; got %q", i, testCase.speed, s) 110 | } 111 | } 112 | } 113 | 114 | func TestPace(t *testing.T) { 115 | testCases := []struct { 116 | spm float64 // seconds per meter 117 | pace string 118 | }{ 119 | { 120 | spm: 0.060, 121 | pace: "1:00 min/km", 122 | }, 123 | { 124 | spm: 0.601, 125 | pace: "10:01 min/km", 126 | }, 127 | } 128 | 129 | for i, testCase := range testCases { 130 | s := Pace(testCase.spm).String() 131 | if s != testCase.pace { 132 | t.Errorf("test case %d: expected %q; got %q", i, testCase.pace, s) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/codegangsta/negroni v1.0.0 h1:+aYywywx4bnKXWvoWtRfJ91vC59NbEhEY03sZjQhbVY= 2 | github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0= 3 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 4 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 5 | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= 6 | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= 7 | github.com/gorilla/csrf v1.6.0 h1:60oN1cFdncCE8tjwQ3QEkFND5k37lQPcRjnlvm7CIJ0= 8 | github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= 9 | github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= 10 | github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= 11 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 12 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 13 | github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= 14 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= 18 | github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 19 | github.com/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= 20 | github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= 21 | github.com/naoina/toml v0.1.1 h1:PT/lllxVVN0gzzSqSlHEmP8MJB4MY2U7STGxiouV4X8= 22 | github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= 23 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 24 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 25 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc h1:+H31hHGMW8l423zznrfQraQc1HQ7Kv0jfPQ3clA5KdE= 26 | github.com/thcyron/gpx v0.0.0-20160210223831-f95a7bb694cc/go.mod h1:lbbxvopbvIqhA57Bi1C3CACj4nr5ti86XKLHr7ceFnM= 27 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible h1:BeLkcwOwiVc6E0RUoyCNdsiTRVnn3VzpSZmogq6ljTw= 28 | github.com/thcyron/sqlbuilder v2.0.1-0.20160307210201-609a1abb836e+incompatible/go.mod h1:W/nWeL06VCuTWU/BfO3hFkp8XTKV7QNB7SoRZpI3N2A= 29 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 30 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc= 31 | golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 32 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 35 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | -------------------------------------------------------------------------------- /cmd/control/user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "syscall" 8 | "unicode/utf8" 9 | 10 | "github.com/thcyron/tracklog/pkg/models" 11 | 12 | "golang.org/x/crypto/bcrypt" 13 | "golang.org/x/crypto/ssh/terminal" 14 | ) 15 | 16 | func userUsage() { 17 | fmt.Fprint(os.Stderr, `usage: tracklog-control user [arg...] 18 | 19 | Commands: 20 | 21 | add Add a new user 22 | delete Delete a user 23 | password Change a user’s password 24 | `) 25 | os.Exit(2) 26 | } 27 | 28 | func userCmd(args []string) { 29 | if len(args) == 0 { 30 | userUsage() 31 | } 32 | 33 | switch args[0] { 34 | case "add": 35 | addUserCmd(args[1:]) 36 | case "delete": 37 | deleteUserCmd(args[1:]) 38 | case "password": 39 | passwordUserCmd(args[1:]) 40 | default: 41 | userUsage() 42 | } 43 | } 44 | 45 | func addUserUsage() { 46 | fmt.Fprint(os.Stderr, `usage: tracklog-control user add \n`) 47 | os.Exit(2) 48 | } 49 | 50 | func addUserCmd(args []string) { 51 | if len(args) != 1 { 52 | addUserUsage() 53 | } 54 | 55 | username := args[0] 56 | if username == "" { 57 | addUserUsage() 58 | } 59 | 60 | fmt.Print("Password: ") 61 | password, err := terminal.ReadPassword(syscall.Stdin) 62 | if err != nil { 63 | log.Fatalln(err) 64 | } 65 | if utf8.RuneCount(password) == 0 { 66 | log.Fatalln("zero-length password not allowed") 67 | } 68 | 69 | pwhash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) 70 | fmt.Print("\n") 71 | if err != nil { 72 | log.Fatalln(err) 73 | } 74 | 75 | user := &models.User{ 76 | Username: username, 77 | Password: string(pwhash), 78 | } 79 | 80 | if err := database.AddUser(user); err != nil { 81 | log.Fatalln(err) 82 | } 83 | 84 | fmt.Printf("user %s created\n", username) 85 | } 86 | 87 | func deleteUserCmd(args []string) { 88 | if len(args) != 1 { 89 | addUserUsage() 90 | } 91 | 92 | username := args[0] 93 | if username == "" { 94 | addUserUsage() 95 | } 96 | 97 | user, err := database.UserByUsername(username) 98 | if err != nil { 99 | log.Fatalln(err) 100 | } 101 | if user == nil { 102 | log.Fatalf("no such user: %s\n", username) 103 | } 104 | 105 | if err := database.DeleteUser(user); err != nil { 106 | log.Fatalln(err) 107 | } 108 | 109 | log.Printf("user %s deleted\n", username) 110 | } 111 | 112 | func passwordUserCmd(args []string) { 113 | if len(args) != 1 { 114 | addUserUsage() 115 | } 116 | 117 | username := args[0] 118 | if username == "" { 119 | addUserUsage() 120 | } 121 | 122 | user, err := database.UserByUsername(username) 123 | if err != nil { 124 | log.Fatalln(err) 125 | } 126 | if user == nil { 127 | log.Fatalf("no such user: %s\n", username) 128 | } 129 | 130 | fmt.Print("Password: ") 131 | password, err := terminal.ReadPassword(syscall.Stdin) 132 | if err != nil { 133 | log.Fatalln(err) 134 | } 135 | if utf8.RuneCount(password) == 0 { 136 | log.Fatalln("zero-length password not allowed") 137 | } 138 | 139 | pwhash, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) 140 | fmt.Print("\n") 141 | if err != nil { 142 | log.Fatalln(err) 143 | } 144 | user.Password = string(pwhash) 145 | user.PasswordVersion += 1 146 | 147 | if err := database.UpdateUser(user); err != nil { 148 | log.Fatalln(err) 149 | } 150 | 151 | log.Printf("password for %s changed\n", username) 152 | } 153 | -------------------------------------------------------------------------------- /js/tracklog.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import "whatwg-fetch"; 4 | 5 | import {uploadLogs} from "./upload"; 6 | import {renderLog} from "./components/Log"; 7 | 8 | document.addEventListener("DOMContentLoaded", () => { 9 | const nodes = document.querySelectorAll("a[data-method]"); 10 | for (let i = 0; i < nodes.length; i++) { 11 | const node = nodes[i]; 12 | const method = node.attributes.getNamedItem("data-method").value; 13 | node.appendChild(createForm(node.href, method)); 14 | node.addEventListener("click", (event) => { 15 | event.preventDefault(); 16 | const a = event.target; 17 | const form = a.querySelector("form"); 18 | form.submit(); 19 | }); 20 | } 21 | }); 22 | 23 | function createForm(href, method) { 24 | const form = document.createElement("form"); 25 | form.method = "POST"; 26 | form.action = href; 27 | form.style = "display: none;" 28 | 29 | const methodField = document.createElement("input"); 30 | methodField.type = "hidden"; 31 | methodField.name = "_method"; 32 | methodField.value = method.toUpperCase(); 33 | form.appendChild(methodField); 34 | 35 | const csrfField = document.createElement("input"); 36 | csrfField.type = "hidden"; 37 | csrfField.name = "_csrf"; 38 | csrfField.value = Tracklog.csrfToken; 39 | form.appendChild(csrfField); 40 | 41 | return form; 42 | } 43 | 44 | document.addEventListener("DOMContentLoaded", () => { 45 | const nodes = document.querySelectorAll("form"); 46 | for (let i = 0; i < nodes.length; i++) { 47 | const node = nodes[i]; 48 | node.addEventListener("submit", (event) => { 49 | const form = event.target; 50 | const csrfField = document.createElement("input"); 51 | csrfField.type = "hidden"; 52 | csrfField.name = "_csrf"; 53 | csrfField.value = Tracklog.csrfToken; 54 | form.appendChild(csrfField); 55 | }); 56 | } 57 | }); 58 | 59 | document.addEventListener("DOMContentLoaded", () => { 60 | const nodes = document.querySelectorAll(".logs-upload-button"); 61 | for (let i = 0; i < nodes.length; i++) { 62 | const node = nodes[i]; 63 | 64 | const changeButton = ({textAttribute, disabled}) => { 65 | node.textContent = node.attributes.getNamedItem(textAttribute).value; 66 | node.disabled = disabled; 67 | }; 68 | 69 | node.addEventListener("click", () => { 70 | const fileInput = node.nextElementSibling; 71 | 72 | fileInput.addEventListener("change", (event) => { 73 | const input = event.target; 74 | const files = input.files; 75 | 76 | let filesArray = []; 77 | for (let i = 0; i < files.length; i++) { 78 | filesArray.push(files[i]); 79 | } 80 | 81 | uploadLogs({ 82 | files: filesArray, 83 | }) 84 | .then((results) => { 85 | changeButton({ 86 | textAttribute: "data-text-upload", 87 | disabled: false, 88 | }); 89 | 90 | if (results.length == 1) { 91 | const id = results[0].id; 92 | window.location = `/logs/${id}`; 93 | } else { 94 | window.location = "/logs"; 95 | } 96 | }) 97 | .catch((err) => { 98 | changeButton({ 99 | textAttribute: "data-text-upload", 100 | disabled: false, 101 | }); 102 | 103 | alert(err); 104 | }); 105 | 106 | changeButton({ 107 | textAttribute: "data-text-uploading", 108 | disabled: true, 109 | }); 110 | }); 111 | 112 | fileInput.click(); 113 | }); 114 | } 115 | }); 116 | 117 | window.Tracklog = { 118 | renderLog: renderLog, 119 | }; 120 | -------------------------------------------------------------------------------- /pkg/server/sessions.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/dgrijalva/jwt-go" 8 | "github.com/thcyron/tracklog/pkg/models" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | const tokenCookieName = "tracklog_token" 13 | 14 | var tokenSigningMethod = jwt.SigningMethodHS256 15 | 16 | func (s *Server) HandleGetSignIn(w http.ResponseWriter, r *http.Request) { 17 | ctx := NewContext(r, w) 18 | user := ctx.User() 19 | if user != nil { 20 | http.Redirect(w, r, "/", http.StatusFound) 21 | return 22 | } 23 | s.renderSignIn(w, r, signInData{}) 24 | } 25 | 26 | func (s *Server) HandlePostSignIn(w http.ResponseWriter, r *http.Request) { 27 | username, password := r.FormValue("username"), r.FormValue("password") 28 | if username == "" || password == "" { 29 | s.renderSignIn(w, r, signInData{}) 30 | return 31 | } 32 | 33 | user, err := s.db.UserByUsername(username) 34 | if err != nil { 35 | panic(err) 36 | } 37 | if user == nil { 38 | s.renderSignIn(w, r, signInData{ 39 | Username: username, 40 | Alert: "Bad username/password", 41 | }) 42 | return 43 | } 44 | switch bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) { 45 | case nil: 46 | break 47 | case bcrypt.ErrMismatchedHashAndPassword: 48 | s.renderSignIn(w, r, signInData{ 49 | Username: username, 50 | Alert: "Bad username/password", 51 | }) 52 | return 53 | default: 54 | panic(err) 55 | } 56 | 57 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 58 | "user_id": user.ID, 59 | "v": user.PasswordVersion}) 60 | 61 | tokenString, err := token.SignedString([]byte(s.config.Server.SigningKey)) 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | cookie := &http.Cookie{ 67 | Name: tokenCookieName, 68 | Value: tokenString, 69 | HttpOnly: true, 70 | } 71 | http.SetCookie(w, cookie) 72 | 73 | http.Redirect(w, r, "/", http.StatusFound) 74 | } 75 | 76 | type signInData struct { 77 | Username string 78 | Alert string 79 | } 80 | 81 | func (s *Server) renderSignIn(w http.ResponseWriter, r *http.Request, data signInData) { 82 | ctx := NewContext(r, w) 83 | ctx.SetNoLayout(true) 84 | ctx.SetData(data) 85 | s.render(w, r, "signin") 86 | } 87 | 88 | func (s *Server) HandlePostSignOut(w http.ResponseWriter, r *http.Request) { 89 | cookie := &http.Cookie{ 90 | Name: tokenCookieName, 91 | Value: "", 92 | HttpOnly: true, 93 | } 94 | http.SetCookie(w, cookie) 95 | s.redirectToSignIn(w, r) 96 | } 97 | 98 | func (s *Server) redirectToSignIn(w http.ResponseWriter, r *http.Request) { 99 | http.Redirect(w, r, "/signin", http.StatusFound) 100 | } 101 | 102 | func (s *Server) userFromRequest(r *http.Request) (*models.User, error) { 103 | cookie, err := r.Cookie(tokenCookieName) 104 | if err != nil { 105 | return nil, nil 106 | } 107 | 108 | token, err := jwt.Parse(cookie.Value, func(token *jwt.Token) (interface{}, error) { 109 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 110 | return nil, errors.New("bad signing method") 111 | } 112 | return []byte(s.config.Server.SigningKey), nil 113 | }) 114 | if err != nil || !token.Valid { 115 | return nil, nil 116 | } 117 | 118 | claims, ok := token.Claims.(jwt.MapClaims) 119 | if !ok { 120 | return nil, nil 121 | } 122 | 123 | id, ok := claims["user_id"].(float64) 124 | if !ok { 125 | return nil, nil 126 | } 127 | user, err := s.db.UserByID(int(id)) 128 | if err != nil { 129 | return nil, err 130 | } 131 | if user == nil { 132 | return nil, nil 133 | } 134 | 135 | v, ok := claims["v"].(float64) 136 | if !ok { 137 | return nil, err 138 | } 139 | if int(v) != user.PasswordVersion { 140 | return nil, nil 141 | } 142 | return user, nil 143 | } 144 | -------------------------------------------------------------------------------- /js/components/Log.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import Immutable from "immutable"; 6 | 7 | import LogMap from "./LogMap"; 8 | import LogDetails from "./LogDetails"; 9 | import LogName from "./LogName"; 10 | import LogCharts from "./LogCharts"; 11 | 12 | import Dispatcher from "../Dispatcher"; 13 | 14 | import LogStore from "../stores/LogStore"; 15 | 16 | export default class Log extends React.Component { 17 | constructor(props) { 18 | super(props); 19 | 20 | this.state = { 21 | log: props.log, 22 | editing: false, 23 | }; 24 | 25 | LogStore.init(this.props.log); 26 | 27 | LogStore.on("change", () => { 28 | this.setState({ 29 | log: LogStore.log, 30 | }); 31 | }); 32 | } 33 | 34 | onEdit() { 35 | if (!this.state.editing) { 36 | this.setState({ 37 | editing: true, 38 | oldLog: this.state.log, 39 | }); 40 | } 41 | } 42 | 43 | onSave(event) { 44 | this.setState({ 45 | editing: false, 46 | oldLog: null, 47 | }); 48 | 49 | const tags = this.state.log.get("tags").filter(tag => tag.length > 0); 50 | 51 | window.fetch(`/logs/${this.state.log.get("id")}`, { 52 | method: "PATCH", 53 | credentials: "same-origin", 54 | headers: { 55 | "Content-Type": "application/json; charset=utf-8", 56 | "X-CSRF-Token": Tracklog.csrfToken, 57 | }, 58 | body: JSON.stringify({ 59 | "name": this.state.log.get("name"), 60 | "tags": tags.toJSON(), 61 | }), 62 | }) 63 | .then((data) => { 64 | if (data.status != 204) { 65 | alert("Failed to save log"); 66 | this.setState({ 67 | editing: true, 68 | oldLog: this.state.log, 69 | }); 70 | } 71 | }) 72 | .catch((err) => { 73 | alert(err); 74 | }); 75 | } 76 | 77 | onCancel(event) { 78 | this.setState({ 79 | editing: false, 80 | log: this.state.oldLog, 81 | oldLog: null, 82 | }); 83 | } 84 | 85 | get topRow() { 86 | if (this.state.editing) { 87 | return ( 88 |
    89 |
    90 | 91 |
    92 |
    93 |
    94 |
    95 | 96 |
    97 |
    98 | 99 |
    100 |
    101 |
    102 |
    103 | ); 104 | } 105 | 106 | return ( 107 |
    108 |
    109 | 110 |
    111 |
    112 | ); 113 | } 114 | 115 | render() { 116 | return ( 117 |
    118 | {this.topRow} 119 |
    120 |
    121 | 122 | 123 |
    124 |
    125 | 126 |
    127 |
    128 |
    129 | ); 130 | } 131 | } 132 | 133 | export function renderLog(container, log) { 134 | const immutableLog = Immutable.fromJS(log); 135 | ReactDOM.render(, container); 136 | } 137 | -------------------------------------------------------------------------------- /pkg/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "net/http" 8 | "path" 9 | "time" 10 | 11 | "github.com/codegangsta/negroni" 12 | "github.com/gorilla/context" 13 | "github.com/gorilla/csrf" 14 | "github.com/gorilla/handlers" 15 | "github.com/julienschmidt/httprouter" 16 | "github.com/thcyron/tracklog" 17 | "github.com/thcyron/tracklog/pkg/config" 18 | "github.com/thcyron/tracklog/pkg/db" 19 | "github.com/thcyron/tracklog/pkg/models" 20 | ) 21 | 22 | // DataDir points to the directory where the public/ and templates/ directories are. 23 | var DataDir = "." 24 | 25 | type Server struct { 26 | config *config.Config 27 | db db.DB 28 | handler http.Handler 29 | csrfHandler func(http.Handler) http.Handler 30 | tmpl *template.Template 31 | } 32 | 33 | func New(conf *config.Config, db db.DB) (*Server, error) { 34 | s := &Server{ 35 | config: conf, 36 | db: db, 37 | } 38 | 39 | if !s.config.Server.Development { 40 | tmpl, err := s.loadTemplates() 41 | if err != nil { 42 | return nil, err 43 | } 44 | s.tmpl = tmpl 45 | } 46 | 47 | n := negroni.Classic() 48 | 49 | csrfHandler := csrf.Protect( 50 | []byte(s.config.Server.CSRFAuthKey), 51 | csrf.Secure(!s.config.Server.Development), 52 | csrf.FieldName("_csrf"), 53 | ) 54 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 55 | csrfHandler(next).ServeHTTP(w, r) 56 | }) 57 | 58 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 59 | handlers.HTTPMethodOverrideHandler(next).ServeHTTP(w, r) 60 | }) 61 | n.UseFunc(func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 62 | handlers.CompressHandler(next).ServeHTTP(w, r) 63 | }) 64 | 65 | n.UseFunc(s.userAuthMiddleware) 66 | 67 | r := httprouter.New() 68 | r.ServeFiles("/static/*filepath", http.Dir(path.Join(DataDir, "public"))) 69 | r.GET("/signin", s.wrapHandler(s.HandleGetSignIn)) 70 | r.POST("/signin", s.wrapHandler(s.HandlePostSignIn)) 71 | r.POST("/signout", s.wrapHandler(s.HandlePostSignOut)) 72 | r.GET("/logs", s.wrapHandler(s.HandleGetLogs)) 73 | r.POST("/logs", s.wrapHandler(s.HandlePostLog)) 74 | r.GET("/logs/:id/download", s.wrapHandler(s.HandleDownloadLog)) 75 | r.GET("/logs/:id", s.wrapHandler(s.HandleGetLog)) 76 | r.PATCH("/logs/:id", s.wrapHandler(s.HandlePatchLog)) 77 | r.DELETE("/logs/:id", s.wrapHandler(s.HandleDeleteLog)) 78 | r.GET("/", s.wrapHandler(s.HandleDashboard)) 79 | n.UseHandler(r) 80 | 81 | s.handler = n 82 | return s, nil 83 | } 84 | 85 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 86 | defer context.Clear(r) 87 | s.handler.ServeHTTP(w, r) 88 | } 89 | 90 | type HandlerFunc func(w http.ResponseWriter, r *http.Request) 91 | 92 | func (s *Server) wrapHandler(handler HandlerFunc) httprouter.Handle { 93 | return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { 94 | ctx := NewContext(r, w) 95 | ctx.SetStart(time.Now()) 96 | ctx.SetParams(ps) 97 | handler(w, r) 98 | } 99 | } 100 | 101 | func (s *Server) userAuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 102 | user, err := s.userFromRequest(r) 103 | if err != nil { 104 | panic(err) 105 | } 106 | if user != nil { 107 | ctx := NewContext(r, w) 108 | ctx.SetUser(user) 109 | } 110 | next(w, r) 111 | } 112 | 113 | func (s *Server) loadTemplates() (*template.Template, error) { 114 | return template.ParseGlob(path.Join(DataDir, "templates/*.html")) 115 | } 116 | 117 | type renderData struct { 118 | Title string 119 | ActiveTab string 120 | Breadcrumb *Breadcrumb 121 | User *models.User 122 | CSRFToken string 123 | CSRFField template.HTML 124 | MapboxAccessToken string 125 | Version string 126 | Runtime string 127 | Content template.HTML 128 | Data interface{} 129 | } 130 | 131 | func (s *Server) render(w http.ResponseWriter, r *http.Request, name string) { 132 | ctx := NewContext(r, w) 133 | 134 | tmpl := s.tmpl 135 | if tmpl == nil { 136 | t, err := s.loadTemplates() 137 | if err != nil { 138 | panic(err) 139 | } 140 | tmpl = t 141 | } 142 | 143 | data := renderData{ 144 | Title: ctx.Title(), 145 | ActiveTab: ctx.ActiveTab(), 146 | Breadcrumb: ctx.Breadcrumb(), 147 | User: ctx.User(), 148 | CSRFToken: csrf.Token(r), 149 | CSRFField: csrf.TemplateField(r), 150 | MapboxAccessToken: s.config.Server.MapboxAccessToken, 151 | Version: tracklog.Version, 152 | Data: ctx.Data(), 153 | } 154 | if s.config.Server.Development { 155 | data.Runtime = fmt.Sprintf("%.0fms", time.Now().Sub(ctx.Start()).Seconds()*1000) 156 | } 157 | 158 | if ctx.NoLayout() { 159 | if err := tmpl.ExecuteTemplate(w, name+".html", data); err != nil { 160 | panic(err) 161 | } 162 | return 163 | } 164 | 165 | buf := new(bytes.Buffer) 166 | if err := tmpl.ExecuteTemplate(buf, name+".html", data); err != nil { 167 | panic(err) 168 | } 169 | data.Content = template.HTML(buf.String()) 170 | 171 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 172 | 173 | if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil { 174 | panic(err) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /pkg/server/logs.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/thcyron/tracklog/pkg/geo" 12 | "github.com/thcyron/tracklog/pkg/heartrate" 13 | "github.com/thcyron/tracklog/pkg/models" 14 | "github.com/thcyron/tracklog/pkg/rdp" 15 | "github.com/thcyron/tracklog/pkg/utils" 16 | ) 17 | 18 | const logTimeFormat = "2006-01-02 15:04:05" 19 | 20 | type logsData struct { 21 | Years []logsDataYear 22 | Groups []*logDataGroup 23 | } 24 | 25 | type logsDataYear struct { 26 | Year int 27 | Active bool 28 | } 29 | 30 | type logDataGroup struct { 31 | Title string 32 | Duration string 33 | Distance string 34 | Logs []logsDataLog 35 | } 36 | 37 | type logsDataLog struct { 38 | ID int 39 | Name string 40 | Start string 41 | Duration string 42 | Distance string 43 | Tags []string 44 | } 45 | 46 | func (s *Server) HandleGetLogs(w http.ResponseWriter, r *http.Request) { 47 | ctx := NewContext(r, w) 48 | 49 | user := ctx.User() 50 | if user == nil { 51 | s.redirectToSignIn(w, r) 52 | return 53 | } 54 | 55 | years, err := s.db.UserLogYears(user) 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | year := 0 61 | explicitYear := false 62 | if s := r.FormValue("year"); s != "" { 63 | if y, err := strconv.Atoi(s); err == nil { 64 | year = y 65 | explicitYear = true 66 | } 67 | } 68 | if year == 0 && len(years) > 0 { 69 | year = years[len(years)-1] 70 | } 71 | 72 | logs, err := s.db.UserLogsByYear(user, year) 73 | if err != nil { 74 | panic(err) 75 | } 76 | 77 | var ( 78 | data logsData 79 | group *logDataGroup 80 | duration uint 81 | distance uint 82 | month time.Month 83 | ) 84 | 85 | for _, log := range logs { 86 | if month != log.Start.Month() { 87 | if group != nil { 88 | group.Duration = utils.Duration(duration).String() 89 | group.Distance = utils.Distance(distance).String() 90 | } 91 | 92 | duration = 0 93 | distance = 0 94 | month = log.Start.Month() 95 | 96 | group = &logDataGroup{ 97 | Title: fmt.Sprintf("%s %d", month, year), 98 | } 99 | data.Groups = append(data.Groups, group) 100 | } 101 | 102 | group.Logs = append(group.Logs, logsDataLog{ 103 | ID: log.ID, 104 | Name: log.Name, 105 | Start: log.Start.Format(logTimeFormat), 106 | Duration: utils.Duration(log.Duration).String(), 107 | Distance: utils.Distance(log.Distance).String(), 108 | Tags: log.Tags, 109 | }) 110 | distance += log.Distance 111 | duration += log.Duration 112 | } 113 | if group != nil { 114 | group.Duration = utils.Duration(duration).String() 115 | group.Distance = utils.Distance(distance).String() 116 | } 117 | 118 | for _, y := range years { 119 | data.Years = append(data.Years, logsDataYear{ 120 | Year: y, 121 | Active: y == year, 122 | }) 123 | } 124 | 125 | if explicitYear { 126 | ctx.SetTitle(fmt.Sprintf("Logs %d", year)) 127 | ctx.Breadcrumb().Add("Logs", "/logs", false) 128 | ctx.Breadcrumb().Add(strconv.Itoa(year), "", true) 129 | } else { 130 | ctx.SetTitle("Logs") 131 | ctx.Breadcrumb().Add("Logs", "", true) 132 | } 133 | 134 | ctx.SetActiveTab("logs") 135 | ctx.SetData(data) 136 | 137 | s.render(w, r, "logs") 138 | } 139 | 140 | type postLogRequest struct { 141 | Filename string `json:"filename"` 142 | GPX string `json:"gpx"` 143 | } 144 | 145 | type postLogResponse struct { 146 | ID int `json:"id"` 147 | } 148 | 149 | func (s *Server) HandlePostLog(w http.ResponseWriter, r *http.Request) { 150 | if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { 151 | http.Error(w, "JSON requests only", http.StatusNotAcceptable) 152 | return 153 | } 154 | 155 | ctx := NewContext(r, w) 156 | user := ctx.User() 157 | if user == nil { 158 | http.Error(w, "unauthorized", http.StatusUnauthorized) 159 | return 160 | } 161 | 162 | var req postLogRequest 163 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 164 | http.Error(w, err.Error(), http.StatusBadRequest) 165 | return 166 | } 167 | 168 | name := strings.TrimSuffix(req.Filename, ".gpx") 169 | log, err := models.NewLog(name, []byte(req.GPX)) 170 | if err != nil { 171 | http.Error(w, err.Error(), http.StatusBadRequest) 172 | return 173 | } 174 | 175 | if err := s.db.AddUserLog(user, log); err != nil { 176 | panic(err) 177 | } 178 | 179 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 180 | w.WriteHeader(http.StatusCreated) 181 | resp := postLogResponse{ID: log.ID} 182 | json.NewEncoder(w).Encode(resp) 183 | } 184 | 185 | type logData struct { 186 | Log logDataLog 187 | } 188 | 189 | type logDataLog struct { 190 | ID int `json:"id"` 191 | Name string `json:"name"` 192 | Start string `json:"start"` 193 | End string `json:"end"` 194 | Duration string `json:"duration"` 195 | Distance string `json:"distance"` 196 | Speed string `json:"speed"` 197 | Pace string `json:"pace"` 198 | HR string `json:"hr,omitempty"` 199 | HRZones logDataHRZones `json:"hrzones"` 200 | Tracks [][]logDataPoint `json:"tracks"` 201 | Tags []string `json:"tags"` 202 | Ascent string `json:"ascent,omitempty"` 203 | Descent string `json:"descent,omitempty"` 204 | } 205 | 206 | type logDataHRZones struct { 207 | Red float64 `json:"red"` 208 | Anaerobic float64 `json:"anaerobic"` 209 | Aerobic float64 `json:"aerobic"` 210 | FatBurning float64 `json:"fatburning"` 211 | Easy float64 `json:"easy"` 212 | None float64 `json:"none"` 213 | } 214 | 215 | type logDataPoint struct { 216 | Lat float64 `json:"lat"` 217 | Lon float64 `json:"lon"` 218 | CumulatedDistance float64 `json:"cumulated_distance"` 219 | Speed float64 `json:"speed"` 220 | Ele float64 `json:"ele"` 221 | HR uint `json:"hr"` 222 | } 223 | 224 | func (s *Server) HandleGetLog(w http.ResponseWriter, r *http.Request) { 225 | ctx := NewContext(r, w) 226 | 227 | user := ctx.User() 228 | if user == nil { 229 | s.redirectToSignIn(w, r) 230 | return 231 | } 232 | 233 | id, err := strconv.Atoi(ctx.Params().ByName("id")) 234 | if err != nil { 235 | http.NotFound(w, r) 236 | return 237 | } 238 | 239 | log, err := s.db.UserLogByID(user, id) 240 | if err != nil { 241 | panic(err) 242 | } 243 | if log == nil { 244 | http.NotFound(w, r) 245 | return 246 | } 247 | 248 | data := logData{ 249 | Log: logDataLog{ 250 | ID: log.ID, 251 | Name: log.Name, 252 | Start: log.Start.Format(logTimeFormat), 253 | End: log.End.Format(logTimeFormat), 254 | Duration: utils.Duration(log.Duration).String(), 255 | Distance: utils.Distance(log.Distance).String(), 256 | Speed: utils.Speed(log.Speed()).String(), 257 | Pace: utils.Speed(log.Speed()).Pace().String(), 258 | Tracks: make([][]logDataPoint, 0, len(log.Tracks)), 259 | Tags: log.Tags, 260 | }, 261 | } 262 | if data.Log.Tags == nil { 263 | data.Log.Tags = make([]string, 0, 0) 264 | } 265 | 266 | var cumDistance, ascent, descent float64 267 | performReduce := r.FormValue("reduce") != "no" 268 | 269 | for _, track := range log.Tracks { 270 | points := track.Points 271 | 272 | if performReduce { 273 | rdpPoints := make([]rdp.Point, 0, len(track.Points)) 274 | for _, point := range track.Points { 275 | λ, φ := geo.ToRad(point.Longitude), geo.ToRad(point.Latitude) 276 | x, y := geo.EquirectangularProjection(λ, φ, 0) 277 | rdpPoints = append(rdpPoints, rdp.Point{ 278 | X: x, 279 | Y: y, 280 | Data: point, 281 | }) 282 | } 283 | const epsilon = 0.0000001 // ≈ 1m 284 | reducedPoints := rdp.Reduce(rdpPoints, epsilon) 285 | points = make([]*models.Point, 0, len(reducedPoints)) 286 | for _, rp := range reducedPoints { 287 | points = append(points, rp.Data.(*models.Point)) 288 | } 289 | } 290 | 291 | var ps []logDataPoint 292 | for i, point := range points { 293 | p := logDataPoint{ 294 | Lat: point.Latitude, 295 | Lon: point.Longitude, 296 | Ele: point.Elevation, 297 | HR: point.Heartrate, 298 | } 299 | if i > 0 { 300 | lastPoint := points[i-1] 301 | cumDistance += point.DistanceTo(lastPoint) 302 | p.CumulatedDistance = cumDistance 303 | p.Speed = point.SpeedTo(lastPoint) 304 | 305 | dEle := point.Elevation - lastPoint.Elevation 306 | if dEle >= 0 { 307 | ascent += dEle 308 | } else { 309 | descent += -dEle 310 | } 311 | } else if len(points) > 1 { 312 | nextPoint := points[i+1] 313 | p.CumulatedDistance = cumDistance 314 | p.Speed = nextPoint.SpeedTo(point) 315 | } 316 | ps = append(ps, p) 317 | } 318 | data.Log.Tracks = append(data.Log.Tracks, ps) 319 | } 320 | 321 | if ascent != 0 || descent != 0 { 322 | data.Log.Ascent = fmt.Sprintf("%d m", int(ascent)) 323 | data.Log.Descent = fmt.Sprintf("%d m", int(descent)) 324 | } 325 | 326 | hrSummary := heartrate.SummaryForLog(log) 327 | if len(hrSummary.Rates) > 0 { 328 | data.Log.HR = strconv.Itoa(hrSummary.Average) 329 | 330 | perc := func(zone heartrate.Zone) float64 { 331 | return float64(hrSummary.Zones[zone]) / float64(len(hrSummary.Rates)) * 100.0 332 | } 333 | 334 | data.Log.HRZones.Red = perc(heartrate.ZoneRed) 335 | data.Log.HRZones.Anaerobic = perc(heartrate.ZoneAnaerobic) 336 | data.Log.HRZones.Aerobic = perc(heartrate.ZoneAerobic) 337 | data.Log.HRZones.FatBurning = perc(heartrate.ZoneFatBurning) 338 | data.Log.HRZones.Easy = perc(heartrate.ZoneEasy) 339 | data.Log.HRZones.None = perc(heartrate.ZoneNone) 340 | } 341 | 342 | ctx.SetTitle(log.Name) 343 | ctx.Breadcrumb().Add("Logs", "/logs", false) 344 | ctx.Breadcrumb().Add(log.Name, "", true) 345 | ctx.SetActiveTab("logs") 346 | ctx.SetData(data) 347 | 348 | s.render(w, r, "log") 349 | } 350 | 351 | func (s *Server) HandleDownloadLog(w http.ResponseWriter, r *http.Request) { 352 | ctx := NewContext(r, w) 353 | 354 | user := ctx.User() 355 | if user == nil { 356 | s.redirectToSignIn(w, r) 357 | return 358 | } 359 | 360 | id, err := strconv.Atoi(ctx.Params().ByName("id")) 361 | if err != nil { 362 | http.NotFound(w, r) 363 | return 364 | } 365 | 366 | log, err := s.db.UserLogByID(user, id) 367 | if err != nil { 368 | panic(err) 369 | } 370 | if log == nil { 371 | http.NotFound(w, r) 372 | return 373 | } 374 | 375 | w.Header().Set("Content-Type", "application/gpx+json") 376 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%d.gpx", log.ID)) 377 | w.Write([]byte(log.GPX)) 378 | } 379 | 380 | type patchLogRequest struct { 381 | Name string `json:"name"` 382 | Tags []string `json:"tags"` 383 | } 384 | 385 | func (s *Server) HandlePatchLog(w http.ResponseWriter, r *http.Request) { 386 | ctx := NewContext(r, w) 387 | 388 | user := ctx.User() 389 | if user == nil { 390 | s.redirectToSignIn(w, r) 391 | return 392 | } 393 | 394 | id, err := strconv.Atoi(ctx.Params().ByName("id")) 395 | if err != nil { 396 | http.NotFound(w, r) 397 | return 398 | } 399 | 400 | log, err := s.db.UserLogByID(user, id) 401 | if err != nil { 402 | panic(err) 403 | } 404 | if log == nil { 405 | http.NotFound(w, r) 406 | return 407 | } 408 | 409 | var req patchLogRequest 410 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 411 | panic(err) 412 | } 413 | log.Name = req.Name 414 | if req.Tags != nil { 415 | log.Tags = req.Tags 416 | } 417 | 418 | if err := s.db.UpdateLog(log); err != nil { 419 | panic(err) 420 | } 421 | 422 | w.WriteHeader(http.StatusNoContent) 423 | } 424 | 425 | func (s *Server) HandleDeleteLog(w http.ResponseWriter, r *http.Request) { 426 | ctx := NewContext(r, w) 427 | 428 | user := ctx.User() 429 | if user == nil { 430 | s.redirectToSignIn(w, r) 431 | return 432 | } 433 | 434 | id, err := strconv.Atoi(ctx.Params().ByName("id")) 435 | if err != nil { 436 | http.NotFound(w, r) 437 | return 438 | } 439 | 440 | log, err := s.db.UserLogByID(user, id) 441 | if err != nil { 442 | panic(err) 443 | } 444 | if log == nil { 445 | http.NotFound(w, r) 446 | return 447 | } 448 | 449 | if err := s.db.DeleteLog(log); err != nil { 450 | panic(err) 451 | } 452 | 453 | http.Redirect(w, r, "/logs", http.StatusSeeOther) 454 | } 455 | -------------------------------------------------------------------------------- /pkg/db/postgres.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | _ "github.com/lib/pq" 8 | "github.com/thcyron/sqlbuilder" 9 | "github.com/thcyron/tracklog/pkg/models" 10 | ) 11 | 12 | type Postgres struct { 13 | db *sql.DB 14 | } 15 | 16 | func (d *Postgres) Open(dsn string) error { 17 | db, err := sql.Open("postgres", dsn) 18 | if err != nil { 19 | return err 20 | } 21 | if err := db.Ping(); err != nil { 22 | return err 23 | } 24 | d.db = db 25 | return nil 26 | } 27 | 28 | func (d *Postgres) UserByID(id int) (*models.User, error) { 29 | user := new(models.User) 30 | 31 | query, args, dest := sqlbuilder.Select(). 32 | Dialect(sqlbuilder.Postgres). 33 | From(`"user"`). 34 | Map(`"id"`, &user.ID). 35 | Map(`"username"`, &user.Username). 36 | Map(`"password"`, &user.Password). 37 | Map(`"password_version"`, &user.PasswordVersion). 38 | Where(`"id" = ?`, id). 39 | Build() 40 | 41 | err := d.db.QueryRow(query, args...).Scan(dest...) 42 | if err == sql.ErrNoRows { 43 | return nil, nil 44 | } 45 | if err != nil { 46 | return nil, err 47 | } 48 | return user, nil 49 | } 50 | 51 | func (d *Postgres) UserByUsername(username string) (*models.User, error) { 52 | user := new(models.User) 53 | 54 | query, args, dest := sqlbuilder.Select(). 55 | Dialect(sqlbuilder.Postgres). 56 | From(`"user"`). 57 | Map(`"id"`, &user.ID). 58 | Map(`"username"`, &user.Username). 59 | Map(`"password"`, &user.Password). 60 | Map(`"password_version"`, &user.PasswordVersion). 61 | Where(`"username" = ?`, username). 62 | Build() 63 | 64 | err := d.db.QueryRow(query, args...).Scan(dest...) 65 | if err == sql.ErrNoRows { 66 | return nil, nil 67 | } 68 | if err != nil { 69 | return nil, err 70 | } 71 | return user, nil 72 | } 73 | 74 | func (d *Postgres) AddUser(user *models.User) error { 75 | var id int 76 | 77 | query, args, dest := sqlbuilder.Insert(). 78 | Dialect(sqlbuilder.Postgres). 79 | Into(`"user"`). 80 | Set(`"username"`, user.Username). 81 | Set(`"password"`, user.Password). 82 | Return(`"id"`, &id). 83 | Build() 84 | 85 | err := d.db.QueryRow(query, args...).Scan(dest...) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | user.ID = id 91 | return nil 92 | } 93 | 94 | func (d *Postgres) UpdateUser(user *models.User) error { 95 | query, args := sqlbuilder.Update(). 96 | Dialect(sqlbuilder.Postgres). 97 | Table(`"user"`). 98 | Set(`"username"`, user.Username). 99 | Set(`"password"`, user.Password). 100 | Set(`"password_version"`, user.PasswordVersion). 101 | Build() 102 | _, err := d.db.Exec(query, args...) 103 | return err 104 | } 105 | 106 | func (d *Postgres) DeleteUser(user *models.User) error { 107 | _, err := d.db.Exec(`DELETE FROM "user" WHERE "id" = $1`, user.ID) 108 | return err 109 | } 110 | 111 | func (d *Postgres) RecentUserLogs(user *models.User, count int) ([]*models.Log, error) { 112 | tx, err := d.db.Begin() 113 | if err != nil { 114 | return nil, err 115 | } 116 | defer tx.Rollback() // read-only transaction 117 | 118 | var ( 119 | log models.Log 120 | logs []*models.Log 121 | ) 122 | 123 | query, args, dest := sqlbuilder.Select(). 124 | Dialect(sqlbuilder.Postgres). 125 | From(`"log"`). 126 | Map(`"id"`, &log.ID). 127 | Map(`"user_id"`, &log.UserID). 128 | Map(`"name"`, &log.Name). 129 | Map(`"start"`, &log.Start). 130 | Map(`"end"`, &log.End). 131 | Map(`"duration"`, &log.Duration). 132 | Map(`"distance"`, &log.Distance). 133 | Where(`"user_id" = ?`, user.ID). 134 | Order(`"created" DESC`). 135 | Limit(count). 136 | Build() 137 | 138 | rows, err := tx.Query(query, args...) 139 | if err != nil { 140 | return nil, err 141 | } 142 | for rows.Next() { 143 | if err := rows.Scan(dest...); err != nil { 144 | return nil, err 145 | } 146 | l := new(models.Log) 147 | *l = log 148 | logs = append(logs, l) 149 | } 150 | if err := rows.Err(); err != nil { 151 | return nil, err 152 | } 153 | 154 | for _, log := range logs { 155 | if err := d.getLogTags(tx, log); err != nil { 156 | return nil, err 157 | } 158 | } 159 | 160 | return logs, nil 161 | } 162 | 163 | func (d *Postgres) UserLogYears(user *models.User) ([]int, error) { 164 | var ( 165 | years []int 166 | year int 167 | ) 168 | 169 | query, args, dest := sqlbuilder.Select(). 170 | Dialect(sqlbuilder.Postgres). 171 | From(`"log"`). 172 | Map(`DISTINCT EXTRACT(YEAR FROM "start")`, &year). 173 | Where(`"user_id" = ?`, user.ID). 174 | Order(`EXTRACT(YEAR FROM "start") ASC`). 175 | Build() 176 | 177 | rows, err := d.db.Query(query, args...) 178 | if err != nil { 179 | return nil, err 180 | } 181 | for rows.Next() { 182 | if err := rows.Scan(dest...); err != nil { 183 | return nil, err 184 | } 185 | years = append(years, year) 186 | } 187 | if err := rows.Err(); err != nil { 188 | return nil, err 189 | } 190 | return years, nil 191 | } 192 | 193 | func (d *Postgres) UserLogByID(user *models.User, id int) (*models.Log, error) { 194 | tx, err := d.db.Begin() 195 | if err != nil { 196 | return nil, err 197 | } 198 | defer tx.Rollback() // read-only transaction 199 | 200 | log := new(models.Log) 201 | 202 | query, args, dest := sqlbuilder.Select(). 203 | Dialect(sqlbuilder.Postgres). 204 | From(`"log"`). 205 | Map(`"id"`, &log.ID). 206 | Map(`"user_id"`, &log.UserID). 207 | Map(`"name"`, &log.Name). 208 | Map(`"start"`, &log.Start). 209 | Map(`"end"`, &log.End). 210 | Map(`"duration"`, &log.Duration). 211 | Map(`"distance"`, &log.Distance). 212 | Map(`"gpx"`, &log.GPX). 213 | Where(`"id" = ?`, id). 214 | Build() 215 | 216 | err = tx.QueryRow(query, args...).Scan(dest...) 217 | if err == sql.ErrNoRows { 218 | return nil, nil 219 | } 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | if err := d.getLogTracks(tx, log); err != nil { 225 | return nil, err 226 | } 227 | if err := d.getLogTags(tx, log); err != nil { 228 | return nil, err 229 | } 230 | 231 | return log, nil 232 | } 233 | 234 | func (d *Postgres) getLogTracks(tx *sql.Tx, log *models.Log) error { 235 | var ( 236 | track models.Track 237 | tracks []*models.Track 238 | ) 239 | 240 | query, args, dest := sqlbuilder.Select(). 241 | Dialect(sqlbuilder.Postgres). 242 | From(`"track"`). 243 | Map(`"id"`, &track.ID). 244 | Map(`"log_id"`, &track.LogID). 245 | Map(`COALESCE("name", '')`, &track.Name). 246 | Map(`"start"`, &track.Start). 247 | Map(`"end"`, &track.End). 248 | Map(`"duration"`, &track.Duration). 249 | Map(`"distance"`, &track.Distance). 250 | Where(`"log_id" = ?`, log.ID). 251 | Build() 252 | 253 | rows, err := tx.Query(query, args...) 254 | if err != nil { 255 | return err 256 | } 257 | for rows.Next() { 258 | if err := rows.Scan(dest...); err != nil { 259 | return err 260 | } 261 | t := new(models.Track) 262 | *t = track 263 | tracks = append(tracks, t) 264 | } 265 | if err := rows.Err(); err != nil { 266 | return err 267 | } 268 | 269 | for _, track := range tracks { 270 | if err := d.getTrackPoints(tx, track); err != nil { 271 | return err 272 | } 273 | } 274 | 275 | log.Tracks = tracks 276 | return nil 277 | } 278 | 279 | func (d *Postgres) getTrackPoints(tx *sql.Tx, track *models.Track) error { 280 | var ( 281 | point models.Point 282 | points []*models.Point 283 | ) 284 | 285 | query, args, dest := sqlbuilder.Select(). 286 | Dialect(sqlbuilder.Postgres). 287 | From(`"trackpoint"`). 288 | Map(`"id"`, &point.ID). 289 | Map(`"track_id"`, &point.TrackID). 290 | Map(`"point"[0]`, &point.Longitude). 291 | Map(`"point"[1]`, &point.Latitude). 292 | Map(`"time"`, &point.Time). 293 | Map(`"elevation"`, &point.Elevation). 294 | Map(`"heartrate"`, &point.Heartrate). 295 | Where(`"track_id" = ?`, track.ID). 296 | Build() 297 | 298 | rows, err := tx.Query(query, args...) 299 | if err != nil { 300 | return err 301 | } 302 | for rows.Next() { 303 | if err := rows.Scan(dest...); err != nil { 304 | return err 305 | } 306 | p := new(models.Point) 307 | *p = point 308 | points = append(points, p) 309 | } 310 | if err := rows.Err(); err != nil { 311 | return err 312 | } 313 | 314 | track.Points = points 315 | return nil 316 | } 317 | 318 | func (d *Postgres) getLogTags(tx *sql.Tx, log *models.Log) error { 319 | var ( 320 | tag string 321 | tags []string 322 | ) 323 | 324 | query, args, dest := sqlbuilder.Select(). 325 | Dialect(sqlbuilder.Postgres). 326 | From(`"log_tag"`). 327 | Map(`"tag"`, &tag). 328 | Where(`"log_id" = ?`, log.ID). 329 | Build() 330 | 331 | rows, err := tx.Query(query, args...) 332 | if err != nil { 333 | return err 334 | } 335 | for rows.Next() { 336 | if err := rows.Scan(dest...); err != nil { 337 | return err 338 | } 339 | tags = append(tags, tag) 340 | } 341 | if err := rows.Err(); err != nil { 342 | return err 343 | } 344 | log.Tags = tags 345 | return nil 346 | } 347 | 348 | func (d *Postgres) UserLogsByYear(user *models.User, year int) ([]*models.Log, error) { 349 | tx, err := d.db.Begin() 350 | if err != nil { 351 | return nil, err 352 | } 353 | defer tx.Rollback() // read-only transaction 354 | 355 | var ( 356 | log models.Log 357 | logs []*models.Log 358 | ) 359 | 360 | query, args, dest := sqlbuilder.Select(). 361 | Dialect(sqlbuilder.Postgres). 362 | From(`"log"`). 363 | Map(`"id"`, &log.ID). 364 | Map(`"name"`, &log.Name). 365 | Map(`"start"`, &log.Start). 366 | Map(`"end"`, &log.End). 367 | Map(`"duration"`, &log.Duration). 368 | Map(`"distance"`, &log.Distance). 369 | Map(`"gpx"`, &log.GPX). 370 | Where(`"user_id" = ?`, user.ID). 371 | Where(`EXTRACT(YEAR FROM "start") = ?`, year). 372 | Order(`"start" DESC`). 373 | Build() 374 | 375 | rows, err := tx.Query(query, args...) 376 | if err != nil { 377 | return nil, err 378 | } 379 | for rows.Next() { 380 | if err := rows.Scan(dest...); err != nil { 381 | rows.Close() 382 | return nil, err 383 | } 384 | l := new(models.Log) 385 | *l = log 386 | logs = append(logs, l) 387 | } 388 | if err := rows.Err(); err != nil { 389 | return nil, err 390 | } 391 | 392 | for _, log := range logs { 393 | if err := d.getLogTags(tx, log); err != nil { 394 | return nil, err 395 | } 396 | } 397 | 398 | return logs, nil 399 | } 400 | 401 | func (d *Postgres) AddUserLog(user *models.User, log *models.Log) error { 402 | tx, err := d.db.Begin() 403 | if err != nil { 404 | return err 405 | } 406 | query, args, dest := sqlbuilder.Insert(). 407 | Dialect(sqlbuilder.Postgres). 408 | Into(`"log"`). 409 | Set(`"user_id"`, user.ID). 410 | Set(`"start"`, log.Start). 411 | Set(`"end"`, log.End). 412 | Set(`"duration"`, log.Duration). 413 | Set(`"distance"`, log.Distance). 414 | Set(`"name"`, log.Name). 415 | Set(`"gpx"`, log.GPX). 416 | Return(`"id"`, &log.ID). 417 | Build() 418 | 419 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil { 420 | tx.Rollback() 421 | return err 422 | } 423 | 424 | for _, track := range log.Tracks { 425 | if err := d.addLogTrack(tx, log, track); err != nil { 426 | tx.Rollback() 427 | return err 428 | } 429 | } 430 | 431 | return tx.Commit() 432 | } 433 | 434 | func (d *Postgres) addLogTrack(tx *sql.Tx, log *models.Log, track *models.Track) error { 435 | var name *string 436 | if track.Name != "" { 437 | name = &track.Name 438 | } 439 | query, args, dest := sqlbuilder.Insert(). 440 | Dialect(sqlbuilder.Postgres). 441 | Into(`"track"`). 442 | Set(`"log_id"`, log.ID). 443 | Set(`"name"`, name). 444 | Set(`"start"`, track.Start). 445 | Set(`"end"`, track.End). 446 | Set(`"duration"`, track.Duration). 447 | Set(`"distance"`, track.Distance). 448 | Return(`"id"`, &track.ID). 449 | Build() 450 | 451 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil { 452 | return err 453 | } 454 | 455 | for _, point := range track.Points { 456 | if err := d.addTrackPoint(tx, track, point); err != nil { 457 | return err 458 | } 459 | } 460 | 461 | return nil 462 | } 463 | 464 | func (d *Postgres) addTrackPoint(tx *sql.Tx, track *models.Track, point *models.Point) error { 465 | query, args, dest := sqlbuilder.Insert(). 466 | Dialect(sqlbuilder.Postgres). 467 | Into(`"trackpoint"`). 468 | Set(`"track_id"`, track.ID). 469 | SetSQL(`"point"`, fmt.Sprintf("point(%f,%f)", point.Longitude, point.Latitude)). 470 | Set(`"time"`, point.Time). 471 | Set(`"elevation"`, point.Elevation). 472 | Set(`"heartrate"`, point.Heartrate). 473 | Return(`"id"`, &point.ID). 474 | Build() 475 | 476 | if err := tx.QueryRow(query, args...).Scan(dest...); err != nil { 477 | return err 478 | } 479 | return nil 480 | } 481 | 482 | func (d *Postgres) UpdateLog(log *models.Log) error { 483 | tx, err := d.db.Begin() 484 | if err != nil { 485 | return nil 486 | } 487 | 488 | query, args := sqlbuilder.Update(). 489 | Dialect(sqlbuilder.Postgres). 490 | Table(`"log"`). 491 | Set(`"name"`, log.Name). 492 | Where(`"id" = ?`, log.ID). 493 | Build() 494 | 495 | _, err = tx.Exec(query, args...) 496 | if err != nil { 497 | return err 498 | } 499 | 500 | if err := d.replaceLogTags(tx, log); err != nil { 501 | tx.Rollback() 502 | return err 503 | } 504 | 505 | return tx.Commit() 506 | } 507 | 508 | func (d *Postgres) replaceLogTags(tx *sql.Tx, log *models.Log) error { 509 | _, err := tx.Exec(`DELETE FROM "log_tag" WHERE "log_id" = $1`, log.ID) 510 | if err != nil { 511 | return err 512 | } 513 | 514 | for _, tag := range log.Tags { 515 | query, args, _ := sqlbuilder.Insert(). 516 | Dialect(sqlbuilder.Postgres). 517 | Into(`"log_tag"`). 518 | Set(`"log_id"`, log.ID). 519 | Set(`"tag"`, tag). 520 | Build() 521 | _, err = tx.Exec(query, args...) 522 | if err != nil { 523 | return err 524 | } 525 | } 526 | 527 | return nil 528 | } 529 | 530 | func (d *Postgres) DeleteLog(log *models.Log) error { 531 | tx, err := d.db.Begin() 532 | if err != nil { 533 | return err 534 | } 535 | 536 | _, err = tx.Exec(`DELETE FROM "log" WHERE "id" = $1`, log.ID) 537 | if err != nil { 538 | return err 539 | } 540 | 541 | return tx.Commit() 542 | } 543 | --------------------------------------------------------------------------------