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] [server]
listen = 127.0.0.1 listen = 127.0.0.1
port = 3000 port = 3000
base_path = /
[app] [app]
cleanup = true cleanup = true

5
go.mod
View File

@ -3,15 +3,16 @@ module github.com/muety/wakapi
go 1.13 go 1.13
require ( require (
github.com/codegangsta/negroni v1.0.0
github.com/gobuffalo/packr/v2 v2.8.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/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/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/gorm v1.9.11 github.com/jinzhu/gorm v1.9.11
github.com/joho/godotenv v1.3.0 github.com/joho/godotenv v1.3.0
github.com/kr/pretty v0.2.0 // indirect github.com/kr/pretty v0.2.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible 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/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f 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/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/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/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/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/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= 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 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 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/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.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 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 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 v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/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= 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.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 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25QiF0w=
github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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 h1:+2DdDcxVYlarHjYcZTt8dZ4Ec8cXZirzL5ko0mkKPjU=
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= 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= 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 package main
import ( import (
"crypto/md5" "github.com/gorilla/handlers"
"encoding/hex"
"encoding/json"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/codegangsta/negroni"
"github.com/gobuffalo/packr/v2" "github.com/gobuffalo/packr/v2"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/jinzhu/gorm" "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/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
"github.com/rubenv/sql-migrate"
_ "github.com/jinzhu/gorm/dialects/mysql" _ "github.com/jinzhu/gorm/dialects/mysql"
_ "github.com/jinzhu/gorm/dialects/postgres" _ "github.com/jinzhu/gorm/dialects/postgres"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "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 // 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() { func main() {
// Read Config config = models.GetConfig()
config := readConfig()
// Enable line numbers in logging // Enable line numbers in logging
if config.IsDev() { if config.IsDev() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
} }
// Connect to database // 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" { if config.DbDialect == "sqlite3" {
db.DB().Exec("PRAGMA foreign_keys = ON;") db.DB().Exec("PRAGMA foreign_keys = ON;")
} }
@ -134,7 +56,7 @@ func main() {
db.DB().SetMaxOpenConns(int(config.DbMaxConn)) db.DB().SetMaxOpenConns(int(config.DbMaxConn))
if err != nil { if err != nil {
log.Println(err) log.Println(err)
log.Fatal("Could not connect to database.") log.Fatal("could not connect to database")
} }
// TODO: Graceful shutdown // TODO: Graceful shutdown
defer db.Close() defer db.Close()
@ -143,53 +65,58 @@ func main() {
migrateDo := databaseMigrateActions(config.DbDialect) migrateDo := databaseMigrateActions(config.DbDialect)
migrateDo(db) migrateDo(db)
// Custom migrations and initial data
addDefaultUser(db, config)
migrateLanguages(db, config)
// Services // Services
aliasSrvc := &services.AliasService{Config: config, Db: db} aliasService = services.NewAliasService(db)
heartbeatSrvc := &services.HeartbeatService{Config: config, Db: db} heartbeatService = services.NewHeartbeatService(db)
userSrvc := &services.UserService{Config: config, Db: db} userService = services.NewUserService(db)
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc} summaryService = services.NewSummaryService(db, heartbeatService, aliasService)
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc} aggregationService = services.NewAggregationService(db, userService, summaryService, heartbeatService)
services := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc} // Custom migrations and initial data
for _, s := range services { addDefaultUser()
s.Init() migrateLanguages()
}
// Aggregate heartbeats to summaries and persist them // Aggregate heartbeats to summaries and persist them
go aggregationSrvc.Schedule() go aggregationService.Schedule()
if config.CleanUp { if config.CleanUp {
go heartbeatSrvc.ScheduleCleanUp() go heartbeatService.ScheduleCleanUp()
} }
// Handlers // Handlers
heartbeatHandler := &routes.HeartbeatHandler{HeartbeatSrvc: heartbeatSrvc} heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := &routes.SummaryHandler{SummarySrvc: summarySrvc} summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := &routes.HealthHandler{Db: db} 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 // Middlewares
authenticateMiddleware := &middlewares.AuthenticateMiddleware{ recoveryMiddleware := handlers.RecoveryHandler()
UserSrvc: userSrvc, loggingMiddleware := middlewares.NewLoggingMiddleware().Handler
WhitelistPaths: []string{"/api/health"}, corsMiddleware := handlers.CORS()
} authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
basicAuthMiddleware := &middlewares.RequireBasicAuthMiddleware{} userService,
corsMiddleware := cors.New(cors.Options{ []string{"/api/health"},
AllowedOrigins: []string{"*"}, ).Handler
AllowedHeaders: []string{"*"},
Debug: false,
})
// Setup Routing // Router configs
router := mux.NewRouter() router.Use(loggingMiddleware, recoveryMiddleware)
mainRouter := mux.NewRouter().PathPrefix("/").Subrouter() summaryRouter.Use(authenticateMiddleware)
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Main Routes // Public Routes
mainRouter.Path("/").Methods(http.MethodGet).HandlerFunc(summaryHandler.Index) 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 // API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -197,21 +124,7 @@ func main() {
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Static Routes // Static Routes
router.PathPrefix("/assets").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static"))))) router.PathPrefix("/assets").Handler(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),
))
// Listen HTTP // Listen HTTP
portString := config.Addr + ":" + strconv.Itoa(config.Port) portString := config.Addr + ":" + strconv.Itoa(config.Port)
@ -251,8 +164,8 @@ func databaseMigrateActions(dbDialect string) func(db *gorm.DB) {
return migrateDo return migrateDo
} }
func migrateLanguages(db *gorm.DB, cfg *models.Config) { func migrateLanguages() {
for k, v := range cfg.CustomLanguages { for k, v := range config.CustomLanguages {
result := db.Model(models.Heartbeat{}). result := db.Model(models.Heartbeat{}).
Where("language = ?", ""). Where("language = ?", "").
Where("entity LIKE ?", "%."+k). Where("entity LIKE ?", "%."+k).
@ -266,17 +179,18 @@ func migrateLanguages(db *gorm.DB, cfg *models.Config) {
} }
} }
func addDefaultUser(db *gorm.DB, cfg *models.Config) { func addDefaultUser() {
pw := md5.Sum([]byte(cfg.DefaultUserPassword)) u, created, err := userService.CreateOrGet(&models.Signup{
pwString := hex.EncodeToString(pw[:]) Username: config.DefaultUserName,
apiKey := uuid.NewV4().String() Password: config.DefaultUserPassword,
u := &models.User{ID: cfg.DefaultUserName, Password: pwString, ApiKey: apiKey} })
result := db.FirstOrCreate(u, &models.User{ID: u.ID})
if result.Error != nil { if err != nil {
log.Println("Unable to create default user.") log.Println("unable to create default user")
log.Fatal(result.Error) log.Fatal(err)
} } else if created {
if result.RowsAffected > 0 { log.Printf("created default user '%s' with password '%s' and API key '%s'\n", u.ID, config.DefaultUserPassword, u.ApiKey)
log.Printf("Created default user '%s' with password '%s' and API key '%s'.\n", u.ID, cfg.DefaultUserPassword, u.ApiKey) } else {
log.Printf("default user '%s' already existing\n", u.ID)
} }
} }

View File

@ -2,12 +2,10 @@ package middlewares
import ( import (
"context" "context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors" "errors"
"fmt"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@ -18,25 +16,29 @@ import (
) )
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
UserSrvc *services.UserService config *models.Config
Cache *cache.Cache userSrvc *services.UserService
WhitelistPaths []string cache *cache.Cache
Initialized bool whitelistPaths []string
} }
func (m *AuthenticateMiddleware) Init() { func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
if m.Cache == nil { return &AuthenticateMiddleware{
m.Cache = cache.New(1*time.Hour, 2*time.Hour) 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) { func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
if !m.Initialized { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.Init() 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 { if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
next(w, r) next(w, r)
return return
@ -44,81 +46,66 @@ func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request,
} }
var user *models.User var user *models.User
var userKey string user, err := m.tryGetUserByCookie(r)
user, userKey, err := m.tryGetUserByPassword(r)
if err != nil { if err != nil {
user, userKey, err = m.tryGetUserByApiKey(r) user, err = m.tryGetUserByApiKey(r)
} }
if err != nil { 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 return
} }
m.Cache.Set(userKey, user, cache.DefaultExpiration) m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user) ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx)) next(w, r.WithContext(ctx))
} }
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, string, error) { func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ") key, err := utils.ExtractBearerAuth(r)
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return nil, "", errors.New("failed to extract API key")
}
key, err := base64.StdEncoding.DecodeString(authHeader[1])
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
var user *models.User var user *models.User
userKey := strings.TrimSpace(string(key)) userKey := strings.TrimSpace(key)
cachedUser, ok := m.Cache.Get(userKey) cachedUser, ok := m.cache.Get(userKey)
if !ok { if !ok {
user, err = m.UserSrvc.GetUserByKey(userKey) user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
} else { } else {
user = cachedUser.(*models.User) user = cachedUser.(*models.User)
} }
return user, userKey, nil return user, nil
} }
func (m *AuthenticateMiddleware) tryGetUserByPassword(r *http.Request) (*models.User, string, error) { func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ") login, err := utils.ExtractCookieAuth(r, m.config)
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))
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
var user *models.User var user *models.User
cachedUser, ok := m.Cache.Get(userKey) cachedUser, ok := m.cache.Get(login.Username)
if !ok { if !ok {
re := regexp.MustCompile(`^(.+):(.+)$`) user, err = m.userSrvc.GetUserById(login.Username)
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 { if err != nil {
return nil, "", err return nil, err
} }
passwordHash := md5.Sum([]byte(password)) if !utils.CheckPassword(user, login.Password) {
passwordHashString := hex.EncodeToString(passwordHash[:]) return nil, errors.New("invalid password")
if passwordHashString != user.Password {
return nil, "", errors.New("invalid password")
} }
} else { } else {
user = cachedUser.(*models.User) 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,23 +1,151 @@
package models 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 { type Config struct {
Env string Env string
Port int Port int
Addr string Addr string
DbHost string BasePath string
DbPort uint DbHost string
DbUser string DbPort uint
DbPassword string DbUser string
DbName string DbPassword string
DbDialect string DbName string
DbMaxConn uint DbDialect string
CleanUp bool DbMaxConn uint
DefaultUserName string CleanUp bool
DefaultUserPassword string DefaultUserName string
CustomLanguages map[string]string DefaultUserPassword string
LanguageColors map[string]string SecureCookieHashKey string
SecureCookieBlockKey string
CustomLanguages map[string]string
LanguageColors map[string]string
SecureCookie *securecookie.SecureCookie
} }
func (c *Config) IsDev() bool { func (c *Config) IsDev() bool {
return c.Env == "dev" 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

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

View File

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

View File

@ -5,3 +5,14 @@ type User struct {
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"` 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 { 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) { func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int var dbStatus int
if err := h.Db.DB().Ping(); err == nil { if err := h.db.DB().Ping(); err == nil {
dbStatus = 1 dbStatus = 1
} }

View File

@ -12,15 +12,18 @@ import (
) )
type HeartbeatHandler struct { 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) { func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) 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.Editor = editor
hb.User = user hb.User = user
hb.UserID = user.ID hb.UserID = user.ID
hb.Augment(h.HeartbeatSrvc.Config.CustomLanguages) hb.Augment(h.config.CustomLanguages)
if !hb.Valid() { if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest) 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) w.WriteHeader(http.StatusInternalServerError)
os.Stderr.WriteString(err.Error()) os.Stderr.WriteString(err.Error())
return 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 ( import (
"errors" "errors"
"html/template"
"net/http" "net/http"
"path"
"time" "time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
@ -22,39 +20,19 @@ const (
) )
type SummaryHandler struct { type SummaryHandler struct {
SummarySrvc *services.SummaryService cummarySrvc *services.SummaryService
Initialized bool config *models.Config
indexTemplate *template.Template
} }
func (m *SummaryHandler) Init() { func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
m.loadTemplates() return &SummaryHandler{
m.Initialized = true 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) { func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { summary, err, status := loadUserSummary(r, h.cummarySrvc)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.Initialized {
h.Init()
}
summary, err, status := loadUserSummary(r, h.SummarySrvc)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) 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) { func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if h.config.IsDev() {
w.WriteHeader(http.StatusMethodNotAllowed) loadTemplates()
return
}
if !h.Initialized {
h.Init()
}
if h.SummarySrvc.Config.IsDev() {
h.loadTemplates()
} }
q := r.URL.Query() q := r.URL.Query()
@ -84,7 +53,7 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
} }
summary, err, status := loadUserSummary(r, h.SummarySrvc) summary, err, status := loadUserSummary(r, h.cummarySrvc)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -93,10 +62,10 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, 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) { func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {

View File

@ -22,14 +22,22 @@ type AggregationService struct {
HeartbeatService *HeartbeatService 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 { type AggregationJob struct {
UserID string UserID string
From time.Time From time.Time
To time.Time To time.Time
} }
func (srv *AggregationService) Init() {}
// Schedule a job to (re-)generate summaries every day shortly after midnight // Schedule a job to (re-)generate summaries every day shortly after midnight
// TODO: Make configurable // TODO: Make configurable
func (srv *AggregationService) Schedule() { func (srv *AggregationService) Schedule() {

View File

@ -13,9 +13,14 @@ type AliasService struct {
Db *gorm.DB 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 { func (srv *AliasService) LoadUserAliases(userId string) error {
var aliases []*models.Alias 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 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 { func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
var batch []interface{} var batch []interface{}

View File

@ -21,15 +21,21 @@ type SummaryService struct {
AliasService *AliasService 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 { type Interval struct {
Start time.Time Start time.Time
End 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) { func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
var existingSummaries []*models.Summary var existingSummaries []*models.Summary
var cacheKey string var cacheKey string
@ -138,7 +144,7 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
return summaries, nil 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) { func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary var summaries []*models.Summary
if err := srv.Db. if err := srv.Db.

View File

@ -1,8 +1,11 @@
package services package services
import ( import (
"crypto/md5"
"encoding/hex"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
uuid "github.com/satori/go.uuid"
) )
type UserService struct { type UserService struct {
@ -10,7 +13,12 @@ type UserService struct {
Db *gorm.DB 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) { func (srv *UserService) GetUserById(userId string) (*models.User, error) {
u := &models.User{} u := &models.User{}
@ -37,3 +45,26 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
} }
return users, nil 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) 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> <html>
<head> {{ template "head.tpl.html" . }}
<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>
<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"> <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"> <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>
<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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script> {{ template "alerts.tpl.html" . }}
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
<script> <main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
let wakapiData = {} <div class="flex-grow max-w-lg mt-12">
let languageColors = {{ .LanguageColors | json }} <form action="login" method="post">
wakapiData.projects = {{ .Projects | json }} <div class="mb-8">
wakapiData.operatingSystems = {{ .OperatingSystems | json }} <label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
wakapiData.editors = {{ .Editors | json }} <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"
wakapiData.languages = {{ .Languages | json }} type="text" id="username"
</script> name="username" placeholder="Enter your username" required>
<script src="assets/app.js"></script> </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="******" 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> </body>
</html> </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>