From ec429adb87d9466a1cb92e19d31c781d188ad7a4 Mon Sep 17 00:00:00 2001 From: George Suntres Date: Sun, 29 Mar 2026 12:31:04 -0400 Subject: [PATCH] Initial commit --- .gitignore | 26 ++++ encrypt.go | 33 +++++ encrypt_test.go | 41 ++++++ persist.go | 299 +++++++++++++++++++++++++++++++++++++++++++ persist_deleteone.go | 54 ++++++++ persist_find.go | 58 +++++++++ persist_findone.go | 57 +++++++++ persist_getone.go | 57 +++++++++ persist_insertone.go | 57 +++++++++ persist_test.go | 194 ++++++++++++++++++++++++++++ type.go | 23 ++++ 11 files changed, 899 insertions(+) create mode 100644 .gitignore create mode 100644 encrypt.go create mode 100644 encrypt_test.go create mode 100644 persist.go create mode 100644 persist_deleteone.go create mode 100644 persist_find.go create mode 100644 persist_findone.go create mode 100644 persist_getone.go create mode 100644 persist_insertone.go create mode 100644 persist_test.go create mode 100644 type.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a66ef27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Allowlisting gitignore template for GO projects prevents us +# from adding various unwanted local files, such as generated +# files, developer configurations or IDE-specific files etc. +# +# Recommended: Go.AllowList.gitignore + +# Ignore everything +* + +# But these files... +!.gitignore + +!*.go +!go.sum +!go.mod + +!README.md +!LICENSE + +!Makefile + +!*.sh +!*.md + +# ...even if they are in subdirectories +!*/ diff --git a/encrypt.go b/encrypt.go new file mode 100644 index 0000000..f252d81 --- /dev/null +++ b/encrypt.go @@ -0,0 +1,33 @@ +package persist + +import ( + "log" + // "unsafe" + + "golang.org/x/crypto/bcrypt" +) + +// EncryptPassword will encrypt the password and replace the old one. Will return the encrypted password. +func (user *User) EncryptPassword() string { + if user.Password == "" { + log.Println("Nothing to encrypt.") + + return "" + } + + hpass, err := bcrypt.GenerateFromPassword([]byte(user.Password), 10) + if err != nil { + log.Fatal(err) + } + + user.Password = string(hpass) + + return user.Password +} + +// CheckPassword returns true if passwords match. +func (user *User) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + + return err == nil +} \ No newline at end of file diff --git a/encrypt_test.go b/encrypt_test.go new file mode 100644 index 0000000..f9ed8ec --- /dev/null +++ b/encrypt_test.go @@ -0,0 +1,41 @@ +package persist + +import ( + "testing" +) + +func TestUser_EncryptPassword(t *testing.T) { + u := &User{ + Password: "1234", + } + + encrypted := u.EncryptPassword() + + if u.Password != encrypted { + t.Fatal("Failed to encrypt password") + } +} + +func TestUser_CheckPassword(t *testing.T) { + u := &User{ + Password: "1234", + } + + u.EncryptPassword() + + if !u.CheckPassword("1234") { + t.Fatal("Failed to check password") + } +} + +func TestUser_CheckPassword_FalsePositive(t *testing.T) { + u := &User{ + Password: "1234", + } + + u.EncryptPassword() + + if u.CheckPassword("!234") { + t.Fatal("Should have not accepted password") + } +} \ No newline at end of file diff --git a/persist.go b/persist.go new file mode 100644 index 0000000..c3b345f --- /dev/null +++ b/persist.go @@ -0,0 +1,299 @@ +// Package persist offers a convinient way to interact with the database. It takes into consideration multitenancy. +package persist + +import ( + "log" + "context" + "fmt" + "errors" + "reflect" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/go-viper/mapstructure/v2" + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/commons" + "git.gsuntres.com/general/mongo" + "git.gsuntres.com/general/structful" +) + +type PersistProps struct { + MongoSysDb string +} + +// Persist is the root object for accessing data. +type Persist struct { + // IPersist + + SysDb string +} + +var fields []reflect.StructField + +var sysDb string + +func NewPersist(props *PersistProps) *Persist { + p := &Persist{ + } + + p.SysDb = props.MongoSysDb + + return p +} + + +var persistOriginal *Persist + +var deferedFuncs map[string]any = make(map[string]any, 0) + +var persist any + +var caseString cases.Caser = cases.Title(language.English) + +type InitProps struct { + MongoSysDb string +} + +// Init runs on boot, reads system_collections from structful and registers them. No changes after boot are going to be applied. +func Init(props *InitProps) { + var persistProps PersistProps + commons.CopyToStruct(props, &persistProps) + persist = NewPersist(&persistProps) + + sysDb = props.MongoSysDb + + persistOriginal = NewPersist(&persistProps) + + // Load structful + s := structful.Current() + + filter := map[string]any{} + scol, err := s.FilterByGroup("system_collections", filter) + if err != nil { + log.Fatalf("Failed to get system collections: %v", err) + } + + // TODO(me): WIP + // Build a new struct similar to Persist + + origType := reflect.TypeOf(persist) + if origType.Kind() == reflect.Ptr { + origType = origType.Elem() + } + + // 1. Copy all existing fields + for i := 0; i < origType.NumField(); i++ { + log.Printf("Add existing field: %v", origType.Field(i)) + fields = append(fields, origType.Field(i)) + } + + client := mongo.GetMongoClient() + // Take system_collection from structful and build the calls. + for _, col := range scol { + BuildInsertOne(col) + BuildFindOne(col) + BuildFind(col) + BuildGetOne(col) + BuildDeleteOne(col) + + client.AddDefinition(col) + } + + // 3. Create the new struct type + newStructType := reflect.StructOf(fields) + + // 4. Create a new instance + newStruct := reflect.New(newStructType).Elem() + + for _, field := range fields { + // log.Printf("FIELD %s", field.Name) + if actualFunc, ok := deferedFuncs[field.Name]; ok { + f := newStruct.FieldByName(field.Name) + f.Set(reflect.ValueOf(actualFunc)) + } + } + + persist = newStruct.Addr().Interface() +} + +func GetCurrent() any { + return persist +} + +func Orig() *Persist { + return persistOriginal +} + +func Call(p any, name string, args... any) []reflect.Value { + v := reflect.ValueOf(p) + + // 1. Make sure it's the right type + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + // 2. Get field + fn := v.FieldByName(name) + + // 3. Validate + if !fn.IsValid() { + log.Fatalf("Failed to find %s field", name) + } + + if fn.Kind() != reflect.Func { + log.Fatalf("%s is not a function", name) + } + + if fn.IsNil() { + log.Fatalf("%s is nil", name) + } + + // 4. Prepare arguments + vArgs := make([]reflect.Value, len(args)) + + for i, arg := range args { + vArgs[i] = reflect.ValueOf(arg) + } + + // 5. Call and return results + return fn.Call(vArgs) +} + +const SAVE_USER_DATA = ` +{ + "type": "object", + "properties": { + "Fullname": { + "type": "string" + }, + "Firstname": { + "type": "string" + }, + "Username": { + "type": "string", + "minLength": 3 + }, + "Password": { + "type": "string", + "minLength": 1 + }, + "Email": { + "type": "string" + } + }, + "required": ["Username", "Password"] +} +` + +// SaveUser will encrypt the password before saving the user. +func (p *Persist) SaveUser(ctx context.Context, data *User) (*User, error) { + if valid := commons.Validate(SAVE_USER_DATA, data); valid != nil { + return nil, errors.New(fmt.Sprintf("%s", valid.Error())) + } + + data.EncryptPassword() + + client := mongo.GetMongoClient() + + dataNew, err := client.InsertOneFromStruct(ctx, p.SysDb, USER_COLLECTION, data) + if err != nil { + return nil, err + } + d, err := bson.Marshal(dataNew) + if err != nil { + return nil, err + } + + // Unmarshal into struct + var u User + err = bson.Unmarshal(d, &u) + if err != nil { + return nil, err + } + + return &u, nil +} + +func (p *Persist) CheckUser(ctx context.Context, usernameOrEmail string, password string) (*User, error) { + if len(usernameOrEmail) < 3 { + return nil, errors.New("username or email too short") + } + + client := mongo.GetMongoClient() + + filter := bson.M{ + "$or": bson.A{ + bson.M{"username": usernameOrEmail}, + bson.M{"email": usernameOrEmail}, + }, + } + + found, err := client.FindOne(ctx, p.SysDb, USER_COLLECTION, filter) + if err != nil { + return nil, err + } + + var user User + if err := mongo.ToStruct(found, &user); err != nil { + return nil, err + } + + if !user.CheckPassword(password) { + return nil, errors.New("invalid credentials") + } + + return &user, nil +} + +const SAVE_ACCOUNT_DATA = ` +{ + "type": "object", + "properties": { + "Code": { + "type": "string", + "minLength": 6 + }, + "Owner": { + "type": "string", + "minLength": 6 + } + }, + "required": ["Code", "Owner"] +} +` + +func (p *Persist) SaveAccount(ctx context.Context, data *Account) (*Account, error) { + if valid := commons.Validate(SAVE_ACCOUNT_DATA, data); valid != nil { + return nil, errors.New(fmt.Sprintf("%s", valid.Error())) + } + + client := mongo.GetMongoClient() + + dataNew, err := client.InsertOneFromStruct(ctx, p.SysDb, ACCOUNT_COLLECTION, data) + if err != nil { + return nil, err + } + + var o Account + mapstructure.Decode(dataNew, &o) + + return &o, nil +} + +func (p *Persist) GetAccountByCode(ctx context.Context, code string) (*Account, error) { + client := mongo.GetMongoClient() + + filter := bson.M{"code": code} + found, err := client.FindOne(ctx, p.SysDb, ACCOUNT_COLLECTION, filter) + if err != nil { + return nil, err + } + + var acc Account + if err := mongo.ToStruct(found, &acc); err != nil { + return nil, err + } + + return &acc, nil +} diff --git a/persist_deleteone.go b/persist_deleteone.go new file mode 100644 index 0000000..0ef4946 --- /dev/null +++ b/persist_deleteone.go @@ -0,0 +1,54 @@ +package persist + +import ( + "fmt" + "context" + "reflect" + + "git.gsuntres.com/general/mongo" +) + +func BuildDeleteOne(col map[string]any) { + name := col["_name"].(string) + singular := col["singular"].(string) + + // prepare input arguments and return results + in := []reflect.Type{ + reflect.TypeOf((*context.Context)(nil)).Elem(), + reflect.TypeOf((*map[string]any)(nil)).Elem(), + } + out := []reflect.Type{ + reflect.TypeOf((*error)(nil)).Elem(), + } + + // create function signature + variadic := false + funcType := reflect.FuncOf(in, out, variadic) + deleteOneName := fmt.Sprintf("%s%s", "Delete", caseString.String(singular)) + fields = append(fields, reflect.StructField{ + Name: deleteOneName, + Type: funcType, + }) + + isSystem := false + if v, ok := col["system"]; ok { + isSystem = v.(bool) + } + + mc := mongo.GetMongoClient() + + // we defer function's implementation until we create the actual struct + deferedFuncs[deleteOneName] = func(ctx context.Context, filter map[string]any) error { + db := "__undefined__" + if isSystem { + db = sysDb + } else { + account := ctx.Value("account").(string) + if account != "" { + db = mc.GetName(account) + } + } + + return mc.DeleteOne(ctx, db, name, filter) + } +} diff --git a/persist_find.go b/persist_find.go new file mode 100644 index 0000000..7e2d5b2 --- /dev/null +++ b/persist_find.go @@ -0,0 +1,58 @@ +package persist + +import ( + "fmt" + "context" + "reflect" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo" +) + +func BuildFind(col map[string]any) { + name := col["_name"].(string) + plural := col["plural"].(string) + + // prepare input arguments and return results + in := []reflect.Type{ + reflect.TypeOf((*context.Context)(nil)).Elem(), + reflect.TypeOf((*map[string]any)(nil)).Elem(), + reflect.TypeOf((*int64)(nil)).Elem(), + } + out := []reflect.Type{ + reflect.TypeOf(bson.M{}), + reflect.TypeOf((*error)(nil)).Elem(), + } + + // create function signature + variadic := false + funcType := reflect.FuncOf(in, out, variadic) + funcName := fmt.Sprintf("%s%s", "Find", caseString.String(plural)) + fields = append(fields, reflect.StructField{ + Name: funcName, + Type: funcType, + }) + + isSystem := false + if v, ok := col["system"]; ok { + isSystem = v.(bool) + } + + mc := mongo.GetMongoClient() + + // we defer function's implementation until we create the actual struct + deferedFuncs[funcName] = func(ctx context.Context, filter map[string]any, limit int64) (bson.M, error) { + db := "__undefined__" + if isSystem { + db = sysDb + } else { + account := ctx.Value("account").(string) + if account != "" { + db = mc.GetName(account) + } + } + + return mc.Find(ctx, db, name, filter, limit) + } +} diff --git a/persist_findone.go b/persist_findone.go new file mode 100644 index 0000000..04b277d --- /dev/null +++ b/persist_findone.go @@ -0,0 +1,57 @@ +package persist + +import ( + "fmt" + "context" + "reflect" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo" +) + +func BuildFindOne(col map[string]any) { + name := col["_name"].(string) + singular := col["singular"].(string) + + // prepare input arguments and return results + in := []reflect.Type{ + reflect.TypeOf((*context.Context)(nil)).Elem(), + reflect.TypeOf((*map[string]any)(nil)).Elem(), + } + out := []reflect.Type{ + reflect.TypeOf(bson.M{}), + reflect.TypeOf((*error)(nil)).Elem(), + } + + // create function signature + variadic := false + funcType := reflect.FuncOf(in, out, variadic) + funcName := fmt.Sprintf("%s%s", "Find", caseString.String(singular)) + fields = append(fields, reflect.StructField{ + Name: funcName, + Type: funcType, + }) + + isSystem := false + if v, ok := col["system"]; ok { + isSystem = v.(bool) + } + + mc := mongo.GetMongoClient() + + // we defer function's implementation until we create the actual struct + deferedFuncs[funcName] = func(ctx context.Context, filter map[string]any) (bson.M, error) { + db := "__undefined__" + if isSystem { + db = sysDb + } else { + account := ctx.Value("account").(string) + if account != "" { + db = mc.GetName(account) + } + } + + return mc.FindOne(ctx, db, name, filter) + } +} diff --git a/persist_getone.go b/persist_getone.go new file mode 100644 index 0000000..5745baa --- /dev/null +++ b/persist_getone.go @@ -0,0 +1,57 @@ +package persist + +import ( + "fmt" + "context" + "reflect" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo" +) + +func BuildGetOne(col map[string]any) { + name := col["_name"].(string) + singular := col["singular"].(string) + + // prepare input arguments and return results + in := []reflect.Type{ + reflect.TypeOf((*context.Context)(nil)).Elem(), + reflect.TypeOf((*map[string]any)(nil)).Elem(), + } + out := []reflect.Type{ + reflect.TypeOf(bson.M{}), + reflect.TypeOf((*error)(nil)).Elem(), + } + + // create function signature + variadic := false + funcType := reflect.FuncOf(in, out, variadic) + funcName := fmt.Sprintf("%s%s", "Get", caseString.String(singular)) + fields = append(fields, reflect.StructField{ + Name: funcName, + Type: funcType, + }) + + isSystem := false + if v, ok := col["system"]; ok { + isSystem = v.(bool) + } + + mc := mongo.GetMongoClient() + + // we defer function's implementation until we create the actual struct + deferedFuncs[funcName] = func(ctx context.Context, data map[string]any) (bson.M, error) { + db := "__undefined__" + if isSystem { + db = sysDb + } else { + account := ctx.Value("account").(string) + if account != "" { + db = mc.GetName(account) + } + } + + return mc.GetOne(ctx, db, name, data) + } +} diff --git a/persist_insertone.go b/persist_insertone.go new file mode 100644 index 0000000..3b0b0e0 --- /dev/null +++ b/persist_insertone.go @@ -0,0 +1,57 @@ +package persist + +import ( + "fmt" + "context" + "reflect" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo" +) + +func BuildInsertOne(col map[string]any) { + name := col["_name"].(string) + singular := col["singular"].(string) + + // prepare input arguments and return results + in := []reflect.Type{ + reflect.TypeOf((*context.Context)(nil)).Elem(), + reflect.TypeOf((*map[string]any)(nil)).Elem(), + } + out := []reflect.Type{ + reflect.TypeOf(bson.M{}), + reflect.TypeOf((*error)(nil)).Elem(), + } + + // create function signature + variadic := false + funcType := reflect.FuncOf(in, out, variadic) + insertOneName := fmt.Sprintf("%s%s", "Insert", caseString.String(singular)) + fields = append(fields, reflect.StructField{ + Name: insertOneName, + Type: funcType, + }) + + isSystem := false + if v, ok := col["system"]; ok { + isSystem = v.(bool) + } + + mc := mongo.GetMongoClient() + + // we defer function's implementation until we create the actual struct + deferedFuncs[insertOneName] = func(ctx context.Context, data map[string]any) (bson.M, error) { + db := "__undefined__" + if isSystem { + db = sysDb + } else { + account := ctx.Value("account").(string) + if account != "" { + db = mc.GetName(account) + } + } + + return mc.InsertOne(ctx, db, name, data) + } +} diff --git a/persist_test.go b/persist_test.go new file mode 100644 index 0000000..a428984 --- /dev/null +++ b/persist_test.go @@ -0,0 +1,194 @@ +package persist + +import ( + "os" + "testing" + "context" + "fmt" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo" + +) + +func TestMain(m *testing.M) { + if os.Getenv("RUN_INTEGRATION") == "" { + fmt.Println("Skipping package tests: RUN_INTEGRATION is missing") + os.Exit(0) // Exit with success, but no tests ran + } + + ctx := context.Background() + + user := "admin" + pass := "1" + + // 1. Setup: Start the MongoDB container + mongoContainer, err := mongodb.Run(ctx, "mongo:8", + testcontainers.WithEnv(map[string]string{ + "MONGO_INITDB_ROOT_USERNAME": user, + "MONGO_INITDB_ROOT_PASSWORD": pass, + }),) + if err != nil { + panic("failed to start container") + } + + // 2. Get the connection string dynamically + endpoint, _ := mongoContainer.ConnectionString(ctx) + + mongo.Start(&mongo.MongoStartProps{ + MongoUri: endpoint, + MongoUser: user, + MongoPass: pass, + }) + + // 3. Run tests + code := m.Run() + + // 4. Teardown: Clean up resources + mongo.Stop() + _ = testcontainers.TerminateContainer(mongoContainer) + + os.Exit(code) +} + +func TestSaveUser_Valid(t *testing.T) { + p := NewPersist(&PersistProps{ + MongoSysDb: "test_boxtep_sys", + }) + + data := &User{ + Fullname: "King Long Too", + Firstname: "Too", + Username: "toolong", + Password: "mypass", + Email: "too@long.test", + } + + user, err := p.SaveUser(context.TODO(), data) + + if err != nil { + t.Fatalf("Should have saved user %#v", err) + } + + data.Id = user.Id + + dataMap, _ := mongo.ToMap(data) + userMap, _ := mongo.ToMap(user) + + mongo.AssertSubset(t, dataMap, userMap, "Should have been equal") +} + +func TestSaveUser_RequireUsername(t *testing.T) { + p := NewPersist(&PersistProps{ + MongoSysDb: "test_boxtep_sys", + }) + + data := &User{ + Fullname: "King Long Too", + Firstname: "Too", + Password: "mypass", + Email: "too@long.test", + } + + _, err := p.SaveUser(context.TODO(), data) + + if err == nil || err.Error() != `properties/Username: value "" too short for "minLength" argument 3` { + t.Fatal("Should require username") + } +} + +func TestSaveUser_RequirePassword(t *testing.T) { + p := NewPersist(&PersistProps{ + MongoSysDb: "test_boxtep_sys", + }) + + data := &User{ + Fullname: "King Long Too", + Firstname: "Too", + Username: "toolong", + Email: "too@long.test", + } + + _, err := p.SaveUser(context.TODO(), data) + + if err == nil || err.Error() != `properties/Password: value "" too short for "minLength" argument 1` { + t.Fatal("Should require password") + } +} + +func TestSaveUser_UsesSysDb(t *testing.T) { + db := "test_boxtep_sys" + p := NewPersist(&PersistProps{ + MongoSysDb: db, + }) + + data := &User{ + Fullname: "King Long Too", + Firstname: "Too", + Username: "toolong", + Password: "1", + Email: "too@long.test", + } + + _, err := p.SaveUser(context.TODO(), data) + + if err != nil { + t.Fatalf("Should have saved user %#v", err) + } + + // check if user is saved + client := mongo.GetMongoClient() + + collection := client.GetCollection(db, USER_COLLECTION) + + var found bson.M + filter := bson.M{"username": "toolong"} + if err := collection.FindOne(context.Background(), filter).Decode(&found); err != nil { + t.Fatalf("Should have found user %#v", err) + } + + delete(found, "_id") + delete(found, "password") + delete(found, "created_at") + delete(found, "updated_at") + + d, _ := mongo.ToMap(data) + + mongo.AssertSubset(t, found, d, "Should have been equal") +} + +func TestAuthUser_UsingUsername(t *testing.T) { + db := "test_boxtep_sys" + p := NewPersist(&PersistProps{ + MongoSysDb: db, + }) + + data := &User{ + Fullname: "King Long Too", + Firstname: "Too", + Username: "toolong_test1", + Password: "1", + Email: "too@long.test", + } + + _, err := p.SaveUser(context.TODO(), data) + + if err != nil { + t.Fatalf("Should have saved user %#v", err) + } + + user, err := p.CheckUser(context.TODO(), "toolong_test1", "1") + if err != nil { + t.Fatalf("Should have checked %#v", err) + } + + d, _ := mongo.ToMap(data) + u, _ := mongo.ToMap(user) + + delete(u, "_id") + + mongo.AssertSubset(t, u, d, "Wrong user") +} diff --git a/type.go b/type.go new file mode 100644 index 0000000..2c1e5a7 --- /dev/null +++ b/type.go @@ -0,0 +1,23 @@ +package persist + +const USER_COLLECTION = "user" + +// User can use [User.EncryptPassword] to encrypt the password +type User struct { + Id string `bson:"_id" json:"id"` + Fullname string `bson:"fullname" json:"fullname"` + Firstname string `bson:"firstname" json:"firstname"` + Username string `bson:"username" json:"username"` + Password string `bson:"password" json:"password"` + Email string `bson:"email" json:"email"` + Roles []string `bson:"roles" json:"roles"` + Credentials map[string]any `bson:"credentials" json:"credentials"` +} + +const ACCOUNT_COLLECTION = "account" +// Deprecate: use core.BusinessAccount +type Account struct { + Id string `bson:"_id" json:"id"` + Code string `bson:"code" json:"code"` + Owner string `bson:"owner" json:"owner"` +}