1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

28 Commits
1.2.2 ... 1.5.1

Author SHA1 Message Date
6c2f0cb1ec feat: add auto-migrations for old md5 password to maintain backwards compatibility 2020-05-25 22:24:29 +02:00
08675bd99f feat: use bcrypt with salts instead of md5 for hashing password (resolve #21) 2020-05-25 21:42:45 +02:00
625994d1e9 feat: add basic sign up instructions 2020-05-24 21:42:15 +02:00
2cca2cb0bb feat: display api key on user interface (resolve #24) 2020-05-24 21:19:05 +02:00
b92a064eb1 chore: update version
chore: update tailwind
2020-05-24 20:38:49 +02:00
f0b17e77b2 docs: update readme 2020-05-24 20:28:12 +02:00
2eabc3953f Merge pull request #20 from muety/2-user-signup-and-login
User signup and login
2020-05-24 20:17:21 +02:00
6614c86395 fix: base path for error redirects 2020-05-24 17:39:19 +02:00
fad91725b0 fix: quick summary paths 2020-05-24 17:36:45 +02:00
f341ae4707 fix: html base href trailing slash 2020-05-24 17:35:44 +02:00
c171d31f30 feat: set html base path from server base path
refactor: services
2020-05-24 17:32:26 +02:00
d6e9f0295a feat: introduce base path config option to better support redirections behind a proxy 2020-05-24 17:08:44 +02:00
411ae49206 fix: use relative urls 2020-05-24 16:55:44 +02:00
abfaa9d768 feat: user signup 2020-05-24 16:34:32 +02:00
a317dc6942 refactor: middlewares and get rid of negroni 2020-05-24 14:50:04 +02:00
041a49ede4 fix: forgot to check in alerts template partial 2020-05-24 13:43:28 +02:00
a7b4b01b04 chore: remove unused dependencies 2020-05-24 13:43:08 +02:00
9697bb5fd5 refactor: use cookie-based login
feat: add login page
2020-05-24 13:41:19 +02:00
d3ab54f6dc chore: enable templates to consist of partials 2020-05-24 10:37:31 +02:00
bbd2c24f9a Update FUNDING.yml 2020-05-23 21:02:12 +02:00
4c39222193 docs: update badges 2020-05-23 20:11:09 +02:00
ef10f8b589 Merge branch 'master' of github.com:n1try/wakapi
 Conflicts:
	README.md
2020-05-23 20:05:40 +02:00
7f3c8dacba docs: add badges 2020-05-23 20:04:48 +02:00
7bf6f353a9 docs: add badges 2020-05-23 20:02:40 +02:00
a77f37da02 Update README.md 2020-05-12 11:16:55 +02:00
94e1de772c doc: update readme 2020-04-26 15:40:46 +02:00
f399f3bf8d chore: remove docker compose file 2020-04-26 15:21:50 +02:00
f264ede147 fix: update docekr file to include env vars and volume 2020-04-26 15:18:47 +02:00
41 changed files with 1157 additions and 468 deletions

View File

@ -5,5 +5,6 @@ WAKAPI_DB_USER=myuser
WAKAPI_DB_PASSWORD=shhh
WAKAPI_DB_HOST=localhost
WAKAPI_DB_PORT=3306
WAKAPI_PASSWORD_SALT=shhh
WAKAPI_DEFAULT_USER_NAME=admin
WAKAPI_DEFAULT_USER_PASSWORD=admin # CHANGE!
WAKAPI_DEFAULT_USER_PASSWORD=admin123 # CHANGE!

1
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
github: muety
liberapay: muety
custom: ['https://paypal.me/ferdinandmuetsch', 'https://www.buymeacoffee.com/n1try']

View File

@ -1,8 +1,10 @@
# Build Stage
FROM golang:1.13 AS build-env
ADD . /src
RUN cd /src && go build -o wakapi
# Final Stage
# When running the application using `docker run`, you can pass environment variables
@ -20,6 +22,15 @@ RUN cd /src && go build -o wakapi
FROM debian
WORKDIR /app
ENV ENV prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_DEFAULT_USER_NAME admin
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.ini /app/
COPY --from=build-env /src/.env.example /app/.env
@ -32,4 +43,6 @@ ADD migrations /app/migrations
ADD views /app/views
ADD wait-for-it.sh .
VOLUME /data
ENTRYPOINT ./wait-for-it.sh

View File

@ -1,39 +1,62 @@
# 📈 wakapi
[![](http://img.shields.io/liberapay/receives/muety.svg?logo=liberapay&style=flat-square)](https://liberapay.com/muety/)
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoff.ee/n1try)
---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
![Wakapi screenshot](https://anchr.io/i/bxQ69.png)
[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoff.ee/n1try)
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
## Prerequisites
### Server
* Go >= 1.13 (with `$GOPATH` properly set)
* An SQL database (MySQL, Postgres, Sqlite)
**On the server side:**
* Go > 1.13 (with `$GOPATH` properly set)
* _Optional_: A MySQL- or Postgres database
### Client
**On your local machine:**
* [WakaTime plugin](https://wakatime.com/plugins) for your editor / IDE
## Usage
* Create an empty database
* Enable Go module support: `export GO111MODULE=on`
* Get code: `go get github.com/muety/wakapi`
* Go to project root: `cd "$GOPATH/src/github.com/muety/wakapi"`
* Copy `.env.example` to `.env` and set database credentials
* Set target port in `config.ini`
* Build executable: `go build`
* Run server: `./wakapi`
* Edit your local `~/.wakatime.cfg` file
* `api_url = https://your.server:someport/api/heartbeat`
* `api_key = the_api_key_printed_to_the_console_after_starting_the_server`
* Open [http://localhost:3000](http://localhost:3000) in your browser
**As an alternative** to building from source or using `go get` you can also download one of the existing [pre-compiled binaries](https://github.com/muety/wakapi/releases).
## Server Setup
### Run from source
1. Enable Go module support: `export GO111MODULE=on`
1. Get code: `go get github.com/muety/wakapi`
1. Go to project root: `cd "$GOPATH/src/github.com/muety/wakapi"`
1. Copy `.env.example` to `.env` and set database credentials
1. Adapt `config.ini` to your needs
1. Build executable: `go build`
1. Run server: `./wakapi`
### Run with Docker
* Edit `docker-compose.yml` file and change passwords for the DB
* Start the application `docker-compose up -d`
* To get the api key look in the logs `docker-compose logs | grep "API key"`
* The application should now be running on `localhost:3000`
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
```
To get your API key, take a look into the logs `docker logs wakapi | grep "API key"`
In addition, you can specify several environment variables for configuration:
* `-e WAKAPI_DEFAULT_USER_NAME=admin`
* `-e WAKAPI_DEFAULT_USER_PASSWORD=admin`
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [.env.example](https://github.com/muety/wakapi/blob/master/.env.example) for further options.
## Client Setup
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics to Wakapi, you need to set them up.
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
2. Make your local WakaTime client talk to Wakapi by **editing your local `~/.wakatime.cfg`** file as follows
```
api_url = https://your.server:someport/api/heartbeat`
api_key = the_api_key_printed_to_the_console_after_starting_the_server`
```
## Customization
### User Accounts
* When starting wakapi for the first time, a default user _**admin**_ with password _**admin**_ is created. The corresponding API key is printed to the console.

View File

@ -1,9 +1,10 @@
[server]
listen = 127.0.0.1
port = 3000
base_path = /
[app]
cleanup = true
cleanup = false
[database]
max_connections = 2

View File

@ -1,27 +0,0 @@
version: "3.1"
services:
wakapi:
image: wakapi:latest
build:
dockerfile: Dockerfile
context: .
container_name: wakapi
environment:
- WAKAPI_DB_TYPE=sqlite3
- WAKAPI_DB_USER=
- WAKAPI_DB_PASSWORD=
- WAKAPI_DB_HOST=
- WAKAPI_DB_NAME=/data/wakapi.db
- WAKAPI_DEFAULT_USER_NAME=admin
- WAKAPI_DEFAULT_USER_PASSWORD=admin
ports:
- "3000:3000"
volumes:
- wakapi_data:/data
volumes:
wakapi_data:

6
go.mod
View File

@ -3,17 +3,19 @@ module github.com/muety/wakapi
go 1.13
require (
github.com/codegangsta/negroni v1.0.0
github.com/gobuffalo/packr/v2 v2.8.0
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/gorm v1.9.11
github.com/joho/godotenv v1.3.0
github.com/kr/pretty v0.2.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rs/cors v1.7.0
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
gopkg.in/ini.v1 v1.50.0
)

10
go.sum
View File

@ -35,8 +35,6 @@ github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
github.com/codegangsta/negroni v1.0.0 h1:+aYywywx4bnKXWvoWtRfJ91vC59NbEhEY03sZjQhbVY=
github.com/codegangsta/negroni v1.0.0/go.mod h1:v0y3T5G7Y1UlFfyxFn/QLRU4a2EuNau2iZY63YTKWo0=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@ -126,9 +124,15 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@ -304,8 +308,6 @@ github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.5.2 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25QiF0w=
github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=

236
main.go
View File

@ -1,131 +1,53 @@
package main
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"io/ioutil"
"github.com/gorilla/handlers"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/codegangsta/negroni"
"github.com/gobuffalo/packr/v2"
"github.com/gorilla/mux"
"github.com/jinzhu/gorm"
"github.com/joho/godotenv"
"github.com/rs/cors"
"github.com/rubenv/sql-migrate"
uuid "github.com/satori/go.uuid"
ini "gopkg.in/ini.v1"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"github.com/rubenv/sql-migrate"
_ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite"
)
var (
db *gorm.DB
config *models.Config
)
var (
aliasService *services.AliasService
heartbeatService *services.HeartbeatService
userService *services.UserService
summaryService *services.SummaryService
aggregationService *services.AggregationService
)
// TODO: Refactor entire project to be structured after business domains
func readConfig() *models.Config {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
env := utils.LookupFatal("ENV")
dbType := utils.LookupFatal("WAKAPI_DB_TYPE")
dbUser := utils.LookupFatal("WAKAPI_DB_USER")
dbPassword := utils.LookupFatal("WAKAPI_DB_PASSWORD")
dbHost := utils.LookupFatal("WAKAPI_DB_HOST")
dbName := utils.LookupFatal("WAKAPI_DB_NAME")
dbPortStr := utils.LookupFatal("WAKAPI_DB_PORT")
defaultUserName := utils.LookupFatal("WAKAPI_DEFAULT_USER_NAME")
defaultUserPassword := utils.LookupFatal("WAKAPI_DEFAULT_USER_PASSWORD")
dbPort, err := strconv.Atoi(dbPortStr)
cfg, err := ini.Load("config.ini")
if err != nil {
log.Fatalf("Fail to read file: %v", err)
}
if dbType == "" {
dbType = "mysql"
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(1)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
data, err := ioutil.ReadFile("data/colors.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
return &models.Config{
Env: env,
Port: port,
Addr: addr,
DbHost: dbHost,
DbPort: uint(dbPort),
DbUser: dbUser,
DbPassword: dbPassword,
DbName: dbName,
DbDialect: dbType,
DbMaxConn: dbMaxConn,
CleanUp: cleanUp,
DefaultUserName: defaultUserName,
DefaultUserPassword: defaultUserPassword,
CustomLanguages: customLangs,
LanguageColors: colors,
}
}
func main() {
// Read Config
config := readConfig()
config = models.GetConfig()
// Enable line numbers in logging
if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Connect to database
db, err := gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
if config.DbDialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;")
}
@ -134,7 +56,7 @@ func main() {
db.DB().SetMaxOpenConns(int(config.DbMaxConn))
if err != nil {
log.Println(err)
log.Fatal("Could not connect to database.")
log.Fatal("could not connect to database")
}
// TODO: Graceful shutdown
defer db.Close()
@ -143,53 +65,58 @@ func main() {
migrateDo := databaseMigrateActions(config.DbDialect)
migrateDo(db)
// Custom migrations and initial data
addDefaultUser(db, config)
migrateLanguages(db, config)
// Services
aliasSrvc := &services.AliasService{Config: config, Db: db}
heartbeatSrvc := &services.HeartbeatService{Config: config, Db: db}
userSrvc := &services.UserService{Config: config, Db: db}
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc}
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc}
aliasService = services.NewAliasService(db)
heartbeatService = services.NewHeartbeatService(db)
userService = services.NewUserService(db)
summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
services := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc}
for _, s := range services {
s.Init()
}
// Custom migrations and initial data
addDefaultUser()
migrateLanguages()
// Aggregate heartbeats to summaries and persist them
go aggregationSrvc.Schedule()
go aggregationService.Schedule()
if config.CleanUp {
go heartbeatSrvc.ScheduleCleanUp()
go heartbeatService.ScheduleCleanUp()
}
// Handlers
heartbeatHandler := &routes.HeartbeatHandler{HeartbeatSrvc: heartbeatSrvc}
summaryHandler := &routes.SummaryHandler{SummarySrvc: summarySrvc}
healthHandler := &routes.HealthHandler{Db: db}
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
publicHandler := routes.NewIndexHandler(userService)
// Setup Routers
router := mux.NewRouter()
publicRouter := router.PathPrefix("/").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
// Middlewares
authenticateMiddleware := &middlewares.AuthenticateMiddleware{
UserSrvc: userSrvc,
WhitelistPaths: []string{"/api/health"},
}
basicAuthMiddleware := &middlewares.RequireBasicAuthMiddleware{}
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedHeaders: []string{"*"},
Debug: false,
})
recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService,
[]string{"/api/health"},
).Handler
// Setup Routing
router := mux.NewRouter()
mainRouter := mux.NewRouter().PathPrefix("/").Subrouter()
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Main Routes
mainRouter.Path("/").Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.Index)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.Login)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.Logout)
publicRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(publicHandler.Signup)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -197,21 +124,7 @@ func main() {
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Static Routes
router.PathPrefix("/assets").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static")))))
// Sub-Routes Setup
router.PathPrefix("/api").Handler(negroni.Classic().
With(corsMiddleware).
With(
negroni.HandlerFunc(authenticateMiddleware.Handle),
negroni.Wrap(apiRouter),
))
router.PathPrefix("/").Handler(negroni.Classic().With(
negroni.HandlerFunc(basicAuthMiddleware.Handle),
negroni.HandlerFunc(authenticateMiddleware.Handle),
negroni.Wrap(mainRouter),
))
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
// Listen HTTP
portString := config.Addr + ":" + strconv.Itoa(config.Port)
@ -251,8 +164,8 @@ func databaseMigrateActions(dbDialect string) func(db *gorm.DB) {
return migrateDo
}
func migrateLanguages(db *gorm.DB, cfg *models.Config) {
for k, v := range cfg.CustomLanguages {
func migrateLanguages() {
for k, v := range config.CustomLanguages {
result := db.Model(models.Heartbeat{}).
Where("language = ?", "").
Where("entity LIKE ?", "%."+k).
@ -266,17 +179,18 @@ func migrateLanguages(db *gorm.DB, cfg *models.Config) {
}
}
func addDefaultUser(db *gorm.DB, cfg *models.Config) {
pw := md5.Sum([]byte(cfg.DefaultUserPassword))
pwString := hex.EncodeToString(pw[:])
apiKey := uuid.NewV4().String()
u := &models.User{ID: cfg.DefaultUserName, Password: pwString, ApiKey: apiKey}
result := db.FirstOrCreate(u, &models.User{ID: u.ID})
if result.Error != nil {
log.Println("Unable to create default user.")
log.Fatal(result.Error)
}
if result.RowsAffected > 0 {
log.Printf("Created default user '%s' with password '%s' and API key '%s'.\n", u.ID, cfg.DefaultUserPassword, u.ApiKey)
func addDefaultUser() {
u, created, err := userService.CreateOrGet(&models.Signup{
Username: config.DefaultUserName,
Password: config.DefaultUserPassword,
})
if err != nil {
log.Println("unable to create default user")
log.Fatal(err)
} else if created {
log.Printf("created default user '%s' with password '%s' and API key '%s'\n", u.ID, config.DefaultUserPassword, u.ApiKey)
} else {
log.Printf("default user '%s' already existing\n", u.ID)
}
}

View File

@ -2,12 +2,11 @@ package middlewares
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"github.com/muety/wakapi/utils"
"log"
"net/http"
"regexp"
"strings"
"time"
@ -18,25 +17,29 @@ import (
)
type AuthenticateMiddleware struct {
UserSrvc *services.UserService
Cache *cache.Cache
WhitelistPaths []string
Initialized bool
config *models.Config
userSrvc *services.UserService
cache *cache.Cache
whitelistPaths []string
}
func (m *AuthenticateMiddleware) Init() {
if m.Cache == nil {
m.Cache = cache.New(1*time.Hour, 2*time.Hour)
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
return &AuthenticateMiddleware{
config: models.GetConfig(),
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
}
m.Initialized = true
}
func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
if !m.Initialized {
m.Init()
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP)
})
}
for _, p := range m.WhitelistPaths {
func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
for _, p := range m.whitelistPaths {
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
next(w, r)
return
@ -44,81 +47,81 @@ func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request,
}
var user *models.User
var userKey string
user, userKey, err := m.tryGetUserByPassword(r)
user, err := m.tryGetUserByCookie(r)
if err != nil {
user, userKey, err = m.tryGetUserByApiKey(r)
user, err = m.tryGetUserByApiKey(r)
}
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized)
} else {
utils.ClearCookie(w, models.AuthCookieKey)
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound)
}
return
}
m.Cache.Set(userKey, user, cache.DefaultExpiration)
m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx))
}
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, string, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return nil, "", errors.New("failed to extract API key")
}
key, err := base64.StdEncoding.DecodeString(authHeader[1])
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
key, err := utils.ExtractBearerAuth(r)
if err != nil {
return nil, "", err
return nil, err
}
var user *models.User
userKey := strings.TrimSpace(string(key))
cachedUser, ok := m.Cache.Get(userKey)
userKey := strings.TrimSpace(key)
cachedUser, ok := m.cache.Get(userKey)
if !ok {
user, err = m.UserSrvc.GetUserByKey(userKey)
user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil {
return nil, "", err
return nil, err
}
} else {
user = cachedUser.(*models.User)
}
return user, userKey, nil
return user, nil
}
func (m *AuthenticateMiddleware) tryGetUserByPassword(r *http.Request) (*models.User, string, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return nil, "", errors.New("failed to extract API key")
}
hash, err := base64.StdEncoding.DecodeString(authHeader[1])
userKey := strings.TrimSpace(string(hash))
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
login, err := utils.ExtractCookieAuth(r, m.config)
if err != nil {
return nil, "", err
return nil, err
}
var user *models.User
cachedUser, ok := m.Cache.Get(userKey)
if !ok {
re := regexp.MustCompile(`^(.+):(.+)$`)
groups := re.FindAllStringSubmatch(userKey, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return nil, "", errors.New("failed to parse user agent string")
}
userId, password := groups[0][1], groups[0][2]
user, err = m.UserSrvc.GetUserById(userId)
if err != nil {
return nil, "", err
}
passwordHash := md5.Sum([]byte(password))
passwordHashString := hex.EncodeToString(passwordHash[:])
if passwordHashString != user.Password {
return nil, "", errors.New("invalid password")
}
} else {
user = cachedUser.(*models.User)
cachedUser, ok := m.cache.Get(login.Username)
if ok {
return cachedUser.(*models.User), nil
}
return user, userKey, nil
user, err := m.userSrvc.GetUserById(login.Username)
if err != nil {
return nil, err
}
if !CheckAndMigratePassword(user, login, m.config.PasswordSalt, m.userSrvc) {
return nil, errors.New("invalid password")
}
return user, nil
}
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
if utils.IsMd5(user.Password) {
if utils.CheckPasswordMd5(user, login.Password) {
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
userServiceRef.MigrateMd5Password(user, login)
return true
}
return false
}
return utils.CheckPasswordBcrypt(user, login.Password, salt)
}

