Merge branch 'master' into master

This commit is contained in:
4lxprime 2024-06-24 12:33:16 +02:00 committed by GitHub
commit 7efd0e254d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 556 additions and 164 deletions

2
Makefile Normal file
View file

@ -0,0 +1,2 @@
test:
@go test -v ./...

View file

@ -5,21 +5,25 @@ import (
"os"
"github.com/anthdm/superkit/db"
"github.com/anthdm/superkit/kit"
_ "github.com/mattn/go-sqlite3"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/extra/bundebug"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// I could not came up with a better naming for this.
// Ideally, app should export a global variable called "DB"
// but this will cause imports cycles for plugins.
var Query *bun.DB
// By default this is a pre-configured Gorm DB instance.
// Change this type based on the database package of your likings.
var dbInstance *gorm.DB
// Get returns the instantiated DB instance.
func Get() *gorm.DB {
return dbInstance
}
func init() {
// Create a default *sql.DB exposed by the superkit/db package
// based on the given configuration.
config := db.Config{
Driver: os.Getenv("DB_DRIVER"),
Name: os.Getenv("DB_NAME"),
@ -27,12 +31,30 @@ func init() {
User: os.Getenv("DB_USER"),
Host: os.Getenv("DB_HOST"),
}
db, err := db.New(config)
dbinst, err := db.NewSQL(config)
if err != nil {
log.Fatal(err)
}
Query = bun.NewDB(db, sqlitedialect.New())
if kit.IsDevelopment() {
Query.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
// Based on the driver create the corresponding DB instance.
// By default, the SuperKit boilerplate comes with a pre-configured
// ORM called Gorm. https://gorm.io.
//
// You can change this to any other DB interaction tool
// of your liking. EG:
// - uptrace bun -> https://bun.uptrace.dev
// - SQLC -> https://github.com/sqlc-dev/sqlc
// - gojet -> https://github.com/go-jet/jet
switch config.Driver {
case db.DriverSqlite3:
dbInstance, err = gorm.Open(sqlite.New(sqlite.Config{
Conn: dbinst,
}))
case db.DriverMysql:
// ...
default:
log.Fatal("invalid driver:", config.Driver)
}
if err != nil {
log.Fatal(err)
}
}

View file

@ -5,9 +5,10 @@ create table if not exists users(
password_hash text not null,
first_name text not null,
last_name text not null,
email_verified_at timestamp with time zone,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null
email_verified_at datetime,
created_at datetime not null,
updated_at datetime not null,
deleted_at datetime
);
-- +goose Down

View file

@ -5,9 +5,10 @@ create table if not exists sessions(
user_id integer not null references users,
ip_address text,
user_agent text,
expires_at timestamp with time zone not null,
last_login_at timestamp with time zone,
created_at timestamp with time zone not null
expires_at datetime not null,
created_at datetime not null,
updated_at datetime not null,
deleted_at datetime
);
-- +goose Down

View file

@ -24,7 +24,7 @@ func InitializeMiddleware(router *chi.Mux) {
func InitializeRoutes(router *chi.Mux) {
// Authentication plugin
//
// By default the auth plugin is active. To disable the auth plugin
// By default the auth plugin is active, to disable the auth plugin
// you will need to pass your own handler in the `AuthFunc`` field
// of the `kit.AuthenticationConfig`.
// authConfig := kit.AuthenticationConfig{

View file

@ -2,7 +2,7 @@ package types
// AuthUser represents an user that might be authenticated.
type AuthUser struct {
ID int
ID uint
Email string
LoggedIn bool
}

View file

@ -13,21 +13,14 @@ require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22
github.com/uptrace/bun v1.2.1
github.com/uptrace/bun/dialect/sqlitedialect v1.2.1
github.com/uptrace/bun/extra/bundebug v1.2.1
golang.org/x/crypto v0.24.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.10
)
require (
github.com/fatih/color v1.17.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/sys v0.21.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
)

View file

@ -2,14 +2,16 @@ github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
github.com/anthdm/superkit v0.0.0-20240622052611-30be5bb82e0d h1:T0qegCdKTBwjk28Rcq81V1vYZl2grFjG9NWhPqIbRm0=
github.com/anthdm/superkit v0.0.0-20240622052611-30be5bb82e0d/go.mod h1:j8+yKABdHVnQ9UqxiE/trbu8CnJuU+gNqlMvfGD6nq4=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.14 h1:PyEwo2Vudraa0x/Wl6eDRRW2NXBvekgfxyydcM0WGE0=
github.com/go-chi/chi/v5 v5.0.14/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
@ -18,30 +20,21 @@ github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFz
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.1 h1:2ENAcfeCfaY5+2e7z5pXrzFKy3vS8VXvkCag6N2Yzfk=
github.com/uptrace/bun v1.2.1/go.mod h1:cNg+pWBUMmJ8rHnETgf65CEvn3aIKErrwOD6IA8e+Ec=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.1 h1:IprvkIKUjEjvt4VKpcmLpbMIucjrsmUPJOSlg19+a0Q=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.1/go.mod h1:mMQf4NUpgY8bnOanxGmxNiHCdALOggS4cZ3v63a9D/o=
github.com/uptrace/bun/extra/bundebug v1.2.1 h1:85MYpX3QESYI02YerKxUi1CD9mHuLrc2BXs1eOCtQus=
github.com/uptrace/bun/extra/bundebug v1.2.1/go.mod h1:sfGKIi0HSGxsTC/sgIHGwpnYduHHYhdMeOIwurgSY+Y=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=

View file

@ -13,6 +13,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const (
@ -40,12 +41,9 @@ func HandleLoginCreate(kit *kit.Kit) error {
}
var user User
err := db.Query.NewSelect().
Model(&user).
Where("user.email = ?", values.Email).
Scan(kit.Request.Context())
err := db.Get().Find(&user, "email = ?", values.Email).Error
if err != nil {
if err == sql.ErrNoRows {
if err == gorm.ErrRecordNotFound {
errors.Add("credentials", "invalid credentials")
return kit.Render(LoginForm(values, errors))
}
@ -59,7 +57,7 @@ func HandleLoginCreate(kit *kit.Kit) error {
skipVerify := kit.Getenv("SUPERKIT_AUTH_SKIP_VERIFY", "false")
if skipVerify != "true" {
if user.EmailVerifiedAt.Equal(time.Time{}) {
if !user.EmailVerifiedAt.Valid {
errors.Add("verified", "please verify your email")
return kit.Render(LoginForm(values, errors))
}
@ -71,24 +69,17 @@ func HandleLoginCreate(kit *kit.Kit) error {
sessionExpiry = 48
}
session := Session{
UserID: user.ID,
Token: uuid.New().String(),
CreatedAt: time.Now(),
LastLoginAt: time.Now(),
ExpiresAt: time.Now().Add(time.Hour * time.Duration(sessionExpiry)),
UserID: user.ID,
Token: uuid.New().String(),
ExpiresAt: time.Now().Add(time.Hour * time.Duration(sessionExpiry)),
}
_, err = db.Query.NewInsert().
Model(&session).
Exec(kit.Request.Context())
if err != nil {
if err = db.Get().Create(&session).Error; err != nil {
return err
}
// TODO change this with kit.Getenv
sess := kit.GetSession(userSessionName)
sess.Values["sessionToken"] = session.Token
sess.Save(kit.Request, kit.Response)
redirectURL := kit.Getenv("SUPERKIT_AUTH_REDIRECT_AFTER_LOGIN", "/profile")
return kit.Redirect(http.StatusSeeOther, redirectURL)
@ -100,10 +91,7 @@ func HandleLoginDelete(kit *kit.Kit) error {
sess.Values = map[any]any{}
sess.Save(kit.Request, kit.Response)
}()
_, err := db.Query.NewDelete().
Model((*Session)(nil)).
Where("token = ?", sess.Values["sessionToken"]).
Exec(kit.Request.Context())
err := db.Get().Delete(&Session{}, "token = ?", sess.Values["sessionToken"]).Error
if err != nil {
return err
}
@ -121,7 +109,7 @@ func HandleEmailVerify(kit *kit.Kit) error {
return []byte(os.Getenv("SUPERKIT_SECRET")), nil
}, jwt.WithLeeway(5*time.Second))
if err != nil {
return err
return kit.Render(EmailVerificationError("invalid verification token"))
}
if !token.Valid {
return kit.Render(EmailVerificationError("invalid verification token"))
@ -141,23 +129,18 @@ func HandleEmailVerify(kit *kit.Kit) error {
}
var user User
err = db.Query.NewSelect().
Model(&user).
Where("id = ?", userID).
Scan(kit.Request.Context())
err = db.Get().First(&user, userID).Error
if err != nil {
return err
}
if user.EmailVerifiedAt.After(time.Time{}) {
if user.EmailVerifiedAt.Time.After(time.Time{}) {
return kit.Render(EmailVerificationError("Email already verified"))
}
user.EmailVerifiedAt = time.Now()
_, err = db.Query.NewUpdate().
Model(&user).
WherePK().
Exec(kit.Request.Context())
now := sql.NullTime{Time: time.Now(), Valid: true}
user.EmailVerifiedAt = now
err = db.Get().Save(&user).Error
if err != nil {
return err
}
@ -174,16 +157,14 @@ func AuthenticateUser(kit *kit.Kit) (kit.Auth, error) {
}
var session Session
err := db.Query.NewSelect().
Model(&session).
Relation("User").
Where("session.token = ? AND session.expires_at > ?", token, time.Now()).
Scan(kit.Request.Context())
if err != nil {
err := db.Get().
Preload("User").
Find(&session, "token = ? AND expires_at > ?", token, time.Now()).Error
if err != nil || session.ID == 0 {
return auth, nil
}
return Auth{
return Auth{
LoggedIn: true,
UserID: session.User.ID,
Email: session.User.Email,

View file

@ -14,7 +14,7 @@ var profileSchema = v.Schema{
}
type ProfileFormValues struct {
ID int `form:"id"`
ID uint `form:"id"`
FirstName string `form:"firstName"`
LastName string `form:"lastName"`
Email string
@ -25,11 +25,7 @@ func HandleProfileShow(kit *kit.Kit) error {
auth := kit.Auth().(Auth)
var user User
err := db.Query.NewSelect().
Model(&user).
Where("id = ?", auth.UserID).
Scan(kit.Request.Context())
if err != nil {
if err := db.Get().First(&user, auth.UserID).Error; err != nil {
return err
}
@ -54,12 +50,12 @@ func HandleProfileUpdate(kit *kit.Kit) error {
if auth.UserID != values.ID {
return fmt.Errorf("unauthorized request for profile %d", values.ID)
}
_, err := db.Query.NewUpdate().
Model((*User)(nil)).
Set("first_name = ?", values.FirstName).
Set("last_name = ?", values.LastName).
err := db.Get().Model(&User{}).
Where("id = ?", auth.UserID).
Exec(kit.Request.Context())
Updates(&User{
FirstName: values.FirstName,
LastName: values.LastName,
}).Error
if err != nil {
return err
}

View file

@ -63,19 +63,15 @@ func HandleResendVerificationCode(kit *kit.Kit) error {
}
var user User
err = db.Query.NewSelect().
Model(&user).
Where("id = ?", id).
Scan(kit.Request.Context())
if err != nil {
if err = db.Get().First(&user, id).Error; err != nil {
return kit.Text(http.StatusOK, "An unexpected error occured")
}
if user.EmailVerifiedAt.After(time.Time{}) {
if user.EmailVerifiedAt.Time.After(time.Time{}) {
return kit.Text(http.StatusOK, "Email already verified!")
}
token, err := createVerificationToken(id)
token, err := createVerificationToken(uint(id))
if err != nil {
return kit.Text(http.StatusOK, "An unexpected error occured")
}
@ -90,7 +86,7 @@ func HandleResendVerificationCode(kit *kit.Kit) error {
return kit.Text(http.StatusOK, msg)
}
func createVerificationToken(userID int) (string, error) {
func createVerificationToken(userID uint) (string, error) {
expiryStr := kit.Getenv("SUPERKIT_AUTH_EMAIL_VERIFICATION_EXPIRY_IN_HOURS", "1")
expiry, err := strconv.Atoi(expiryStr)
if err != nil {

View file

@ -2,10 +2,11 @@ package auth
import (
"AABBCCDD/app/db"
"context"
"database/sql"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Event name constants
@ -22,7 +23,7 @@ type UserWithVerificationToken struct {
}
type Auth struct {
UserID int
UserID uint
Email string
LoggedIn bool
}
@ -32,12 +33,13 @@ func (auth Auth) Check() bool {
}
type User struct {
ID int `bun:"id,pk,autoincrement"`
gorm.Model
Email string
FirstName string
LastName string
PasswordHash string
EmailVerifiedAt time.Time
EmailVerifiedAt sql.NullTime
CreatedAt time.Time
UpdatedAt time.Time
}
@ -52,22 +54,19 @@ func createUserFromFormValues(values SignupFormValues) (User, error) {
FirstName: values.FirstName,
LastName: values.LastName,
PasswordHash: string(hash),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = db.Query.NewInsert().Model(&user).Exec(context.Background())
return user, err
result := db.Get().Create(&user)
return user, result.Error
}
type Session struct {
ID int `bun:"id,pk,autoincrement"`
UserID int
Token string
IPAddress string
UserAgent string
ExpiresAt time.Time
LastLoginAt time.Time
CreatedAt time.Time
gorm.Model
User User `bun:"rel:belongs-to,join:user_id=id"`
UserID uint
Token string
IPAddress string
UserAgent string
ExpiresAt time.Time
CreatedAt time.Time
User User
}

View file

@ -7,6 +7,7 @@ import (
const (
DriverSqlite3 = "sqlite3"
DriverMysql = "mysql"
)
type Config struct {
@ -17,7 +18,7 @@ type Config struct {
Password string
}
func New(cfg Config) (*sql.DB, error) {
func NewSQL(cfg Config) (*sql.DB, error) {
switch cfg.Driver {
case DriverSqlite3:
name := cfg.Name

View file

@ -3,17 +3,14 @@ package event
import (
"context"
"reflect"
"sync"
"testing"
)
func TestEventSubscribeEmit(t *testing.T) {
var (
expect = 1
wg = sync.WaitGroup{}
)
wg.Add(1)
Subscribe("foo.bar", func(_ context.Context, event any) {
expect := 1
ctx, cancel := context.WithCancel(context.Background())
Subscribe("foo.a", func(_ context.Context, event any) {
defer cancel()
value, ok := event.(int)
if !ok {
t.Errorf("expected int got %v", reflect.TypeOf(event))
@ -21,16 +18,15 @@ func TestEventSubscribeEmit(t *testing.T) {
if value != 1 {
t.Errorf("expected %d got %d", expect, value)
}
wg.Done()
})
Emit("foo.bar", expect)
wg.Wait()
Emit("foo.a", expect)
<-ctx.Done()
}
func TestUnsubscribe(t *testing.T) {
sub := Subscribe("foo.bar", func(_ context.Context, _ any) {})
sub := Subscribe("foo.b", func(_ context.Context, _ any) {})
Unsubscribe(sub)
if _, ok := stream.subs["foo.bar"]; ok {
if _, ok := stream.subs["foo.b"]; ok {
t.Errorf("expected topic foo.bar to be deleted")
}
}

12
go.sum
View file

@ -1,8 +1,20 @@
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -31,20 +31,11 @@ func main() {
projectName := args[0]
// check if superkit folder already exists, if so, delete
_, err := os.Stat("superkit")
if !os.IsNotExist(err) {
fmt.Println("-- deleting superkit folder cause its already present")
if err := os.RemoveAll("superkit"); err != nil {
log.Fatal(err)
}
}
secret := generateSecret()
fmt.Println("-- setting up bootstrap")
fmt.Println("-- generating secure secret")
if err = gitdl.DownloadGit(
if err := gitdl.DownloadGit(
"anthdm/superkit",
"bootstrap",
projectName,

59
ui/button/button.go Normal file
View file

@ -0,0 +1,59 @@
package button
import (
"github.com/anthdm/superkit/ui"
"github.com/a-h/templ"
)
const (
buttonBaseClass = "inline-flex items-center justify-center px-4 py-2 font-medium text-sm tracking-wide transition-colors duration-200 rounded-md focus:ring focus:shadow-outline focus:outline-none"
buttonVariantPrimary = "text-primary-foreground bg-primary focus:ring-primary hover:bg-primary/90"
buttonVariantOutline = "text-primary border border-primary hover:bg-secondary focus:ring-primary"
buttonVariantSecondary = "text-primary bg-secondary hover:bg-secondary/80"
buttonVariantDestructive = "text-primary bg-destructive hover:bg-destructive/80"
)
func New(opts ...func(*templ.Attributes)) templ.Attributes {
return ui.CreateAttrs(buttonBaseClass, buttonVariantPrimary, opts...)
}
func Outline(opts ...func(*templ.Attributes)) templ.Attributes {
return appendVariant("outline", opts...)
}
func Primary(opts ...func(*templ.Attributes)) templ.Attributes {
return appendVariant("primary", opts...)
}
func Secondary(opts ...func(*templ.Attributes)) templ.Attributes {
return appendVariant("secondary", opts...)
}
func Destructive(opts ...func(*templ.Attributes)) templ.Attributes {
return appendVariant("destructive", opts...)
}
func Variant(variant string) func(*templ.Attributes) {
return func(attrs *templ.Attributes) {
att := *attrs
switch variant {
case "primary":
att["class"] = ui.Merge(buttonBaseClass, buttonVariantPrimary)
case "outline":
att["class"] = ui.Merge(buttonBaseClass, buttonVariantOutline)
case "secondary":
att["class"] = ui.Merge(buttonBaseClass, buttonVariantSecondary)
case "destructive":
att["class"] = ui.Merge(buttonBaseClass, buttonVariantDestructive)
}
}
}
func appendVariant(variant string, opts ...func(*templ.Attributes)) templ.Attributes {
opt := []func(*templ.Attributes){
Variant(variant),
}
opt = append(opt, opts...)
return New(opt...)
}

29
ui/card/card.templ Normal file
View file

@ -0,0 +1,29 @@
package card
import "github.com/anthdm/superkit/ui"
const cardBaseClass = "rounded-lg border bg-card text-card-foreground shadow-sm"
templ Card(opts ...func(*templ.Attributes)) {
<div { ui.CreateAttrs(cardBaseClass, "", opts...)... }>
{ children... }
</div>
}
templ Header(opts ...func(*templ.Attributes)) {
<div { ui.CreateAttrs("p-6", "", opts...)... }>
{ children... }
</div>
}
templ Content(opts ...func(*templ.Attributes)) {
<div { ui.CreateAttrs("p-6 pt-0", "", opts...)... }>
{ children... }
</div>
}
templ Footer(opts ...func(*templ.Attributes)) {
<div { ui.CreateAttrs("p-6 pt-0", "", opts...)... }>
{ children... }
</div>
}

13
ui/input/input.go Normal file
View file

@ -0,0 +1,13 @@
package input
import (
"github.com/a-h/templ"
"github.com/anthdm/superkit/ui"
)
const defaultInputClass = "flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
func Input(opts ...func(*templ.Attributes)) templ.Attributes {
return ui.CreateAttrs(defaultInputClass, "", opts...)
}

70
ui/modal/modal.templ Normal file
View file

@ -0,0 +1,70 @@
package modal
templ Modal() {
<div
x-data="{ modalOpen: false }"
@keydown.escape.window="modalOpen = false"
class="relative z-50 w-auto h-auto"
>
<div @click="modalOpen=true">
foo
</div>
<template x-teleport="body">
<div x-show="modalOpen" class="fixed top-0 left-0 z-[99] flex items-center justify-center w-screen h-screen" x-cloak>
<div
x-show="modalOpen"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="modalOpen=false"
class="absolute inset-0 w-full h-full bg-black bg-opacity-40"
></div>
<div
x-show="modalOpen"
x-trap.inert.noscroll="modalOpen"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="relative w-full py-6 px-7 sm:max-w-lg sm:rounded-lg"
>
{ children... }
<div @click="modalOpen=true">
foo
</div>
</div>
</div>
</template>
</div>
}
templ Header() {
<div class="flex items-center justify-between pb-2">
<h3 class="text-lg font-semibold">
{ children... }
</h3>
<button @click="modalOpen=false" class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 rounded-full">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
}
templ Content() {
<div class="relative w-auto">
{ children... }
</div>
}
templ Trigger() {
<div @click="modalOpen=true">
{ children... }
</div>
}
templ Footer() {
}

54
ui/table/table.templ Normal file
View file

@ -0,0 +1,54 @@
package table
import (
"github.com/anthdm/superkit/ui"
)
templ Table() {
<div class="flex flex-col border rounded-md">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full divide-y">
{ children... }
</table>
</div>
</div>
</div>
</div>
}
templ Header(opts ...func(*templ.Attributes)) {
<thead { ui.CreateAttrs("", "", opts...)... }>
<tr>
{ children... }
</tr>
</thead>
}
templ Body(opts ...func(*templ.Attributes)) {
<tbody { ui.CreateAttrs("divide-y", "", opts...)... }>
{ children... }
</tbody>
}
templ Footer(opts ...func(*templ.Attributes)) {
<tfoot>
<tr>
{ children... }
</tr>
</tfoot>
}
const (
thBaseClass = "px-5 py-3 text-xs font-medium uppercase"
tdBaseClass = "px-5 py-4 text-sm whitespace-nowrap"
)
func Td(opts ...func(*templ.Attributes)) templ.Attributes {
return ui.CreateAttrs(tdBaseClass, "", opts...)
}
func Th(opts ...func(*templ.Attributes)) templ.Attributes {
return ui.CreateAttrs(thBaseClass, "", opts...)
}

29
ui/ui.go Normal file
View file

@ -0,0 +1,29 @@
package ui
import (
"fmt"
"github.com/a-h/templ"
)
func CreateAttrs(baseClass string, defaultClass string, opts ...func(*templ.Attributes)) templ.Attributes {
attrs := templ.Attributes{
"class": baseClass + " " + defaultClass,
}
for _, o := range opts {
o(&attrs)
}
return attrs
}
func Merge(a, b string) string {
return fmt.Sprintf("%s %s", a, b)
}
func Class(class string) func(*templ.Attributes) {
return func(attrs *templ.Attributes) {
attr := *attrs
class := attr["class"].(string) + " " + class
attr["class"] = class
}
}

4
validate/README.md Normal file
View file

@ -0,0 +1,4 @@
# Validate
Schema based validation with superpowers for Golang.
# TODO

View file

@ -10,7 +10,7 @@ import (
var (
emailRegex = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
urlRegex = regexp.MustCompile(`^(http(s)?://)?([\da-z\.-]+)\.([a-z\.]{2,6})([/\w \.-]*)*/?$`)
urlRegex = regexp.MustCompile(`^(https?:\/\/)?(www\.)?([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}(\/[a-zA-Z0-9\-._~:\/?#\[\]@!$&'()*+,;=]*)?$`)
)
// RuleSet holds the state of a single rule.

View file

@ -39,7 +39,7 @@ func (e Errors) Has(field string) bool {
// Schema represents a validation schema.
type Schema map[string][]RuleSet
// Merge merges the two given schemas returning a new Schema.
// Merge merges the two given schemas, returning a new Schema.
func Merge(schema, other Schema) Schema {
newSchema := Schema{}
maps.Copy(newSchema, schema)
@ -75,9 +75,14 @@ func Request(r *http.Request, data any, schema Schema) (Errors, bool) {
func validate(data any, schema Schema, errors Errors) (Errors, bool) {
ok := true
for fieldName, ruleSets := range schema {
// Uppercase the field name so we never check un-exported fields
fieldName = string(unicode.ToUpper(rune(fieldName[0]))) + fieldName[1:]
fieldValue := getFieldValueByName(data, fieldName)
// Uppercase the field name so we never check un-exported fields.
// But we need to watch out for member fields that are uppercased by
// the user. For example (URL, ID, ...)
if !isUppercase(fieldName) {
fieldName = string(unicode.ToUpper(rune(fieldName[0]))) + fieldName[1:]
}
fieldValue := getFieldAndTagByName(data, fieldName)
for _, set := range ruleSets {
set.FieldValue = fieldValue
set.FieldName = fieldName
@ -98,7 +103,7 @@ func validate(data any, schema Schema, errors Errors) (Errors, bool) {
return errors, ok
}
func getFieldValueByName(v any, name string) any {
func getFieldAndTagByName(v any, name string) any {
val := reflect.ValueOf(v)
if val.Kind() == reflect.Ptr {
val = val.Elem()
@ -148,12 +153,18 @@ func parseRequest(r *http.Request, v any) error {
}
case reflect.String:
fieldVal.SetString(formValue)
case reflect.Int:
case reflect.Int, reflect.Int32, reflect.Int64:
intVal, err := strconv.Atoi(formValue)
if err != nil {
return fmt.Errorf("failed to parse int: %v", err)
}
fieldVal.SetInt(int64(intVal))
case reflect.Uint, reflect.Uint32, reflect.Uint64:
intVal, err := strconv.Atoi(formValue)
if err != nil {
return fmt.Errorf("failed to parse int: %v", err)
}
fieldVal.SetUint(uint64(intVal))
case reflect.Float64:
floatVal, err := strconv.ParseFloat(formValue, 64)
if err != nil {
@ -168,3 +179,12 @@ func parseRequest(r *http.Request, v any) error {
}
return nil
}
func isUppercase(s string) bool {
for _, ch := range s {
if !unicode.IsUpper(rune(ch)) {
return false
}
}
return true
}

View file

@ -2,6 +2,9 @@ package validate
import (
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
@ -28,6 +31,65 @@ var testSchema = Schema{
"username": Rules(Required),
}
func TestValidateRequest(t *testing.T) {
var (
email = "foo@bar.com"
password = "superHunter123@"
firstName = "Anthony"
website = "http://foo.com"
randomNumber = 123
randomFloat = 9.999
)
formValues := url.Values{}
formValues.Set("email", email)
formValues.Set("password", password)
formValues.Set("firstName", firstName)
formValues.Set("url", website)
formValues.Set("brandom", fmt.Sprint(randomNumber))
formValues.Set("arandom", fmt.Sprint(randomFloat))
encodedValues := formValues.Encode()
req, err := http.NewRequest("POST", "http://foo.com", strings.NewReader(encodedValues))
assert.Nil(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
type SignupData struct {
Email string `form:"email"`
Password string `form:"password"`
FirstName string `form:"firstName"`
URL string `form:"url"`
ARandomRenamedNumber int `form:"brandom"`
ARandomRenamedFloat float64 `form:"arandom"`
}
schema := Schema{
"Email": Rules(Email),
"Password": Rules(
Required,
ContainsDigit,
ContainsUpper,
ContainsSpecial,
Min(7),
),
"FirstName": Rules(Min(3), Max(50)),
"URL": Rules(URL),
"ARandomRenamedNumber": Rules(GT(100), LT(124)),
"ARandomRenamedFloat": Rules(GT(9.0), LT(10.1)),
}
var data SignupData
errors, ok := Request(req, &data, schema)
assert.True(t, ok)
assert.Empty(t, errors)
assert.Equal(t, data.Email, email)
assert.Equal(t, data.Password, password)
assert.Equal(t, data.FirstName, firstName)
assert.Equal(t, data.URL, website)
assert.Equal(t, data.ARandomRenamedNumber, randomNumber)
assert.Equal(t, data.ARandomRenamedFloat, randomFloat)
}
func TestTime(t *testing.T) {
type Foo struct {
CreatedAt time.Time
@ -48,21 +110,89 @@ func TestTime(t *testing.T) {
func TestURL(t *testing.T) {
type Foo struct {
URL string
URL string `v:"URL"`
}
foo := Foo{
URL: "not an url",
}
schema := Schema{
"url": Rules(URL),
"URL": Rules(URL),
}
errors, ok := Validate(foo, schema)
assert.False(t, ok)
assert.NotEmpty(t, errors)
foo.URL = "www.user.com"
validURLS := []string{
"http://google.com",
"http://www.google.com",
"https://www.google.com",
"https://www.google.com",
"www.google.com",
"https://book.com/sales",
"app.book.com",
"app.book.com/signup",
}
for _, url := range validURLS {
foo.URL = url
errors, ok = Validate(foo, schema)
assert.True(t, ok)
assert.Empty(t, errors)
}
}
func TestContainsUpper(t *testing.T) {
type Foo struct {
Password string
}
foo := Foo{"hunter"}
schema := Schema{
"Password": Rules(ContainsUpper),
}
errors, ok := Validate(foo, schema)
assert.False(t, ok)
assert.NotEmpty(t, errors)
foo.Password = "Hunter"
errors, ok = Validate(foo, schema)
assert.True(t, ok)
fmt.Println(errors)
assert.Empty(t, errors)
}
func TestContainsDigit(t *testing.T) {
type Foo struct {
Password string
}
foo := Foo{"hunter"}
schema := Schema{
"Password": Rules(ContainsDigit),
}
errors, ok := Validate(foo, schema)
assert.False(t, ok)
assert.NotEmpty(t, errors)
foo.Password = "Hunter1"
errors, ok = Validate(foo, schema)
assert.True(t, ok)
assert.Empty(t, errors)
}
func TestContainsSpecial(t *testing.T) {
type Foo struct {
Password string
}
foo := Foo{"hunter"}
schema := Schema{
"Password": Rules(ContainsSpecial),
}
errors, ok := Validate(foo, schema)
assert.False(t, ok)
assert.NotEmpty(t, errors)
foo.Password = "Hunter@"
errors, ok = Validate(foo, schema)
assert.True(t, ok)
assert.Empty(t, errors)
}
func TestRuleIn(t *testing.T) {