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

Merge pull request #20 from muety/2-user-signup-and-login

User signup and login
This commit is contained in:
Ferdinand Mütsch 2020-05-24 20:17:21 +02:00 committed by GitHub
commit 2eabc3953f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 933 additions and 405 deletions

View File

@ -1,6 +1,7 @@
[server]
listen = 127.0.0.1
port = 3000
base_path = /
[app]
cleanup = true

5
go.mod
View File

@ -3,15 +3,16 @@ 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

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)
indexHandler := routes.NewIndexHandler(userService)
// Setup Routers
router := mux.NewRouter()
indexRouter := router.PathPrefix("/").Subrouter()
summaryRouter := indexRouter.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
indexRouter.Path("/").Methods(http.MethodGet).HandlerFunc(indexHandler.Index)
indexRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(indexHandler.Login)
indexRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(indexHandler.Logout)
indexRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(indexHandler.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,10 @@ package middlewares
import (
"context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
"strings"
"time"
@ -18,25 +16,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 +46,66 @@ 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 {
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)
cachedUser, ok := m.cache.Get(login.Username)
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)
user, err = m.userSrvc.GetUserById(login.Username)
if err != nil {
return nil, "", err
return nil, err
}
passwordHash := md5.Sum([]byte(password))
passwordHashString := hex.EncodeToString(passwordHash[:])
if passwordHashString != user.Password {
return nil, "", errors.New("invalid password")
if !utils.CheckPassword(user, login.Password) {
return nil, errors.New("invalid password")
}
} else {
user = cachedUser.(*models.User)
}
return user, userKey, nil
return user, nil
}

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,9 +1,24 @@
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
BasePath string
DbHost string
DbPort uint
DbUser string
@ -14,10 +29,123 @@ type Config struct {
CleanUp bool
DefaultUserName string
DefaultUserPassword 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 readConfig() *Config {
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
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")
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,
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,
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

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

View File

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

View File

@ -5,3 +5,14 @@ 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"`
}

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

214
routes/index.go Normal file
View File

@ -0,0 +1,214 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"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
}
if !utils.CheckPassword(user, login.Password) {
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.Password != signup.PasswordRepeat {
respondAlert(w, "passwords do not match", "", "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)
}
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
}

50
routes/routes.go Normal file
View File

@ -0,0 +1,50 @@
package routes
import (
"fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"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
},
})
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
}
}

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
cummarySrvc *services.SummaryService
config *models.Config
}
func (m *SummaryHandler) Init() {
m.loadTemplates()
m.Initialized = true
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
return &SummaryHandler{
cummarySrvc: summaryService,
config: models.GetConfig(),
}
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)
}
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.cummarySrvc)
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,7 +53,7 @@ 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.cummarySrvc)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
@ -93,10 +62,10 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{
Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.SummarySrvc.Config.LanguageColors, summary),
LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
}
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 (
"crypto/md5"
"encoding/hex"
"github.com/jinzhu/gorm"
"github.com/muety/wakapi/models"
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,26 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
}
return users, nil
}
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
pw := md5.Sum([]byte(signup.Password))
pwString := hex.EncodeToString(pw[:])
apiKey := uuid.NewV4().String()
u := &models.User{
ID: signup.Username,
ApiKey: apiKey,
Password: pwString,
}
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
}

65
utils/auth.go Normal file
View File

@ -0,0 +1,65 @@
package utils
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"github.com/muety/wakapi/models"
"net/http"
"regexp"
"strings"
)
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 CheckPassword(user *models.User, password string) bool {
passwordHash := md5.Sum([]byte(password))
passwordHashString := hex.EncodeToString(passwordHash[:])
if passwordHashString == user.Password {
return true
}
return false
}

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:])
}

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>

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

@ -0,0 +1,3 @@
<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>

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.0/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>
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</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>
{{ 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-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" required>
</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 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="******" 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>
<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>
<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 "footer.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>
{{ template "foot.tpl.html" . }}
</body>
</html>

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

@ -0,0 +1,42 @@
<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-12">
<form 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" required>
</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" 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" 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>

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

@ -0,0 +1,67 @@
<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="absolute top-0 right-0 mr-8 mt-10 py-2">
<form action="logout" method="post">
<button type="submit" class="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>