View File

@ -1,14 +0,0 @@
package middlewares
import (
"net/http"
)
type RequireBasicAuthMiddleware struct{}
func (m *RequireBasicAuthMiddleware) Init() {}
func (m *RequireBasicAuthMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
next(w, r)
}

17
middlewares/logging.go Normal file
View File

@ -0,0 +1,17 @@
package middlewares
import (
"github.com/gorilla/handlers"
"net/http"
"os"
)
type LoggingMiddleware struct{}
func NewLoggingMiddleware() *LoggingMiddleware {
return &LoggingMiddleware{}
}
func (m *LoggingMiddleware) Handler(h http.Handler) http.Handler {
return handlers.LoggingHandler(os.Stdout, h)
}

View File

@ -1,23 +1,173 @@
package models
import (
"encoding/json"
"github.com/gorilla/securecookie"
"github.com/joho/godotenv"
"gopkg.in/ini.v1"
"io/ioutil"
"log"
"os"
"strconv"
"strings"
)
var cfg *Config
type Config struct {
Env string
Port int
Addr string
DbHost string
DbPort uint
DbUser string
DbPassword string
DbName string
DbDialect string
DbMaxConn uint
CleanUp bool
DefaultUserName string
DefaultUserPassword string
CustomLanguages map[string]string
LanguageColors map[string]string
Env string
Version string
Port int
Addr string
BasePath string
DbHost string
DbPort uint
DbUser string
DbPassword string
DbName string
DbDialect string
DbMaxConn uint
CleanUp bool
DefaultUserName string
DefaultUserPassword string
PasswordSalt string
SecureCookieHashKey string
SecureCookieBlockKey string
CustomLanguages map[string]string
LanguageColors map[string]string
SecureCookie *securecookie.SecureCookie
}
func (c *Config) IsDev() bool {
return c.Env == "dev"
}
func SetConfig(config *Config) {
cfg = config
}
func GetConfig() *Config {
return cfg
}
func LookupFatal(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("missing env variable '%s'", key)
}
return v
}
func readVersion() string {
file, err := os.Open("version.txt")
if err != nil {
log.Fatal(err)
}
defer file.Close()
bytes, err := ioutil.ReadAll(file)
if err != nil {
log.Fatal(err)
}
return string(bytes)
}
func readConfig() *Config {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
version := readVersion()
env := LookupFatal("ENV")
dbType := LookupFatal("WAKAPI_DB_TYPE")
dbUser := LookupFatal("WAKAPI_DB_USER")
dbPassword := LookupFatal("WAKAPI_DB_PASSWORD")
dbHost := LookupFatal("WAKAPI_DB_HOST")
dbName := LookupFatal("WAKAPI_DB_NAME")
dbPortStr := LookupFatal("WAKAPI_DB_PORT")
passwordSalt := LookupFatal("WAKAPI_PASSWORD_SALT")
defaultUserName := LookupFatal("WAKAPI_DEFAULT_USER_NAME")
defaultUserPassword := LookupFatal("WAKAPI_DEFAULT_USER_PASSWORD")
dbPort, err := strconv.Atoi(dbPortStr)
cfg, err := ini.Load("config.ini")
if err != nil {
log.Fatalf("Fail to read file: %v", err)
}
if dbType == "" {
dbType = "mysql"
}
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(1)
addr := cfg.Section("server").Key("listen").MustString("127.0.0.1")
port, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
port = cfg.Section("server").Key("port").MustInt()
}
basePath := cfg.Section("server").Key("base_path").MustString("/")
if strings.HasSuffix(basePath, "/") {
basePath = basePath[:len(basePath)-1]
}
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
// Read custom languages
customLangs := make(map[string]string)
languageKeys := cfg.Section("languages").Keys()
for _, k := range languageKeys {
customLangs[k.Name()] = k.MustString("unknown")
}
// Read language colors
// Source: https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
var colors = make(map[string]string)
var rawColors map[string]struct {
Color string `json:"color"`
Url string `json:"url"`
}
data, err := ioutil.ReadFile("data/colors.json")
if err != nil {
log.Fatal(err)
}
if err := json.Unmarshal(data, &rawColors); err != nil {
log.Fatal(err)
}
for k, v := range rawColors {
colors[strings.ToLower(k)] = v.Color
}
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
secureCookie := securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
return &Config{
Env: env,
Version: version,
Port: port,
Addr: addr,
BasePath: basePath,
DbHost: dbHost,
DbPort: uint(dbPort),
DbUser: dbUser,
DbPassword: dbPassword,
DbName: dbName,
DbDialect: dbType,
DbMaxConn: dbMaxConn,
CleanUp: cleanUp,
SecureCookie: secureCookie,
PasswordSalt: passwordSalt,
DefaultUserName: defaultUserName,
DefaultUserPassword: defaultUserPassword,
CustomLanguages: customLangs,
LanguageColors: colors,
}
}

