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:
commit
2eabc3953f
|
@ -1,6 +1,7 @@
|
|||
[server]
|
||||
listen = 127.0.0.1
|
||||
port = 3000
|
||||
base_path = /
|
||||
|
||||
[app]
|
||||
cleanup = true
|
||||
|
|
5
go.mod
5
go.mod
|
@ -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
10
go.sum
|
@ -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
236
main.go
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
utils.ClearCookie(w, models.AuthCookieKey)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.BasePath), http.StatusFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.Cache.Set(userKey, user, cache.DefaultExpiration)
|
||||
m.cache.Set(user.ID, user, cache.DefaultExpiration)
|
||||
|
||||
ctx := context.WithValue(r.Context(), models.UserKey, user)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, string, error) {
|
||||
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
|
||||
if len(authHeader) != 2 || authHeader[0] != "Basic" {
|
||||
return nil, "", errors.New("failed to extract API key")
|
||||
}
|
||||
|
||||
key, err := base64.StdEncoding.DecodeString(authHeader[1])
|
||||
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
|
||||
key, err := utils.ExtractBearerAuth(r)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
userKey := strings.TrimSpace(string(key))
|
||||
cachedUser, ok := m.Cache.Get(userKey)
|
||||
userKey := strings.TrimSpace(key)
|
||||
cachedUser, ok := m.cache.Get(userKey)
|
||||
if !ok {
|
||||
user, err = m.UserSrvc.GetUserByKey(userKey)
|
||||
user, err = m.userSrvc.GetUserByKey(userKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
user = cachedUser.(*models.User)
|
||||
}
|
||||
return user, userKey, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *AuthenticateMiddleware) tryGetUserByPassword(r *http.Request) (*models.User, string, error) {
|
||||
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
|
||||
if len(authHeader) != 2 || authHeader[0] != "Basic" {
|
||||
return nil, "", errors.New("failed to extract API key")
|
||||
}
|
||||
|
||||
hash, err := base64.StdEncoding.DecodeString(authHeader[1])
|
||||
userKey := strings.TrimSpace(string(hash))
|
||||
func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
|
||||
login, err := utils.ExtractCookieAuth(r, m.config)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
cachedUser, ok := m.Cache.Get(userKey)
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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
17
middlewares/logging.go
Normal 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)
|
||||
}
|
158
models/config.go
158
models/config.go
|
@ -1,23 +1,151 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/ini.v1"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var cfg *Config
|
||||
|
||||
type Config struct {
|
||||
Env string
|
||||
Port int
|
||||
Addr string
|
||||
DbHost string
|
||||
DbPort uint
|
||||
DbUser string
|
||||
DbPassword string
|
||||
DbName string
|
||||
DbDialect string
|
||||
DbMaxConn uint
|
||||
CleanUp bool
|
||||
DefaultUserName string
|
||||
DefaultUserPassword string
|
||||
CustomLanguages map[string]string
|
||||
LanguageColors map[string]string
|
||||
Env string
|
||||
Port int
|
||||
Addr string
|
||||
BasePath string
|
||||
DbHost string
|
||||
DbPort uint
|
||||
DbUser string
|
||||
DbPassword string
|
||||
DbName string
|
||||
DbDialect string
|
||||
DbMaxConn uint
|
||||
CleanUp bool
|
||||
DefaultUserName string
|
||||
DefaultUserPassword string
|
||||
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
5
models/models.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
func init() {
|
||||
SetConfig(readConfig())
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package models
|
||||
|
||||
const (
|
||||
UserKey = "user"
|
||||
UserKey = "user"
|
||||
AuthCookieKey = "wakapi_auth"
|
||||
)
|
||||
|
|
|
@ -39,4 +39,6 @@ type SummaryItemContainer struct {
|
|||
type SummaryViewModel struct {
|
||||
*Summary
|
||||
LanguageColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
214
routes/index.go
Normal 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
50
routes/routes.go
Normal 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
|
||||
}
|
||||
}
|
|
@ -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 (m *SummaryHandler) loadTemplates() {
|
||||
indexTplPath := "views/index.tpl.html"
|
||||
indexTpl, err := template.New(path.Base(indexTplPath)).Funcs(template.FuncMap{
|
||||
"json": utils.Json,
|
||||
"date": utils.FormatDateHuman,
|
||||
}).ParseFiles(indexTplPath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
|
||||
return &SummaryHandler{
|
||||
cummarySrvc: summaryService,
|
||||
config: models.GetConfig(),
|
||||
}
|
||||
m.indexTemplate = indexTpl
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.Initialized {
|
||||
h.Init()
|
||||
}
|
||||
|
||||
summary, err, status := loadUserSummary(r, h.SummarySrvc)
|
||||
summary, err, status := loadUserSummary(r, h.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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package services
|
||||
|
||||
type Initializable interface {
|
||||
Init()
|
||||
}
|
|
@ -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{}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
65
utils/auth.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
10
utils/strings.go
Normal 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
13
views/alerts.tpl.html
Normal 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
12
views/foot.tpl.html
Normal 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
3
views/footer.tpl.html
Normal 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
9
views/head.tpl.html
Normal 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>
|
|
@ -1,76 +1,42 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<title>Wakapi – Coding Statistics</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<link rel="icon" data-emoji="📊" type="image/png">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
|
||||
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link href="assets/app.css" rel="stylesheet">
|
||||
</head>
|
||||
{{ template "head.tpl.html" . }}
|
||||
|
||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
|
||||
</div>
|
||||
<div class="text-white text-sm flex items-center justify-center mt-4">
|
||||
<a href="?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
|
||||
<a href="?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
|
||||
<a href="?interval=week" class="m-1 border-b border-green-700">This Week</a>
|
||||
<a href="?interval=month" class="m-1 border-b border-green-700">This Month</a>
|
||||
<a href="?interval=year" class="m-1 border-b border-green-700">This Year</a>
|
||||
<a href="?interval=any" class="m-1 border-b border-green-700">All Time</a>
|
||||
</div>
|
||||
<main class="mt-10 flex-grow" id="grid-container">
|
||||
<div class="flex justify-center">
|
||||
<div class="p-1">
|
||||
<div class="flex justify-center p-4 bg-white rounded shadow">
|
||||
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
|
||||
<p class="mx-2"><strong>⏹</strong> <span title="End Time">{{ .ToTime | date }}</span></p>
|
||||
<p class="mx-2"><strong>⏱</strong> <span id="total-span" title="Total Hours"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
|
||||
<canvas id="chart-projects"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
|
||||
<canvas id="chart-os"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
|
||||
<canvas id="chart-language"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
|
||||
<canvas id="chart-editor"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="w-full text-center text-gray-300 text-xs mt-12">
|
||||
Made by <a href="https://muetsch.io" class="border-b border-green-700">Ferdinand Mütsch</a> as <a href="https://github.com/muety/wakapi" class="border-b border-green-700">open-source</a>.
|
||||
</footer>
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<script>
|
||||
let wakapiData = {}
|
||||
let languageColors = {{ .LanguageColors | json }}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
||||
<main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
|
||||
<div class="flex-grow max-w-lg mt-12">
|
||||
<form action="login" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
|
||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||
type="text" id="username"
|
||||
name="username" placeholder="Enter your username" 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="******" required>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a href="signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
42
views/signup.tpl.html
Normal file
42
views/signup.tpl.html
Normal 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
67
views/summary.tpl.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user