Compare commits

8 Commits

Author SHA1 Message Date
50187f5a34 Set user password 2023-10-26 17:40:05 +02:00
dd671d561c Restructure again 2023-10-26 16:49:24 +02:00
7512d75a4d Readme 2023-10-26 16:03:20 +02:00
c1997aaab5 Update makefile 2023-10-26 13:28:10 +02:00
45f6f19441 Update readme with benchmark 2023-10-26 09:51:30 +02:00
2b13292e3b Restructure 2023-10-26 09:43:24 +02:00
da3deedb0c Readme 2023-10-26 00:25:19 +02:00
7024cd0de9 User edit 2023-10-26 00:13:16 +02:00
17 changed files with 297 additions and 51 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/.vscode /.vscode
/__debug* /__debug*
/build
/tmp /tmp
/config.yaml /config.yaml

View File

@ -1,2 +1,8 @@
EXEC=iris-test
run: run:
@air @air
.PHONY: build
build:
@go build -ldflags "-s -w" -o ./build/${EXEC} ./app/main.go

View File

@ -11,3 +11,55 @@
- [Source](https://github.com/CloudyKit/jet) - [Source](https://github.com/CloudyKit/jet)
- [Syntax reference](https://github.com/CloudyKit/jet/blob/master/docs/syntax.md) - [Syntax reference](https://github.com/CloudyKit/jet/blob/master/docs/syntax.md)
## Howto
- [Password Hashing (bcrypt)](https://gowebexamples.com/password-hashing/)
## Tools
- [Bombardier benchmarking](https://github.com/codesenberg/bombardier)
## Bombardier benchmark
Pandora - Jet templating engine
```
eden@pandora:[~/apps/sandbox/golang/iris-web-framework]: bombardier -c 100 -d 10s -l http://localhost:8000 ±[A1][main]
Bombarding http://localhost:8000 for 10s using 100 connection(s)
Done!
Statistics Avg Stdev Max
Reqs/sec 10542.16 2631.23 17296.86
Latency 9.50ms 3.22ms 42.93ms
Latency Distribution
50% 8.77ms
75% 11.86ms
90% 15.31ms
95% 17.86ms
99% 23.90ms
HTTP codes:
1xx - 0, 2xx - 105258, 3xx - 0, 4xx - 0, 5xx - 0
others - 0
Throughput: 28.08MB/s
```
```
eden@pandora:[~/apps/sandbox/golang/iris-web-framework]: bombardier -c 100 -d 10s -l http://localhost:8000/users ±[A1][main]
Bombarding http://localhost:8000/users for 10s using 100 connection(s)
Done!
Statistics Avg Stdev Max
Reqs/sec 1096.26 427.09 3211.54
Latency 91.08ms 80.06ms 471.41ms
Latency Distribution
50% 78.37ms
75% 156.58ms
90% 191.60ms
95% 223.49ms
99% 309.59ms
HTTP codes:
1xx - 0, 2xx - 11060, 3xx - 0, 4xx - 0, 5xx - 0
others - 0
Throughput: 19.91MB/s
```

1
app/lib/auth/auth.go Normal file
View File

@ -0,0 +1 @@
package auth

41
app/lib/auth/passwords.go Normal file
View File

@ -0,0 +1,41 @@
package auth
import (
"regexp"
"golang.org/x/crypto/bcrypt"
)
// about bcrypt cost: https://docs.laminas.dev/laminas-crypt/password/#bcrypt
// bcrypt cost benchmarks: https://github.com/nsmithuk/bcrypt-cost-go
const BCRYPT_COST = 10
const MIN_PASSWORD_LENGTH = 10
func IsPasswordGoodEnough(password string) bool {
var re *regexp.Regexp
passwordBytes := []byte(password)
if len(password) < MIN_PASSWORD_LENGTH {
return false
}
re, _ = regexp.Compile("[a-z]")
if re.Find(passwordBytes) == nil {
return false
}
re, _ = regexp.Compile("[A-Z]")
if re.Find(passwordBytes) == nil {
return false
}
re, _ = regexp.Compile("[0-9]")
//lint:ignore S1008 allow early exit instead optimization
if re.Find(passwordBytes) == nil {
return false
}
return true
}
func HashPassword(password string, secretKey string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password+secretKey), BCRYPT_COST)
return string(bytes), err
}

View File

@ -1,4 +1,4 @@
package main package cfg
import ( import (
"fmt" "fmt"

View File

@ -1,7 +1,9 @@
package main package db
import ( import (
"fmt" "fmt"
"iris-test/app/lib/cfg"
"iris-test/app/lib/logging"
"iris-test/app/repository" "iris-test/app/repository"
"strconv" "strconv"
"strings" "strings"
@ -20,18 +22,18 @@ var DBConn *gorm.DB
func InitDB() *gorm.DB { func InitDB() *gorm.DB {
var connectionString = strings.Join([]string{ var connectionString = strings.Join([]string{
"postgres://", "postgres://",
Config.Database.Username, ":", cfg.Config.Database.Username, ":",
Config.Database.Password, "@", cfg.Config.Database.Password, "@",
Config.Database.Host, ":", cfg.Config.Database.Host, ":",
Config.Database.Port, "/", cfg.Config.Database.Port, "/",
Config.Database.Name, cfg.Config.Database.Name,
"?sslmode=disable", "?sslmode=disable",
"&TimeZone=UTC", "&TimeZone=UTC",
"&connect_timeout=", strconv.Itoa(DB_CONNECTION_TIMEOUT), "&connect_timeout=", strconv.Itoa(DB_CONNECTION_TIMEOUT),
}, "") }, "")
var logLevel = gormLogger.Silent var logLevel = gormLogger.Silent
if Config.Application.DebugSQL { if cfg.Config.Application.DebugSQL {
logLevel = gormLogger.Info logLevel = gormLogger.Info
} }
@ -41,7 +43,7 @@ func InitDB() *gorm.DB {
}) })
if err != nil { if err != nil {
msg := fmt.Sprintf("Error connecting to database: %s. Terminating!", err) msg := fmt.Sprintf("Error connecting to database: %s. Terminating!", err)
Log.Error(msg) logging.Error(msg)
panic(msg) panic(msg)
} }

View File

@ -1,9 +1,10 @@
package main package logging
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"iris-test/app/lib/cfg"
"os" "os"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -28,9 +29,9 @@ func Warn(message string) {
} }
func InitLogging() { func InitLogging() {
logLevel, err := logrus.ParseLevel(Config.Application.LogLevel) logLevel, err := logrus.ParseLevel(cfg.Config.Application.LogLevel)
if err != nil { if err != nil {
panic(fmt.Sprintf("Invalid configured logLevel: %s\n", Config.Application.LogLevel)) panic(fmt.Sprintf("Invalid configured logLevel: %s\n", cfg.Config.Application.LogLevel))
} }
Log.SetLevel(logLevel) Log.SetLevel(logLevel)
@ -42,14 +43,14 @@ func InitLogging() {
DisableQuote: true, DisableQuote: true,
}) })
LogFile := Config.Application.LogFile LogFile := cfg.Config.Application.LogFile
file, err := os.OpenFile( file, err := os.OpenFile(
LogFile, LogFile,
os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.O_CREATE|os.O_WRONLY|os.O_APPEND,
0655, 0655,
) )
if err != nil { if err != nil {
msg := fmt.Sprintf("Failed to log to file %s: %s", Config.Application.LogFile, err) msg := fmt.Sprintf("Failed to log to file %s: %s", cfg.Config.Application.LogFile, err)
Log.Warning(msg) Log.Warning(msg)
panic(msg) panic(msg)
} }
@ -57,7 +58,7 @@ func InitLogging() {
mw := io.MultiWriter(os.Stdout, file) mw := io.MultiWriter(os.Stdout, file)
Log.SetOutput(mw) Log.SetOutput(mw)
configJson, err := json.Marshal(Config) configJson, err := json.Marshal(cfg.Config)
if err == nil { if err == nil {
Info(fmt.Sprintf("Using config: %s", configJson)) Info(fmt.Sprintf("Using config: %s", configJson))
} }

View File

@ -2,6 +2,9 @@ package main
import ( import (
"fmt" "fmt"
"iris-test/app/lib/cfg"
"iris-test/app/lib/db"
"iris-test/app/lib/logging"
"iris-test/app/views" "iris-test/app/views"
"os" "os"
"time" "time"
@ -15,32 +18,32 @@ import (
var redisDB *redis.Database var redisDB *redis.Database
func createSessionEngine() *sessions.Sessions { func createSessionEngine() *sessions.Sessions {
redisAddr := fmt.Sprintf("%s:%d", Config.Redis.Host, Config.Redis.Port) redisAddr := fmt.Sprintf("%s:%d", cfg.Config.Redis.Host, cfg.Config.Redis.Port)
redisDB = redis.New(redis.Config{ redisDB = redis.New(redis.Config{
Network: "tcp", Network: "tcp",
Addr: redisAddr, Addr: redisAddr,
Timeout: time.Duration(30) * time.Second, Timeout: time.Duration(30) * time.Second,
MaxActive: 10, MaxActive: 10,
Username: Config.Redis.Username, Username: cfg.Config.Redis.Username,
Password: Config.Redis.Password, Password: cfg.Config.Redis.Password,
Database: Config.Redis.Database, Database: cfg.Config.Redis.Database,
Prefix: Config.Redis.Prefix, Prefix: cfg.Config.Redis.Prefix,
Driver: redis.GoRedis(), // defaults to this driver. Driver: redis.GoRedis(), // defaults to this driver.
// To set a custom, existing go-redis client, use the "SetClient" method: // To set a custom, existing go-redis client, use the "SetClient" method:
// Driver: redis.GoRedis().SetClient(customGoRedisClient) // Driver: redis.GoRedis().SetClient(customGoRedisClient)
}) })
sessions_engine := sessions.New(sessions.Config{ sessionsEngine := sessions.New(sessions.Config{
Cookie: "_session_id", Cookie: "_session_id",
Expires: 0, // defaults to 0: unlimited life. Another good value is: 45 * time.Minute, Expires: 0, // defaults to 0: unlimited life. Another good value is: 45 * time.Minute,
AllowReclaim: true, AllowReclaim: true,
CookieSecureTLS: true, CookieSecureTLS: true,
}) })
sessions_engine.UseDatabase(redisDB) sessionsEngine.UseDatabase(redisDB)
return sessions_engine return sessionsEngine
} }
func createAccessLog() *accesslog.AccessLog { func createAccessLog() *accesslog.AccessLog {
@ -83,7 +86,7 @@ func createApp() *iris.Application {
accessLog := createAccessLog() accessLog := createAccessLog()
app := iris.New() app := iris.New()
app.Logger().SetLevel(Config.Application.LogLevel) app.Logger().SetLevel(cfg.Config.Application.LogLevel)
app.Use(sessionsEngine.Handler()) app.Use(sessionsEngine.Handler())
app.UseRouter(accessLog.Handler) app.UseRouter(accessLog.Handler)
app.RegisterView(iris.Jet("./app/templates", ".jet").Reload(true)) app.RegisterView(iris.Jet("./app/templates", ".jet").Reload(true))
@ -92,9 +95,9 @@ func createApp() *iris.Application {
} }
func main() { func main() {
InitCfg() cfg.InitCfg()
InitLogging() logging.InitLogging()
InitDB() db.InitDB()
app := createApp() app := createApp()
defer redisDB.Close() defer redisDB.Close()

View File

@ -1,6 +1,10 @@
package models package models
import "time" import (
"iris-test/app/lib/auth"
"iris-test/app/lib/cfg"
"time"
)
type User struct { type User struct {
Id string `gorm:"type(uuid);unique"` Id string `gorm:"type(uuid);unique"`
@ -16,3 +20,13 @@ type User struct {
func (u *User) TableName() string { func (u *User) TableName() string {
return "users" return "users"
} }
func (u *User) SetPassword(password string) error {
secretKey := cfg.Config.Application.SecretKey
hashedPassword, err := auth.HashPassword(password, secretKey)
if err != nil {
return err
}
u.Password = hashedPassword
return nil
}

View File

@ -12,6 +12,7 @@ type UsersRepository struct {
} }
type UserFilter struct { type UserFilter struct {
Id *string
IsActive *bool IsActive *bool
} }
@ -22,9 +23,14 @@ func CreateUsersRepository(db *gorm.DB) *UsersRepository {
func applyFilter(db *gorm.DB, filter *UserFilter) *gorm.DB { func applyFilter(db *gorm.DB, filter *UserFilter) *gorm.DB {
query := db query := db
// if filter.State != "" { if filter.Id != nil {
// query = query.Where("state = ?", filter.State) query = query.Where("id = ?", filter.Id)
// } }
if filter.IsActive != nil {
query = query.Where(map[string]interface{}{"is_active": filter.IsActive})
}
// if filter.SendAt_lt != nil { // if filter.SendAt_lt != nil {
// query = query.Where("send_at < ?", filter.SendAt_lt) // query = query.Where("send_at < ?", filter.SendAt_lt)
// } // }
@ -69,3 +75,18 @@ func (repository *UsersRepository) List(filter *UserFilter, pagination *Paginati
return &users return &users
} }
func (repository *UsersRepository) Get(filter *UserFilter) *models.User {
var user models.User
query := repository.db.Model(&models.User{})
query = applyFilter(query, filter)
query.First(&user)
return &user
}
func (repository *UsersRepository) Save(user *models.User) *models.User {
repository.db.Save(user)
return user
}

View File

@ -10,14 +10,20 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container mt-3">
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<p> <ul class="nav">
<a href="/">Frontpage</a> <li class="nav-item">
<a href="/users">Users</a> <a href="/" class="nav-link">Frontpage</a>
<a href="/about">About</a> </li>
</p> <li class="nav-item">
<a href="/users" class="nav-link">Users</a>
</li>
<li class="nav-item">
<a href="/about" class="nav-link">About</a>
</li>
</ul>
<main> <main>
{{ yield mainContent() }} {{ yield mainContent() }}

View File

@ -1,10 +1,19 @@
{{ block usersTable(users) }} {{ block usersTable(users) }}
<p>blablabla</p> <table class="table table-hover">
<thead>
<table class="table"> <tr>
<th>ID</th>
<th>First name</th>
<th>Last name</th>
<th>Email</th>
</tr>
</thead>
<tbody> <tbody>
{{ range users }} {{ range users }}
<tr> <tr>
<td>
<a href="/users/{{ .Id }}">{{ .Id }}</a>
</td>
<td>{{ .FirstName }}</td> <td>{{ .FirstName }}</td>
<td>{{ .LastName }}</td> <td>{{ .LastName }}</td>
<td>{{ .Email }}</td> <td>{{ .Email }}</td>

View File

@ -0,0 +1,39 @@
{{ extends "/base/base.jet" }}
{{ import "/components/table_component.jet" }}
{{ block mainContent() }}
<h3>Edit user</h3>
<div class="row">
<form class="mb-5 col-4 ms-auto me-auto" method="post" action="{{ .currentPath }}">
<div class="mb-3">
<label class="form-label">First name</label>
<input type="text" name="first-name" class="form-control" value="{{ user.FirstName }}" required>
</div>
<div class="mb-3">
<label class="form-label">Last name</label>
<input type="text" name="last-name" class="form-control" value="{{ user.LastName }}" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" value="{{ user.Email }}" required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="text" name="password" class="form-control">
</div>
<div class="d-flex">
<a href="/users" class="btn btn-outline-secondary ms-auto me-2">
Cancel
</a>
<button type="submit" class="btn btn-success">
Save
</button>
</div>
</form>
</div>
{{ end }}

View File

@ -3,13 +3,7 @@
{{ block mainContent() }} {{ block mainContent() }}
<ul> <h3>Users</h3>
{{ range params1 }}
<li>{{ . }}</li>
{{ end }}
</ul>
<h3>{{ title }}</h3>
{{ yield usersTable(users=users) }} {{ yield usersTable(users=users) }}
{{ end }} {{ end }}

View File

@ -7,5 +7,8 @@ func CreateRouter(app *iris.Application) {
app.Post("/", PostIndexPage) app.Post("/", PostIndexPage)
app.Get("/users", GetUsersPage) app.Get("/users", GetUsersPage)
app.Get("/users/{userId:uuid}", EditUserPage)
app.Post("/users/{userId:uuid}", SaveUserPage)
app.Get("/about", GetAboutPage) app.Get("/about", GetAboutPage)
} }

View File

@ -1,15 +1,21 @@
package views package views
import ( import (
"fmt"
"iris-test/app/lib/auth"
"iris-test/app/repository" "iris-test/app/repository"
"github.com/kataras/iris/v12" "github.com/kataras/iris/v12"
) )
func GetUsersPage(ctx iris.Context) { type editUserForm struct {
params1 := []string{"param 1", "param 2", "param 3"} FirstName string `form:"first-name"`
ctx.ViewData("params1", params1) LastName string `form:"last-name"`
Email string `form:"email"`
Password string `form:"password"`
}
func GetUsersPage(ctx iris.Context) {
userRepository := repository.Dao.UsersRepository userRepository := repository.Dao.UsersRepository
pagination := repository.NewPagination() pagination := repository.NewPagination()
@ -18,7 +24,8 @@ func GetUsersPage(ctx iris.Context) {
repository.NewOrdering("last_name", repository.ORDERING_ASC), repository.NewOrdering("last_name", repository.ORDERING_ASC),
} }
users := userRepository.List(&repository.UserFilter{}, &pagination, &ordering) isActive := true
users := userRepository.List(&repository.UserFilter{IsActive: &isActive}, &pagination, &ordering)
ctx.ViewData("users", users) ctx.ViewData("users", users)
@ -27,3 +34,49 @@ func GetUsersPage(ctx iris.Context) {
return return
} }
} }
func EditUserPage(ctx iris.Context) {
userId := ctx.Params().Get("userId")
userRepository := repository.Dao.UsersRepository
filter := repository.UserFilter{Id: &userId}
user := userRepository.Get(&filter)
ctx.ViewData("user", user)
ctx.ViewData("currentPath", ctx.Path())
if err := ctx.View("pages/user-edit.jet"); err != nil {
showError(ctx, err)
return
}
}
func SaveUserPage(ctx iris.Context) {
var form editUserForm
err := ctx.ReadForm(&form)
if err != nil {
ctx.StopWithError(iris.StatusBadRequest, err)
return
}
userId := ctx.Params().Get("userId")
userRepository := repository.Dao.UsersRepository
filter := repository.UserFilter{Id: &userId}
user := userRepository.Get(&filter)
user.FirstName = form.FirstName
user.LastName = form.LastName
user.Email = form.Email
if len(form.Password) > 0 {
user.SetPassword(form.Password)
fmt.Printf("Set password: %s\n", user.Password)
fmt.Printf("IsPasswordGoodEnough: %v\n", auth.IsPasswordGoodEnough(form.Password))
}
userRepository.Save(user)
ctx.Redirect("/users")
}