5
models/models.go Normal file
View File

@ -0,0 +1,5 @@
package models
func init() {
SetConfig(readConfig())
}

View File

@ -1,5 +1,6 @@
package models
const (
UserKey = "user"
UserKey = "user"
AuthCookieKey = "wakapi_auth"
)

View File

@ -39,4 +39,7 @@ type SummaryItemContainer struct {
type SummaryViewModel struct {
*Summary
LanguageColors map[string]string
Error string
Success string
ApiKey string
}

View File

@ -5,3 +5,20 @@ type User struct {
ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"`
}
type Login struct {
Username string `schema:"username"`
Password string `schema:"password"`
}
type Signup struct {
Username string `schema:"username"`
Password string `schema:"password"`
PasswordRepeat string `schema:"password_repeat"`
}
func (s *Signup) IsValid() bool {
return len(s.Username) >= 3 &&
len(s.Password) >= 6 &&
s.Password == s.PasswordRepeat
}

View File

@ -7,12 +7,16 @@ import (
)
type HealthHandler struct {
Db *gorm.DB
db *gorm.DB
}
func NewHealthHandler(db *gorm.DB) *HealthHandler {
return &HealthHandler{db: db}
}
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int
if err := h.Db.DB().Ping(); err == nil {
if err := h.db.DB().Ping(); err == nil {
dbStatus = 1
}

View File

@ -12,15 +12,18 @@ import (
)
type HeartbeatHandler struct {
HeartbeatSrvc *services.HeartbeatService
config *models.Config
heartbeatSrvc *services.HeartbeatService
}
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *HeartbeatHandler {
return &HeartbeatHandler{
config: models.GetConfig(),
heartbeatSrvc: heartbeatService,
}
}
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
@ -37,7 +40,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
hb.Editor = editor
hb.User = user
hb.UserID = user.ID
hb.Augment(h.HeartbeatSrvc.Config.CustomLanguages)
hb.Augment(h.config.CustomLanguages)
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)
@ -46,7 +49,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
}
}
if err := h.HeartbeatSrvc.InsertBatch(heartbeats); err != nil {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.WriteString(err.Error())
return

186
routes/public.go Normal file
View File

@ -0,0 +1,186 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
)
type IndexHandler struct {
config *models.Config
userSrvc *services.UserService
}
var loginDecoder = schema.NewDecoder()
var signupDecoder = schema.NewDecoder()
func NewIndexHandler(userService *services.UserService) *IndexHandler {
return &IndexHandler{
config: models.GetConfig(),
userSrvc: userService,
}
}
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "") {
return
}
// TODO: make this more generic and reusable
if success := r.URL.Query().Get("success"); success != "" {
templates["index.tpl.html"].Execute(w, struct {
Success string
Error string
}{Success: success})
return
}
templates["index.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
var login models.Login
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
return
}
// TODO: depending on middleware package here is a hack
if !middlewares.CheckAndMigratePassword(user, &login, h.config.PasswordSalt, h.userSrvc) {
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
return
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
utils.ClearCookie(w, models.AuthCookieKey)
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
switch r.Method {
case http.MethodPost:
h.handlePostSignup(w, r)
return
default:
h.handleGetSignup(w, r)
return
}
}
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
if handleAlerts(w, r, "signup.tpl.html") {
return
}
templates["signup.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
var signup models.Signup
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
if !signup.IsValid() {
respondAlert(w, "invalid parameters", "", "signup.tpl.html", http.StatusBadRequest)
return
}
_, created, err := h.userSrvc.CreateOrGet(&signup)
if err != nil {
respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError)
return
}
if !created {
respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict)
return
}
msg := url.QueryEscape("account created successfully")
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.BasePath, msg), http.StatusFound)
}

84
routes/routes.go Normal file
View File

@ -0,0 +1,84 @@
package routes
import (
"fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"net/http"
"path"
"strings"
)
func init() {
loadTemplates()
}
var templates map[string]*template.Template
func loadTemplates() {
tplPath := "views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
"title": strings.Title,
"capitalize": utils.Capitalize,
"getBasePath": func() string {
return models.GetConfig().BasePath
},
"getVersion": func() string {
return models.GetConfig().Version
},
})
templates = make(map[string]*template.Template)
files, err := ioutil.ReadDir(tplPath)
if err != nil {
panic(err)
}
for _, file := range files {
tplName := file.Name()
if file.IsDir() || path.Ext(tplName) != ".html" {
continue
}
tpl, err := tpls.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
if err != nil {
panic(err)
}
templates[tplName] = tpl
}
}
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
w.WriteHeader(status)
if tplName == "" {
tplName = "index.tpl.html"
}
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error})
}
// TODO: do better
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
} else {
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
}
return true
}
if success := r.URL.Query().Get("success"); success != "" {
respondAlert(w, "", success, tplName, http.StatusOK)
return true
}
return false
}

View File

@ -2,9 +2,7 @@ package routes
import (
"errors"
"html/template"
"net/http"
"path"
"time"
"github.com/muety/wakapi/models"
@ -22,39 +20,19 @@ const (
)
type SummaryHandler struct {
SummarySrvc *services.SummaryService
Initialized bool
indexTemplate *template.Template
summarySrvc *services.SummaryService
config *models.Config
}
func (m *SummaryHandler) Init() {
m.loadTemplates()
m.Initialized = true
}
func (m *SummaryHandler) loadTemplates() {
indexTplPath := "views/index.tpl.html"
indexTpl, err := template.New(path.Base(indexTplPath)).Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
}).ParseFiles(indexTplPath)
if err != nil {
panic(err)
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
return &SummaryHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
}
m.indexTemplate = indexTpl
}
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.Initialized {
h.Init()
}
summary, err, status := loadUserSummary(r, h.SummarySrvc)
summary, err, status := loadUserSummary(r, h.summarySrvc)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -65,17 +43,8 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
}
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.Initialized {
h.Init()
}
if h.SummarySrvc.Config.IsDev() {
h.loadTemplates()
if h.config.IsDev() {
loadTemplates()
}
q := r.URL.Query()
@ -84,19 +53,25 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode()
}
summary, err, status := loadUserSummary(r, h.SummarySrvc)
summary, err, status := loadUserSummary(r, h.summarySrvc)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
respondAlert(w, err.Error(), "", "summary.tpl.html", status)
return
}
user := r.Context().Value(models.UserKey).(*models.User)
if user == nil {
respondAlert(w, "unauthorized", "", "summary.tpl.html", http.StatusUnauthorized)
return
}
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.SummarySrvc.Config.LanguageColors, summary),
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
ApiKey: user.ApiKey,
}
h.indexTemplate.Execute(w, vm)
templates["summary.tpl.html"].Execute(w, vm)
}
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {

View File

@ -22,14 +22,22 @@ type AggregationService struct {
HeartbeatService *HeartbeatService
}
func NewAggregationService(db *gorm.DB, userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
return &AggregationService{
Config: models.GetConfig(),
Db: db,
UserService: userService,
SummaryService: summaryService,
HeartbeatService: heartbeatService,
}
}
type AggregationJob struct {
UserID string
From time.Time
To time.Time
}
func (srv *AggregationService) Init() {}
// Schedule a job to (re-)generate summaries every day shortly after midnight
// TODO: Make configurable
func (srv *AggregationService) Schedule() {

View File

@ -13,9 +13,14 @@ type AliasService struct {
Db *gorm.DB
}
var userAliases sync.Map
func NewAliasService(db *gorm.DB) *AliasService {
return &AliasService{
Config: models.GetConfig(),
Db: db,
}
}
func (srv *AliasService) Init() {}
var userAliases sync.Map
func (srv *AliasService) LoadUserAliases(userId string) error {
var aliases []*models.Alias

View File

@ -1,5 +0,0 @@
package services
type Initializable interface {
Init()
}

View File

@ -21,7 +21,12 @@ type HeartbeatService struct {
Db *gorm.DB
}
func (srv *HeartbeatService) Init() {}
func NewHeartbeatService(db *gorm.DB) *HeartbeatService {
return &HeartbeatService{
Config: models.GetConfig(),
Db: db,
}
}
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
var batch []interface{}

View File

@ -21,15 +21,21 @@ type SummaryService struct {
AliasService *AliasService
}
func NewSummaryService(db *gorm.DB, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
return &SummaryService{
Config: models.GetConfig(),
Cache: cache.New(24*time.Hour, 24*time.Hour),
Db: db,
HeartbeatService: heartbeatService,
AliasService: aliasService,
}
}
type Interval struct {
Start time.Time
End time.Time
}
func (srv *SummaryService) Init() {
srv.Cache = cache.New(24*time.Hour, 24*time.Hour)
}
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary
var cacheKey string
@ -138,7 +144,7 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
return summaries, nil
}
// Will return *models.Summary objects with only user_id and to_time filled
// Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary
if err := srv.Db.

View File

@ -1,8 +1,11 @@
package services
import (
"errors"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
uuid "github.com/satori/go.uuid"
)
type UserService struct {
@ -10,7 +13,12 @@ type UserService struct {
Db *gorm.DB
}
func (srv *UserService) Init() {}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{
Config: models.GetConfig(),
Db: db,
}
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
u := &models.User{}
@ -37,3 +45,42 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
}
return users, nil
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
u := &models.User{
ID: signup.Username,
ApiKey: uuid.NewV4().String(),
Password: signup.Password,
}
if err := utils.HashPassword(u, srv.Config.PasswordSalt); err != nil {
return nil, false, err
}
result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID})
if err := result.Error; err != nil {
return nil, false, err
}
if result.RowsAffected == 1 {
return u, true, nil
}
return u, false, nil
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
return nil, err
}
result := srv.Db.Model(user).Update("password", user.Password)
if err := result.Error; err != nil {
return nil, err
} else if result.RowsAffected < 1 {
return nil, errors.New("nothing changes")
}
return user, nil
}

View File

@ -196,6 +196,21 @@ function getRandomColor(seed) {
return color
}
function showApiKeyPopup(event) {
const el = document.getElementById('api-key-popup')
el.classList.remove('hidden')
el.classList.add('block')
event.stopPropagation()
}
function copyApiKey(event) {
const el = document.getElementById('api-key-container')
el.select()
el.setSelectionRange(0, 9999)
document.execCommand('copy')
event.stopPropagation()
}
// https://koddsson.com/posts/emoji-favicon/
const favicon = document.querySelector('link[rel=icon]')
if (favicon) {
@ -211,6 +226,17 @@ if (favicon) {
}
}
// Click outside
window.addEventListener('click', function(event) {
if (event.target.classList.contains('popup')) {
return
}
document.querySelectorAll('.popup').forEach(el => {
el.classList.remove('block')
el.classList.add('hidden')
})
})
window.addEventListener('load', function () {
draw()
})

87
utils/auth.go Normal file
View File

@ -0,0 +1,87 @@
package utils
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"github.com/muety/wakapi/models"
"golang.org/x/crypto/bcrypt"
"net/http"
"regexp"
"strings"
)
var md5Regex = regexp.MustCompile(`^[a-f0-9]{32}$`)
func ExtractBasicAuth(r *http.Request) (username, password string, err error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return username, password, errors.New("failed to extract API key")
}
hash, err := base64.StdEncoding.DecodeString(authHeader[1])
userKey := strings.TrimSpace(string(hash))
if err != nil {
return username, password, err
}
re := regexp.MustCompile(`^(.+):(.+)$`)
groups := re.FindAllStringSubmatch(userKey, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return username, password, errors.New("failed to parse user agent string")
}
username, password = groups[0][1], groups[0][2]
return username, password, err
}
func ExtractBearerAuth(r *http.Request) (key string, err error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return key, errors.New("failed to extract API key")
}
keyBytes, err := base64.StdEncoding.DecodeString(authHeader[1])
return string(keyBytes), err
}
func ExtractCookieAuth(r *http.Request, config *models.Config) (login *models.Login, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil {
return nil, errors.New("invalid parameters")
}
return login, nil
}
func IsMd5(hash string) bool {
return md5Regex.Match([]byte(hash))
}
func CheckPasswordBcrypt(user *models.User, password, salt string) bool {
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password+salt))
return err == nil
}
// deprecated, only here for backwards compatibility
func CheckPasswordMd5(user *models.User, password string) bool {
hash := md5.Sum([]byte(password))
hashStr := hex.EncodeToString(hash[:])
if hashStr == user.Password {
return true
}
return false
}
// inplace
func HashPassword(u *models.User, salt string) error {
bytes, err := bcrypt.GenerateFromPassword([]byte(u.Password+salt), bcrypt.DefaultCost)
if err == nil {
u.Password = string(bytes)
}
return err
}

View File

@ -1,14 +0,0 @@
package utils
import (
"log"
"os"
)
func LookupFatal(key string) string {
v, ok := os.LookupEnv(key)
if !ok {
log.Fatalf("missing env variable '%s'", key)
}
return v
}

View File

@ -12,3 +12,13 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
w.WriteHeader(http.StatusInternalServerError)
}
}
func ClearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
})
}

10
utils/strings.go Normal file
View File

@ -0,0 +1,10 @@
package utils
import (
"fmt"
"strings"
)
func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
}

View File

@ -1 +1 @@
1.2.2
1.5.1

13
views/alerts.tpl.html Normal file
View File

@ -0,0 +1,13 @@
{{ if .Error }}
<div class="flex justify-center w-full">
<div class="p-4 text-white text-sm bg-red-500 rounded mt-16 shadow flex-grow max-w-lg">
Error: {{ .Error | capitalize }}
</div>
</div>
{{ else if .Success }}
<div class="flex justify-center w-full">
<div class="p-4 text-white text-sm bg-green-500 rounded mt-16 shadow flex-grow max-w-lg">
{{ .Success | capitalize }}
</div>
</div>
{{ end }}

12
views/foot.tpl.html Normal file
View File

@ -0,0 +1,12 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
<script>
let wakapiData = {}
let languageColors = {{ .LanguageColors | json }}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
</script>
<script src="assets/app.js"></script>

11
views/footer.tpl.html Normal file
View File

@ -0,0 +1,11 @@
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-12">
<div class="text-xs font-mono">
v{{ getVersion }}
</div>
<div>
Made with &nbsp; 🤍 &nbsp; by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a
href="https://github.com/muety/wakapi" class="border-b border-green-700">open-source</a> software
</div>
<div>
</div>
</footer>

9
views/head.tpl.html Normal file
View File

@ -0,0 +1,9 @@
<head>
<title>Wakapi Coding Statistics</title>
<base href="{{ getBasePath }}/">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
<link rel="icon" data-emoji="📊" type="image/png">
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet">
<link href="assets/app.css" rel="stylesheet">
</head>

View File

@ -1,76 +1,42 @@
<html>
<head>
<title>Wakapi Coding Statistics</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
<link rel="icon" data-emoji="📊" type="image/png">
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
<link href="assets/app.css" rel="stylesheet">
</head>
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
</div>
<div class="text-white text-sm flex items-center justify-center mt-4">
<a href="?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
<a href="?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
<a href="?interval=week" class="m-1 border-b border-green-700">This Week</a>
<a href="?interval=month" class="m-1 border-b border-green-700">This Month</a>
<a href="?interval=year" class="m-1 border-b border-green-700">This Year</a>
<a href="?interval=any" class="m-1 border-b border-green-700">All Time</a>
</div>
<main class="mt-10 flex-grow" id="grid-container">
<div class="flex justify-center">
<div class="p-1">
<div class="flex justify-center p-4 bg-white rounded shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span id="total-span" title="Total Hours"></span></p>
</div>
</div>
</div>
<div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
<canvas id="chart-projects"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
<canvas id="chart-os"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
<canvas id="chart-language"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
<canvas id="chart-editor"></canvas>
</div>
</div>
</div>
</main>
<footer class="w-full text-center text-gray-300 text-xs mt-12">
Made by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a href="https://github.com/muety/wakapi" class="border-b border-green-700">open-source</a>.
</footer>
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
{{ template "alerts.tpl.html" . }}
<script>
let wakapiData = {}
let languageColors = {{ .LanguageColors | json }}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
</script>
<script src="assets/app.js"></script>
<main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
<div class="flex-grow max-w-lg mt-12">
<form action="login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
name="username" placeholder="Enter your username" minlength="3" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
name="password" placeholder="******" minlength="6" required>
</div>
<div class="flex justify-between">
<a href="signup">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
</a>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

51
views/signup.tpl.html Normal file
View File

@ -0,0 +1,51 @@
<html>
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
<div class="flex-grow max-w-lg mt-8">
<div>
<p class="text-sm text-gray-300">
💡 In order to use Wakapi, you need to create an account.
After successful signup, you still need to set up the <a href="https://wakatime.com" target="_blank" class="border-b border-green-700">WakaTime</a> client tools.
Please refer to <a href="https://github.com/muety/wakapi#client-setup" target="_blank" class="border-b border-green-700">this readme section</a> for instructions.
You will be able to view you <strong>API Key</strong> once you log in.
</p>
</div>
<form class="mt-10" action="signup" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
name="username" placeholder="Choose a username" minlength="3" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
name="password" placeholder="Choose a password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Create Account</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

78
views/summary.tpl.html Normal file
View File

@ -0,0 +1,78 @@
<html>
{{ template "head.tpl.html" . }}
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
<span class="text-xs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly value="{{ .ApiKey }}" style="min-width: 330px">
</div>
<div class="flex items-center px-2 border-l border-gray-700">
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
</div>
</div>
<div class="absolute top-0 right-0 mr-8 mt-10 py-2">
<form action="logout" method="post">
<button type="button" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm" onclick="showApiKeyPopup(event)">🔐</button>
<button type="submit" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
</div>
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
</div>
<div class="text-white text-sm flex items-center justify-center mt-4">
<a href="summary?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
<a href="summary?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
<a href="summary?interval=week" class="m-1 border-b border-green-700">This Week</a>
<a href="summary?interval=month" class="m-1 border-b border-green-700">This Month</a>
<a href="summary?interval=year" class="m-1 border-b border-green-700">This Year</a>
<a href="summary?interval=any" class="m-1 border-b border-green-700">All Time</a>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow" id="grid-container">
<div class="flex justify-center">
<div class="p-1">
<div class="flex justify-center p-4 bg-white rounded shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span id="total-span" title="Total Hours"></span></p>
</div>
</div>
</div>
<div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
<canvas id="chart-projects"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
<canvas id="chart-os"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
<canvas id="chart-language"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
<canvas id="chart-editor"></canvas>
</div>
</div>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>