From c68c01af99504827f730f378816ec4114bb3d32a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 16 Nov 2022 16:16:16 +0400 Subject: [PATCH 001/109] reformat m2m sql --- check_csrf.go | 5 ----- d_api_add.go | 4 +--- d_api_edit.go | 15 ++++----------- db.go | 35 ++++++++++++++--------------------- rate_limit.go | 3 +-- sql_injection.go | 6 ------ 6 files changed, 20 insertions(+), 48 deletions(-) diff --git a/check_csrf.go b/check_csrf.go index 2cae65af..b24bf3e0 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -1,7 +1,6 @@ package uadmin import ( - "net" "net/http" ) @@ -48,7 +47,6 @@ work, `x-csrf-token` paramtere should be added. Where you replace `MY_SESSION_KEY` with the session key. */ func CheckCSRF(r *http.Request) bool { - var err error if r.FormValue("x-csrf-token") != "" && r.FormValue("x-csrf-token") == getSession(r) { return false } @@ -57,9 +55,6 @@ func CheckCSRF(r *http.Request) bool { user = &User{} } ip := GetRemoteIP(r) - if ip, _, err = net.SplitHostPort(ip); err != nil { - ip = GetRemoteIP(r) - } Trail(CRITICAL, "Request failed Anti-CSRF protection from user:%s IP:%s", user.Username, ip) return true diff --git a/d_api_add.go b/d_api_add.go index d4865d43..41bf8792 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -137,9 +137,7 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql := sqlDialect[Database.Type]["insertM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", fmt.Sprint(createdIDs[i]), -1) - sql = strings.Replace(sql, "{TABLE2_ID}", id, -1) - db = db.Exec(sql) + db = db.Exec(sql, createdIDs[i], id) } } } diff --git a/d_api_edit.go b/d_api_edit.go index 734cbdac..7c1206d9 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -2,7 +2,6 @@ package uadmin import ( "encoding/json" - "fmt" "net/http" "reflect" "strings" @@ -128,8 +127,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql := sqlDialect[Database.Type]["deleteM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", fmt.Sprint(GetID(modelArray.Elem().Index(i))), -1) - db = db.Exec(sql) + db = db.Exec(sql, GetID(modelArray.Elem().Index(i))) if v == "" { continue @@ -140,9 +138,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql = sqlDialect[Database.Type]["insertM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", fmt.Sprint(GetID(modelArray.Elem().Index(i))), -1) - sql = strings.Replace(sql, "{TABLE2_ID}", id, -1) - db = db.Exec(sql) + db = db.Exec(sql, GetID(modelArray.Elem().Index(i)), id) } } } @@ -182,8 +178,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql := sqlDialect[Database.Type]["deleteM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", urlParts[2], -1) - db = db.Exec(sql) + db = db.Exec(sql, urlParts[2]) if v == "" { continue @@ -194,9 +189,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql = sqlDialect[Database.Type]["insertM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", urlParts[2], -1) - sql = strings.Replace(sql, "{TABLE2_ID}", id, -1) - db = db.Exec(sql) + db = db.Exec(sql, urlParts[2], id) } } db.Commit() diff --git a/db.go b/db.go index f9d6cf61..9dbbb254 100644 --- a/db.go +++ b/db.go @@ -36,22 +36,22 @@ var db *gorm.DB var sqlDialect = map[string]map[string]string{ "mysql": { "createM2MTable": "CREATE TABLE `{TABLE1}_{TABLE2}` (`table1_id` int(10) unsigned NOT NULL, `table2_id` int(10) unsigned NOT NULL, PRIMARY KEY (`table1_id`,`table2_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;", - "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id={TABLE1_ID};", - "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`={TABLE1_ID};", - "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES ({TABLE1_ID}, {TABLE2_ID});", + "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id=?;", + "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`=?;", + "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES (?, ?);", }, "postgres": { "createM2MTable": `CREATE TABLE "{TABLE1}_{TABLE2}" ("table1_id" BIGINT NOT NULL, "table2_id" BIGINT NOT NULL, PRIMARY KEY ("table1_id","table2_id"))`, - "selectM2M": `SELECT "table2_id" FROM "{TABLE1}_{TABLE2}" WHERE table1_id={TABLE1_ID};`, - "deleteM2M": `DELETE FROM "{TABLE1}_{TABLE2}" WHERE "table1_id"={TABLE1_ID};`, - "insertM2M": `INSERT INTO "{TABLE1}_{TABLE2}" VALUES ({TABLE1_ID}, {TABLE2_ID});`, + "selectM2M": `SELECT "table2_id" FROM "{TABLE1}_{TABLE2}" WHERE table1_id=?;`, + "deleteM2M": `DELETE FROM "{TABLE1}_{TABLE2}" WHERE "table1_id"=?;`, + "insertM2M": `INSERT INTO "{TABLE1}_{TABLE2}" VALUES (?, ?);`, }, "sqlite": { //"createM2MTable": "CREATE TABLE `{TABLE1}_{TABLE2}` (`{TABLE1}_id` INTEGER NOT NULL,`{TABLE2}_id` INTEGER NOT NULL, PRIMARY KEY(`{TABLE1}_id`,`{TABLE2}_id`));", "createM2MTable": "CREATE TABLE `{TABLE1}_{TABLE2}` (`table1_id` INTEGER NOT NULL,`table2_id` INTEGER NOT NULL, PRIMARY KEY(`table1_id`,`table2_id`));", - "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id={TABLE1_ID};", - "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`={TABLE1_ID};", - "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES ({TABLE1_ID}, {TABLE2_ID});", + "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id=?;", + "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`=?;", + "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES (?, ?);", }, } @@ -257,9 +257,6 @@ func GetDB() *gorm.DB { os.Exit(2) } - // Set collate - // db = db.Set("gorm:table_options", "ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci") - // Temp solution for 0 foreign key err = db.Exec("SET PERSIST FOREIGN_KEY_CHECKS=0;").Error if err != nil { @@ -477,13 +474,12 @@ func customSave(m interface{}) (err error) { sql := sqlDialect[Database.Type]["deleteM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", fmt.Sprint(GetID(value)), -1) TimeMetric("uadmin/db/duration", 1000, func() { - err = db.Exec(sql).Error + err = db.Exec(sql, GetID(value)).Error for fmt.Sprint(err) == "database is locked" { time.Sleep(time.Millisecond * 100) - err = db.Exec(sql).Error + err = db.Exec(sql, GetID(value)).Error } }) if err != nil { @@ -496,14 +492,12 @@ func customSave(m interface{}) (err error) { sql := sqlDialect[Database.Type]["insertM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - sql = strings.Replace(sql, "{TABLE1_ID}", fmt.Sprint(GetID(value)), -1) - sql = strings.Replace(sql, "{TABLE2_ID}", fmt.Sprint(GetID(value.Field(i).Index(index))), -1) TimeMetric("uadmin/db/duration", 1000, func() { - err = db.Exec(sql).Error + err = db.Exec(sql, GetID(value), GetID(value.Field(i).Index(index))).Error for fmt.Sprint(err) == "database is locked" { time.Sleep(time.Millisecond * 100) - err = db.Exec(sql).Error + err = db.Exec(sql, GetID(value), GetID(value.Field(i).Index(index))).Error } }) if err != nil { @@ -854,10 +848,9 @@ func customGet(m interface{}, m2m ...string) (err error) { sqlSelect := sqlDialect[Database.Type]["selectM2M"] sqlSelect = strings.Replace(sqlSelect, "{TABLE1}", table1, -1) sqlSelect = strings.Replace(sqlSelect, "{TABLE2}", table2, -1) - sqlSelect = strings.Replace(sqlSelect, "{TABLE1_ID}", fmt.Sprint(GetID(value)), -1) var rows *sql.Rows - rows, err = db.Raw(sqlSelect).Rows() + rows, err = db.Raw(sqlSelect, GetID(value)).Rows() if err != nil { Trail(ERROR, "Unable to get m2m records. %s", err) Trail(ERROR, sqlSelect) diff --git a/rate_limit.go b/rate_limit.go index 7721fd32..265c3f9d 100644 --- a/rate_limit.go +++ b/rate_limit.go @@ -1,7 +1,6 @@ package uadmin import ( - "net" "net/http" "sync" "time" @@ -14,7 +13,7 @@ var rateLimitLock = sync.Mutex{} // the IP in the request has exceeded their quota func CheckRateLimit(r *http.Request) bool { rateLimitLock.Lock() - ip, _, _ := net.SplitHostPort(GetRemoteIP(r)) + ip := GetRemoteIP(r) now := time.Now().Unix() * RateLimit if val, ok := rateLimitMap[ip]; ok { if (val + RateLimitBurst) < now { diff --git a/sql_injection.go b/sql_injection.go index 9bbef373..07f45f2d 100644 --- a/sql_injection.go +++ b/sql_injection.go @@ -1,7 +1,6 @@ package uadmin import ( - "net" "net/http" "regexp" "strings" @@ -15,16 +14,11 @@ import ( // // return true for sql injection attempt and false for safe requests func SQLInjection(r *http.Request, key, value string) bool { - var err error - user := GetUserFromRequest(r) if user == nil { user = &User{} } ip := GetRemoteIP(r) - if ip, _, err = net.SplitHostPort(ip); err != nil { - ip = GetRemoteIP(r) - } errMsg := "SQL Injection attempt (%s '%s'). User:" + user.Username + " IP:" + ip if key != "" { // Case 1 - Comment injection From c8eb77db43ee07881a692ac84d810de1587f9c78 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 16 Nov 2022 16:30:40 +0400 Subject: [PATCH 002/109] upgrade sockio for client --- go.mod | 10 ++++----- go.sum | 22 +++++++++---------- .../assets/socket.io-client/package.json | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 5410c690..409f1962 100644 --- a/go.mod +++ b/go.mod @@ -9,10 +9,10 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e github.com/uadmin/rrd v0.0.0-20200219090641-e438da1b7640 github.com/xuri/excelize/v2 v2.6.1 - golang.org/x/crypto v0.1.0 - golang.org/x/mod v0.6.0 - golang.org/x/net v0.1.0 - gorm.io/driver/mysql v1.4.3 + golang.org/x/crypto v0.2.0 + golang.org/x/mod v0.7.0 + golang.org/x/net v0.2.0 + gorm.io/driver/mysql v1.4.4 gorm.io/driver/postgres v1.4.5 gorm.io/driver/sqlite v1.4.3 gorm.io/gorm v1.24.1 @@ -36,6 +36,6 @@ require ( github.com/richardlehane/msoleps v1.0.3 // indirect github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 // indirect github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 // indirect - golang.org/x/sys v0.1.0 // indirect + golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect ) diff --git a/go.sum b/go.sum index 662a4f72..93ba2e38 100644 --- a/go.sum +++ b/go.sum @@ -166,16 +166,16 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= -golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= +golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= +golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I= -golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= +golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -184,8 +184,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= -golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -205,12 +205,12 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -243,8 +243,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= -gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ= +gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM= gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc= gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg= gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= diff --git a/static/uadmin/assets/socket.io-client/package.json b/static/uadmin/assets/socket.io-client/package.json index 8ec99e7b..e54be9a7 100644 --- a/static/uadmin/assets/socket.io-client/package.json +++ b/static/uadmin/assets/socket.io-client/package.json @@ -64,7 +64,7 @@ "indexof": "0.0.1", "object-component": "0.0.3", "parseuri": "0.0.2", - "socket.io-parser": "3.4.1", + "socket.io-parser": "4.2.1", "to-array": "0.1.3" }, "description": "[![Build Status](https://secure.travis-ci.org/Automattic/socket.io-client.svg)](http://travis-ci.org/Automattic/socket.io-client) ![NPM version](https://badge.fury.io/js/socket.io-client.svg) ![Downloads](http://img.shields.io/npm/dm/socket.io-client.svg?", From 6b3ef1300f7a2fce746aeb1d94a0914c62fafc48 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 17 Nov 2022 18:13:56 +0400 Subject: [PATCH 003/109] BUG FIX for cached permissions --- .jwt | 1 + auth.go | 63 +++++++++++++++++++++++++++++++++++++++++++++- check_csrf.go | 13 +++++++++- encrypt.go | 1 - grouppermission.go | 2 +- register.go | 12 +++++++++ userpermission.go | 5 +++- 7 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 .jwt diff --git a/.jwt b/.jwt new file mode 100644 index 00000000..209b5a28 --- /dev/null +++ b/.jwt @@ -0,0 +1 @@ +3 =J 0\}C#A,k~D \ No newline at end of file diff --git a/auth.go b/auth.go index b3654ea9..4a0d8b36 100644 --- a/auth.go +++ b/auth.go @@ -2,11 +2,15 @@ package uadmin import ( "context" + "encoding/base64" + "encoding/json" "math/big" "net" "path" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "math" "net/http" "strconv" @@ -21,9 +25,12 @@ import ( // an expiry date. var CookieTimeout = -1 -// Salt is extra salt added to password hashing +// Salt is added to password hashing var Salt = "" +// JWT secret for signing tokens +var JWT = "" + // bcryptDiff var bcryptDiff = 12 @@ -497,6 +504,60 @@ func getSession(r *http.Request) string { return r.FormValue("session") } } + // JWT + if r.Header.Get("Authorization") != "" { + if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer") { + jwt := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + jwtParts := strings.Split(jwt, ".") + + if len(jwtParts) != 3 { + return "" + } + + jHeader, err := base64.RawURLEncoding.DecodeString(jwtParts[0]) + if err != nil { + return "" + } + jPayload, err := base64.RawURLEncoding.DecodeString(jwtParts[1]) + if err != nil { + return "" + } + + header := map[string]interface{}{} + err = json.Unmarshal(jHeader, &header) + if err != nil { + return "" + } + payload := map[string]interface{}{} + err = json.Unmarshal(jPayload, &payload) + if err != nil { + return "" + } + + // Verify the signature + if _, ok := payload["alg"]; !ok { + return "" + } + if _, ok := payload["typ"]; ok { + if v, ok := payload["typ"].(string); !ok || v != "JWT" { + return "" + } + } + switch payload["alg"].(string) { + case "none": + // Don't allow none type for auth JWT + return "" + case "HS256": + hash := hmac.New(sha256.New, []byte(Salt)) + hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) + token := hash.Sum(nil) + b64Token := base64.RawURLEncoding.EncodeToString(token) + if b64Token != jwtParts[2] { + return "" + } + } + } + } return "" } diff --git a/check_csrf.go b/check_csrf.go index b24bf3e0..64f9c57c 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -47,7 +47,8 @@ work, `x-csrf-token` paramtere should be added. Where you replace `MY_SESSION_KEY` with the session key. */ func CheckCSRF(r *http.Request) bool { - if r.FormValue("x-csrf-token") != "" && r.FormValue("x-csrf-token") == getSession(r) { + token := getCSRFToken(r) + if token != "" && token == getSession(r) { return false } user := GetUserFromRequest(r) @@ -59,3 +60,13 @@ func CheckCSRF(r *http.Request) bool { Trail(CRITICAL, "Request failed Anti-CSRF protection from user:%s IP:%s", user.Username, ip) return true } + +func getCSRFToken(r *http.Request) string { + if r.FormValue("x-csrf-token") != "" { + return r.FormValue("x-csrf-token") + } + if r.Header.Get("X-CSRF-TOKEN") != "" { + return r.Header.Get("X-CSRF-TOKEN") + } + return "" +} diff --git a/encrypt.go b/encrypt.go index 5228e96e..ffd856d9 100644 --- a/encrypt.go +++ b/encrypt.go @@ -85,7 +85,6 @@ func decryptArray(a interface{}) { if schema, ok := getSchema(getModelName(a)); ok { for _, f := range schema.Fields { if f.Encrypt { - // TODO: Decrypt allArray := reflect.ValueOf(a) for i := 0; i < allArray.Elem().Len(); i++ { encryptedValue := allArray.Elem().Index(i).FieldByName(f.Name).String() diff --git a/grouppermission.go b/grouppermission.go index d16d8fbe..08d3a4fa 100644 --- a/grouppermission.go +++ b/grouppermission.go @@ -24,7 +24,7 @@ func (g GroupPermission) String() string { func (g *GroupPermission) Save() { Save(g) - loadSessions() + loadPermissions() } // HideInDashboard to return false and auto hide this from dashboard diff --git a/register.go b/register.go index ee20e4ec..2b5b6ed7 100644 --- a/register.go +++ b/register.go @@ -149,6 +149,18 @@ func Register(m ...interface{}) { } } + // Check if JWT key is there or generate it + if _, err := os.Stat(".jwt"); os.IsNotExist(err) && os.Getenv("UADMIN_JWT") == "" { + JWT = GenerateBase64(24) + ioutil.WriteFile(".jwt", EncryptKey, 0600) + } else { + JWT = os.Getenv("UADMIN_JWT") + if len(JWT) == 0 { + buf, _ := ioutil.ReadFile(".jwt") + JWT = string(buf) + } + } + // Check if salt is there or generate it users := []User{} if _, err := os.Stat(".salt"); os.IsNotExist(err) && os.Getenv("UADMIN_SALT") == "" { diff --git a/userpermission.go b/userpermission.go index 5d98f876..bd9e1802 100644 --- a/userpermission.go +++ b/userpermission.go @@ -28,7 +28,7 @@ func (u UserPermission) String() string { func (u *UserPermission) Save() { Save(u) - loadSessions() + loadPermissions() } // HideInDashboard to return false and auto hide this from dashboard @@ -37,6 +37,9 @@ func (UserPermission) HideInDashboard() bool { } func loadPermissions() { + if !CachePermissions { + return + } cacheUserPerms = []UserPermission{} cacheGroupPerms = []GroupPermission{} cachedModels = []DashboardMenu{} From 402feb9c80be1010ff0a8a1f05c008db9f177e0a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 17 Nov 2022 20:46:53 +0400 Subject: [PATCH 004/109] add instructions to remove .jwt after testing --- .jwt | 1 - server_test.go | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .jwt diff --git a/.jwt b/.jwt deleted file mode 100644 index 209b5a28..00000000 --- a/.jwt +++ /dev/null @@ -1 +0,0 @@ -3 =J 0\}C#A,k~D \ No newline at end of file diff --git a/server_test.go b/server_test.go index 198acb35..e1de492d 100644 --- a/server_test.go +++ b/server_test.go @@ -319,6 +319,7 @@ func teardownFunction() { os.Remove(".salt") os.Remove(".uproj") os.Remove(".bindip") + os.Remove(".jwt") // Delete temp media file os.RemoveAll("./media") From bcab92da407ce74ea4b76e1d978669916ded6c1d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 17 Nov 2022 21:30:39 +0400 Subject: [PATCH 005/109] Add JWT access support --- auth.go | 102 ++++++++++++++++++++++++++++++++++++----------- login_handler.go | 14 ++----- 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/auth.go b/auth.go index 4a0d8b36..e098da83 100644 --- a/auth.go +++ b/auth.go @@ -37,7 +37,7 @@ var bcryptDiff = 12 // cachedSessions is variable for keeping active sessions var cachedSessions map[string]Session -// invalidAttemps keeps track of invalid password attempts +// invalidAttempts keeps track of invalid password attempts // per IP address var invalidAttempts = map[string]int{} @@ -101,7 +101,7 @@ func IsAuthenticated(r *http.Request) *Session { } // SetSessionCookie sets the session cookie value, The the value passed in -// session is nil, then the session assiged will be a no user session +// session is nil, then the session assigned will be a no user session func SetSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { if s == nil { http.SetCookie(w, &http.Cookie{ @@ -112,18 +112,55 @@ func SetSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { Expires: time.Now().AddDate(0, 0, 1), }) } else { - exDate := time.Time{} - if s.ExpiresOn != nil { - exDate = *s.ExpiresOn - } - http.SetCookie(w, &http.Cookie{ + sessionCookie := &http.Cookie{ Name: "session", Value: s.Key, SameSite: http.SameSiteStrictMode, Path: "/", - Expires: exDate, - }) + } + if s.ExpiresOn != nil { + sessionCookie.Expires = *s.ExpiresOn + } + http.SetCookie(w, sessionCookie) + + jwt := createJWT(r, s) + jwtCookie := &http.Cookie{ + Name: "access-jwt", + Value: jwt, + SameSite: http.SameSiteStrictMode, + Path: "/", + } + if s.ExpiresOn != nil { + jwtCookie.Expires = *s.ExpiresOn + } + http.SetCookie(w, jwtCookie) + } +} + +func createJWT(r *http.Request, s *Session) string { + if s == nil { + return "" } + if !isValidSession(r, s) { + return "" + } + header := map[string]interface{}{ + "alg": "HS256", + "typ": "JWT", + } + payload := map[string]interface{}{ + "sub": s.User.Username, + } + jHeader, _ := json.Marshal(header) + jPayload, _ := json.Marshal(payload) + b64Header := base64.RawURLEncoding.EncodeToString(jHeader) + b64Payload := base64.RawURLEncoding.EncodeToString(jPayload) + + hash := hmac.New(sha256.New, []byte(JWT)) + hash.Write([]byte(b64Header + "." + b64Payload)) + signature := hash.Sum(nil) + b64Signature := base64.RawURLEncoding.EncodeToString(signature) + return b64Header + "." + b64Payload + "." + b64Signature } func isValidSession(r *http.Request, s *Session) bool { @@ -528,34 +565,53 @@ func getSession(r *http.Request) string { if err != nil { return "" } - payload := map[string]interface{}{} - err = json.Unmarshal(jPayload, &payload) - if err != nil { - return "" - } // Verify the signature - if _, ok := payload["alg"]; !ok { - return "" + alg := "HS256" + if v, ok := header["alg"].(string); ok { + alg = v } - if _, ok := payload["typ"]; ok { - if v, ok := payload["typ"].(string); !ok || v != "JWT" { + if _, ok := header["typ"]; ok { + if v, ok := header["typ"].(string); !ok || v != "JWT" { return "" } } - switch payload["alg"].(string) { - case "none": - // Don't allow none type for auth JWT - return "" + switch alg { case "HS256": - hash := hmac.New(sha256.New, []byte(Salt)) + hash := hmac.New(sha256.New, []byte(JWT)) hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) token := hash.Sum(nil) b64Token := base64.RawURLEncoding.EncodeToString(token) if b64Token != jwtParts[2] { return "" } + default: + // For now, only support HMAC-SHA256 + return "" + } + + // Get data from payload + payload := map[string]interface{}{} + err = json.Unmarshal(jPayload, &payload) + if err != nil { + return "" + } + + // if there is no subject, return empty session + if _, ok := payload["sub"].(string); !ok { + return "" } + + sub := payload["sub"].(string) + user := User{} + Get(&user, "username = ?", sub) + + if user.ID == 0 { + return "" + } + + session := user.GetActiveSession() + return session.Key } } return "" diff --git a/login_handler.go b/login_handler.go index 1d6012ca..11e389e7 100644 --- a/login_handler.go +++ b/login_handler.go @@ -61,18 +61,12 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { if session.PendingOTP { Trail(INFO, "User: %s OTP: %s", session.User.Username, session.User.GetOTP()) } - cookie, _ := r.Cookie("session") - if cookie == nil { - cookie = &http.Cookie{} - } - cookie.Name = "session" - cookie.Value = session.Key - cookie.Path = "/" - cookie.SameSite = http.SameSiteStrictMode - http.SetCookie(w, cookie) + + // Set session cookie + SetSessionCookie(w, r, session) // set language cookie - cookie, _ = r.Cookie("language") + cookie, _ := r.Cookie("language") if cookie == nil { cookie = &http.Cookie{} } From bb87390d6c0430010984451e5abd67b137817450 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 17 Nov 2022 22:59:06 +0400 Subject: [PATCH 006/109] Add extra data to JWT --- auth.go | 3 +++ register.go | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/auth.go b/auth.go index e098da83..76fef3e8 100644 --- a/auth.go +++ b/auth.go @@ -151,6 +151,9 @@ func createJWT(r *http.Request, s *Session) string { payload := map[string]interface{}{ "sub": s.User.Username, } + if s.ExpiresOn != nil { + payload["exp"] = s.ExpiresOn.Unix() + } jHeader, _ := json.Marshal(header) jPayload, _ := json.Marshal(payload) b64Header := base64.RawURLEncoding.EncodeToString(jHeader) diff --git a/register.go b/register.go index 2b5b6ed7..f92a10b1 100644 --- a/register.go +++ b/register.go @@ -151,8 +151,8 @@ func Register(m ...interface{}) { // Check if JWT key is there or generate it if _, err := os.Stat(".jwt"); os.IsNotExist(err) && os.Getenv("UADMIN_JWT") == "" { - JWT = GenerateBase64(24) - ioutil.WriteFile(".jwt", EncryptKey, 0600) + JWT = GenerateBase64(64) + ioutil.WriteFile(".jwt", []byte(JWT), 0600) } else { JWT = os.Getenv("UADMIN_JWT") if len(JWT) == 0 { From 5c515052fb3b6ef2efe56467fbe8e626b2b8736d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 18 Nov 2022 10:57:14 +0400 Subject: [PATCH 007/109] BUG FIX: dAPI __is works now with the new version of gorm --- d_api_helper.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index d7881181..1911d070 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -258,7 +258,7 @@ func getQueryOperator(r *http.Request, v string, tableName string) string { return strings.TrimSuffix(v, "__in") + nTerm + columnEnclosure() + " IN (?)" } if strings.HasSuffix(v, "__is") { - return strings.TrimSuffix(v, "__is") + columnEnclosure() + " IS" + nTerm + " ?" + return strings.TrimSuffix(v, "__is") + columnEnclosure() + " IS" + nTerm + " NULL" } if strings.HasSuffix(v, "__contains") { return strings.TrimSuffix(v, "__contains") + columnEnclosure() + nTerm + " " + getLike(true) + " ?" @@ -309,10 +309,10 @@ func getQueryArg(k, v string) []interface{} { return []interface{}{strings.Split(v, ",")} } if strings.HasSuffix(k, "__is") { - if strings.ToUpper(v) == "NULL" { - return []interface{}{interface{}(nil)} - } - return []interface{}{v} + // if strings.ToUpper(v) == "NULL" { + // return []interface{}{} + // } + return []interface{}{} } if strings.HasSuffix(k, "__contains") { return []interface{}{"%" + v + "%"} From d3d63a1ec0e9ec25f7fce018420a0dc15267814c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 19 Nov 2022 20:25:09 +0400 Subject: [PATCH 008/109] BUG FIX: dAPI compatibility with the new version of gorm --- d_api_helper.go | 12 ++++++------ d_api_read.go | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index 1911d070..b84afca8 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -175,7 +175,7 @@ func getFilters(r *http.Request, params map[string]string, tableName string, sch orArgs := []interface{}{} for _, field := range schema.Fields { if field.Searchable { - // TODO: Supprt non-string types + // TODO: Support non-string types if field.TypeName == "string" { orQParts = append(orQParts, getQueryOperator(r, field.ColumnName+"__icontains", tableName)) orArgs = append(orArgs, getQueryArg(field.ColumnName+"__icontains", v)...) @@ -268,14 +268,14 @@ func getQueryOperator(r *http.Request, v string, tableName string) string { } if strings.HasSuffix(v, "__startswith") { if Database.Type == "mysql" { - return strings.TrimSuffix(v, "__startswith") + columnEnclosure() + nTerm + " " + getLike(true) + " BINARY ?" + return strings.TrimSuffix(v, "__startswith") + columnEnclosure() + nTerm + " " + getLike(true) + " ?" } else if Database.Type == "sqlite" { return strings.TrimSuffix(v, "__startswith") + columnEnclosure() + nTerm + " " + getLike(true) + " ?" } } if strings.HasSuffix(v, "__endswith") { if Database.Type == "mysql" { - return strings.TrimSuffix(v, "__endswith") + columnEnclosure() + nTerm + " " + getLike(true) + " BINARY ?" + return strings.TrimSuffix(v, "__endswith") + columnEnclosure() + nTerm + " " + getLike(true) + " ?" } else if Database.Type == "sqlite" { return strings.TrimSuffix(v, "__endswith") + columnEnclosure() + nTerm + " " + getLike(true) + " ?" } @@ -284,13 +284,13 @@ func getQueryOperator(r *http.Request, v string, tableName string) string { return strings.TrimSuffix(v, "__re") + nTerm + " REGEXP ?" } if strings.HasSuffix(v, "__icontains") { - return "UPPER(" + strings.TrimSuffix(v, "__icontains") + columnEnclosure() + ")" + nTerm + " " + getLike(false) + " ?" + return strings.TrimSuffix(v, "__icontains") + columnEnclosure() + nTerm + " " + getLike(false) + " ?" } if strings.HasSuffix(v, "__istartswith") { - return "UPPER(" + strings.TrimSuffix(v, "__istartswith") + columnEnclosure() + ")" + nTerm + " " + getLike(false) + " ?" + return strings.TrimSuffix(v, "__istartswith") + columnEnclosure() + nTerm + " " + getLike(false) + " ?" } if strings.HasSuffix(v, "__iendswith") { - return "UPPER(" + strings.TrimSuffix(v, "__iendswith") + columnEnclosure() + ")" + nTerm + " " + getLike(false) + " ?" + return strings.TrimSuffix(v, "__iendswith") + columnEnclosure() + nTerm + " " + getLike(false) + " ?" } if n { return v + columnEnclosure() + " <> ?" diff --git a/d_api_read.go b/d_api_read.go index 77faab5f..8e99c54f 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -80,6 +80,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Get filters from request q, args := getFilters(r, params, tableName, &schema) + Trail(DEBUG, "q:%s, args:%#v", q, args) // Apply List Modifier from Schema if schema.ListModifier != nil { From 3265d2fb84b7764d9b8472778138a78624bda7fe Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 20 Nov 2022 00:23:30 +0400 Subject: [PATCH 009/109] BUG FIX: dAPI read with custom schema now returns correct data types --- d_api_helper.go | 91 ++++++++++++------------------------------------- d_api_read.go | 53 +++++++--------------------- 2 files changed, 33 insertions(+), 111 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index b84afca8..2b4c226e 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -1,8 +1,6 @@ package uadmin import ( - "database/sql" - "fmt" "net/http" "net/url" "reflect" @@ -10,53 +8,6 @@ import ( "time" ) -func parseCustomDBSchema(rows *sql.Rows) interface{} { - m := []map[string]interface{}{} - - // Parse the data into an array - columns, _ := rows.Columns() - - //var current interface{} - for rows.Next() { - var vals = makeResultReceiver(len(columns)) - rows.Scan(vals...) - row := map[string]interface{}{} - for i := range columns { - row[columns[i]] = getDBValue(vals[i]) - } - m = append(m, row) - } - return m -} - -func getDBValue(p interface{}) interface{} { - i := p.(*dbScanner) - switch v := (i.Value).(type) { - case int: - return v - case int64: - return v - case int32: - return v - case int8: - return v - case uint64: - return v - case uint32: - return v - case uint8: - return v - case string: - return v - case uint: - return v - case []uint8: - return string(v) - default: - return fmt.Sprint(v) - } -} - func getURLArgs(r *http.Request) map[string]string { params := map[string]string{} @@ -111,23 +62,23 @@ func getURLArgs(r *http.Request) map[string]string { return params } -type dbScanner struct { - Value interface{} -} +// type dbScanner struct { +// Value interface{} +// } -func (d *dbScanner) Scan(src interface{}) error { - d.Value = src - return nil -} +// func (d *dbScanner) Scan(src interface{}) error { +// d.Value = src +// return nil +// } -func makeResultReceiver(length int) []interface{} { - result := make([]interface{}, 0, length) - for i := 0; i < length; i++ { - current := dbScanner{} - result = append(result, ¤t) - } - return result -} +// func makeResultReceiver(length int) []interface{} { +// result := make([]interface{}, 0, length) +// for i := 0; i < length; i++ { +// current := dbScanner{} +// result = append(result, ¤t.Value) +// } +// return result +// } func getFilters(r *http.Request, params map[string]string, tableName string, schema *ModelSchema) (query string, args []interface{}) { qParts := []string{} @@ -198,7 +149,7 @@ func getFilters(r *http.Request, params map[string]string, tableName string, sch if !strings.Contains(query, "deleted_at") { if val, ok := params["$deleted"]; ok { - if val == "0" { + if val == "0" || val == "false" { qParts = append(qParts, tableName+".deleted_at IS NULL") } } else { @@ -716,27 +667,27 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa return nil } -// APILogReader is an interface for models to control loggin their read function in dAPI +// APILogReader is an interface for models to control logging their read function in dAPI type APILogReader interface { APILogRead(*http.Request) bool } -// APILogEditor is an interface for models to control loggin their edit function in dAPI +// APILogEditor is an interface for models to control logging their edit function in dAPI type APILogEditor interface { APILogEdit(*http.Request) bool } -// APILogAdder is an interface for models to control loggin their add function in dAPI +// APILogAdder is an interface for models to control logging their add function in dAPI type APILogAdder interface { APILogAdd(*http.Request) bool } -// APILogDeleter is an interface for models to control loggin their delete function in dAPI +// APILogDeleter is an interface for models to control logging their delete function in dAPI type APILogDeleter interface { APILogDelete(*http.Request) bool } -// APILogSchemer is an interface for models to control loggin their schema function in dAPI +// APILogSchemer is an interface for models to control logging their schema function in dAPI type APILogSchemer interface { APILogSchema(*http.Request) bool } diff --git a/d_api_read.go b/d_api_read.go index 8e99c54f..e7c6386a 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -1,7 +1,6 @@ package uadmin import ( - "database/sql" "encoding/json" "net/http" "reflect" @@ -9,7 +8,6 @@ import ( ) func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { - var err error var rowsCount int64 urlParts := strings.Split(r.URL.Path, "/") @@ -80,7 +78,6 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Get filters from request q, args := getFilters(r, params, tableName, &schema) - Trail(DEBUG, "q:%s, args:%#v", q, args) // Apply List Modifier from Schema if schema.ListModifier != nil { @@ -122,31 +119,21 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { Trail(DEBUG, "%#v", args) } - var rows *sql.Rows - if !customSchema { mArray, _ := NewModelArray(modelName, true) m = mArray.Interface() - } else { - m = []map[string]interface{}{} - } + } // else { + // m = []map[string]interface{}{} + // } if Database.Type == "mysql" { db := GetDB() if !customSchema { db.Raw(SQL, args...).Scan(m) } else { - rows, err = db.Raw(SQL, args...).Rows() - if err != nil { - w.WriteHeader(500) - ReturnJSON(w, r, map[string]interface{}{ - "status": "error", - "err_msg": "Unable to execute SQL. " + err.Error(), - }) - Trail(ERROR, "SQL: %v\nARGS: %v", SQL, args) - return - } - m = parseCustomDBSchema(rows) + var rec []map[string]interface{} + db.Raw(SQL, args...).Scan(&rec) + m = rec } if a, ok := m.([]map[string]interface{}); ok { rowsCount = int64(len(a)) @@ -159,17 +146,9 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { if !customSchema { db.Raw(SQL, args...).Scan(m) } else { - rows, err = db.Raw(SQL, args...).Rows() - if err != nil { - w.WriteHeader(500) - ReturnJSON(w, r, map[string]interface{}{ - "status": "error", - "err_msg": "Unable to execute SQL. " + err.Error(), - }) - Trail(ERROR, "SQL: %v\nARGS: %v", SQL, args) - return - } - m = parseCustomDBSchema(rows) + var rec []map[string]interface{} + db.Raw(SQL, args...).Scan(&rec) + m = rec } db.Exec("PRAGMA case_sensitive_like=OFF;") db.Commit() @@ -183,17 +162,9 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { if !customSchema { db.Raw(SQL, args...).Scan(m) } else { - rows, err = db.Raw(SQL, args...).Rows() - if err != nil { - w.WriteHeader(500) - ReturnJSON(w, r, map[string]interface{}{ - "status": "error", - "err_msg": "Unable to execute SQL. " + err.Error(), - }) - Trail(ERROR, "SQL: %v\nARGS: %v", SQL, args) - return - } - m = parseCustomDBSchema(rows) + var rec []map[string]interface{} + db.Raw(SQL, args...).Scan(&rec) + m = rec } if a, ok := m.([]map[string]interface{}); ok { rowsCount = int64(len(a)) From c02cc09a175b28d479d84c8279007bb7b02f4931 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 21 Nov 2022 08:36:32 +0400 Subject: [PATCH 010/109] Add Custom JWT handler --- auth.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/auth.go b/auth.go index 76fef3e8..8156b2f1 100644 --- a/auth.go +++ b/auth.go @@ -41,6 +41,8 @@ var cachedSessions map[string]Session // per IP address var invalidAttempts = map[string]int{} +var CustomJWT func(r *http.Request, s *Session, payload map[string]interface{}) map[string]interface{} + // GenerateBase64 generates a base64 string of length length func GenerateBase64(length int) string { base := new(big.Int) @@ -154,6 +156,12 @@ func createJWT(r *http.Request, s *Session) string { if s.ExpiresOn != nil { payload["exp"] = s.ExpiresOn.Unix() } + + // Check for custom JWT handler + if CustomJWT != nil { + payload = CustomJWT(r, s, payload) + } + jHeader, _ := json.Marshal(header) jPayload, _ := json.Marshal(payload) b64Header := base64.RawURLEncoding.EncodeToString(jHeader) From 12f637facd1b603e22d9a1f070e4690383b7b496 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 21 Nov 2022 08:38:34 +0400 Subject: [PATCH 011/109] BUG FIX: dAPI bug in __in and __re modifiers --- d_api_helper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index 2b4c226e..eeac16d3 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -206,7 +206,7 @@ func getQueryOperator(r *http.Request, v string, tableName string) string { return strings.TrimSuffix(v, "__lte") + columnEnclosure() + " <= ?" } if strings.HasSuffix(v, "__in") { - return strings.TrimSuffix(v, "__in") + nTerm + columnEnclosure() + " IN (?)" + return strings.TrimSuffix(v, "__in") + columnEnclosure() + nTerm + " IN (?)" } if strings.HasSuffix(v, "__is") { return strings.TrimSuffix(v, "__is") + columnEnclosure() + " IS" + nTerm + " NULL" @@ -232,7 +232,7 @@ func getQueryOperator(r *http.Request, v string, tableName string) string { } } if strings.HasSuffix(v, "__re") { - return strings.TrimSuffix(v, "__re") + nTerm + " REGEXP ?" + return strings.TrimSuffix(v, "__re") + columnEnclosure() + nTerm + " REGEXP ?" } if strings.HasSuffix(v, "__icontains") { return strings.TrimSuffix(v, "__icontains") + columnEnclosure() + nTerm + " " + getLike(false) + " ?" From 35572f59439eb76d73c4ad7b61c99411bb6f0777 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Tue, 22 Nov 2022 03:01:59 +0000 Subject: [PATCH 012/109] fix: static/uadmin/assets/socket.io/package.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-ENGINEIO-3136336 --- static/uadmin/assets/socket.io/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/uadmin/assets/socket.io/package.json b/static/uadmin/assets/socket.io/package.json index d67bfc26..2abfa321 100644 --- a/static/uadmin/assets/socket.io/package.json +++ b/static/uadmin/assets/socket.io/package.json @@ -56,7 +56,7 @@ ], "dependencies": { "debug": "2.1.0", - "engine.io": "3.6.0", + "engine.io": "3.6.1", "has-binary-data": "0.1.3", "socket.io-adapter": "1.1.2", "socket.io-client": "3.1.3", From e64f853e1a340b2e6d836f3639c090b699b7fb5d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 22 Nov 2022 22:55:41 +0400 Subject: [PATCH 013/109] dAPI auth and open API --- approval.go | 13 + auth.go | 187 ++++++--- d_api.go | 15 + d_api_auth.go | 32 ++ d_api_change_password.go | 7 + d_api_helper.go | 2 +- d_api_login.go | 61 +++ d_api_logout.go | 7 + d_api_read.go | 4 +- d_api_reset_password.go | 7 + d_api_signup.go | 7 + get_schema.go | 7 +- global.go | 8 +- login_handler.go | 4 - main_handler.go | 1 + openapi.go | 560 ++++++++++++++++++++++++++ openapi.json | 65 --- openapi/callback.go | 3 + openapi/components.go | 14 + openapi/contact.go | 7 + openapi/discriminator.go | 6 + openapi/encoding.go | 9 + openapi/example.go | 9 + openapi/external_docs.go | 6 + openapi/generate_schema.go | 406 +++++++++++++++++++ openapi/header.go | 5 + openapi/license.go | 7 + openapi/link.go | 11 + openapi/media_type.go | 8 + openapi/oauth_flow.go | 8 + openapi/oauth_flows.go | 8 + openapi/operation.go | 16 + openapi/parameter.go | 18 + openapi/path.go | 17 + openapi/request_body.go | 8 + openapi/response.go | 9 + openapi/schema.go | 14 + openapi/schema_info.go | 11 + openapi/schema_object.go | 24 ++ openapi/security_requirment.go | 3 + openapi/security_scheme.go | 13 + openapi/server.go | 7 + openapi/server_variable.go | 7 + openapi/tag.go | 7 + openapi/x_modifier.go | 7 + openapi/xml.go | 9 + openapi_template.json | 711 +++++++++++++++++++++++++++++++++ register.go | 42 +- schema.go | 16 +- user.go | 1 + 50 files changed, 2280 insertions(+), 154 deletions(-) create mode 100644 d_api_auth.go create mode 100644 d_api_change_password.go create mode 100644 d_api_login.go create mode 100644 d_api_logout.go create mode 100644 d_api_reset_password.go create mode 100644 d_api_signup.go create mode 100644 openapi.go delete mode 100644 openapi.json create mode 100644 openapi/callback.go create mode 100644 openapi/components.go create mode 100644 openapi/contact.go create mode 100644 openapi/discriminator.go create mode 100644 openapi/encoding.go create mode 100644 openapi/example.go create mode 100644 openapi/external_docs.go create mode 100644 openapi/generate_schema.go create mode 100644 openapi/header.go create mode 100644 openapi/license.go create mode 100644 openapi/link.go create mode 100644 openapi/media_type.go create mode 100644 openapi/oauth_flow.go create mode 100644 openapi/oauth_flows.go create mode 100644 openapi/operation.go create mode 100644 openapi/parameter.go create mode 100644 openapi/path.go create mode 100644 openapi/request_body.go create mode 100644 openapi/response.go create mode 100644 openapi/schema.go create mode 100644 openapi/schema_info.go create mode 100644 openapi/schema_object.go create mode 100644 openapi/security_requirment.go create mode 100644 openapi/security_scheme.go create mode 100644 openapi/server.go create mode 100644 openapi/server_variable.go create mode 100644 openapi/tag.go create mode 100644 openapi/x_modifier.go create mode 100644 openapi/xml.go create mode 100644 openapi_template.json diff --git a/approval.go b/approval.go index 78157ff3..5ae3002c 100644 --- a/approval.go +++ b/approval.go @@ -7,6 +7,19 @@ import ( "time" ) +// ApprovalAction is a selection of approval actions +type ApprovalAction int + +// Approved is an accepted change +func (ApprovalAction) Approved() ApprovalAction { + return 1 +} + +// Rejected is a rejected change +func (ApprovalAction) Rejected() ApprovalAction { + return 2 +} + // Approval is a model that stores approval data type Approval struct { Model diff --git a/auth.go b/auth.go index 8156b2f1..8388c199 100644 --- a/auth.go +++ b/auth.go @@ -31,6 +31,16 @@ var Salt = "" // JWT secret for signing tokens var JWT = "" +// jwtIssuer is a URL to identify the application issuing JWT tokens. +// If left empty, a partial hash of JWT will be assigned. This is also +// used to identify the as JWT audience. +var JWTIssuer = "" + +// AcceptedJWTIssuers is a list of accepted JWT issuers. By default the +// local JWTIssuer is accepted. To accept other issuers, add them to +// this list +var AcceptedJWTIssuers = []string{} + // bcryptDiff var bcryptDiff = 12 @@ -57,7 +67,7 @@ func GenerateBase64(length int) string { return tempKey } -// GenerateBase32 generates a base64 string of length length +// GenerateBase32 generates a base32 string of length length func GenerateBase32(length int) string { base := new(big.Int) base.SetString("32", 10) @@ -90,21 +100,16 @@ func IsAuthenticated(r *http.Request) *Session { return nil } - s := Session{} - if CacheSessions { - s = cachedSessions[key] - } else { - Get(&s, "`key` = ?", key) - } - if isValidSession(r, &s) { - return &s + s := getSessionByKey(key) + if isValidSession(r, s) { + return s } return nil } // SetSessionCookie sets the session cookie value, The the value passed in // session is nil, then the session assigned will be a no user session -func SetSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { +func SetSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) string { if s == nil { http.SetCookie(w, &http.Cookie{ Name: "session", @@ -136,7 +141,10 @@ func SetSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { jwtCookie.Expires = *s.ExpiresOn } http.SetCookie(w, jwtCookie) + + return jwt } + return "" } func createJWT(r *http.Request, s *Session) string { @@ -152,6 +160,9 @@ func createJWT(r *http.Request, s *Session) string { } payload := map[string]interface{}{ "sub": s.User.Username, + "iat": s.LastLogin.Unix(), + "iss": JWTIssuer, + "aud": JWTIssuer, } if s.ExpiresOn != nil { payload["exp"] = s.ExpiresOn.Unix() @@ -167,7 +178,7 @@ func createJWT(r *http.Request, s *Session) string { b64Header := base64.RawURLEncoding.EncodeToString(jHeader) b64Payload := base64.RawURLEncoding.EncodeToString(jPayload) - hash := hmac.New(sha256.New, []byte(JWT)) + hash := hmac.New(sha256.New, []byte(JWT+s.Key)) hash.Write([]byte(b64Header + "." + b64Payload)) signature := hash.Sum(nil) b64Signature := base64.RawURLEncoding.EncodeToString(signature) @@ -175,8 +186,13 @@ func createJWT(r *http.Request, s *Session) string { } func isValidSession(r *http.Request, s *Session) bool { + valid, otpPending := isValidSessionOTP(r, s) + return valid && !otpPending +} + +func isValidSessionOTP(r *http.Request, s *Session) (bool, bool) { if s != nil && s.ID != 0 { - if s.Active && !s.PendingOTP && (s.ExpiresOn == nil || s.ExpiresOn.After(time.Now())) { + if s.Active && (s.ExpiresOn == nil || s.ExpiresOn.After(time.Now())) { if s.User.ID != s.UserID { Get(&s.User, "id = ?", s.UserID) } @@ -184,13 +200,13 @@ func isValidSession(r *http.Request, s *Session) bool { // Check for IP restricted session if RestrictSessionIP { ip := GetRemoteIP(r) - return ip == s.IP + return ip == s.IP, s.PendingOTP } - return true + return true, s.PendingOTP } } } - return false + return false, false } // GetUserFromRequest returns a user from a request @@ -210,16 +226,10 @@ func GetUserFromRequest(r *http.Request) *User { // getSessionFromRequest returns a session from a request func getSessionFromRequest(r *http.Request) *Session { key := getSession(r) - s := Session{} - - if CacheSessions { - s = cachedSessions[key] - } else { - Get(&s, "`key` = ?", key) - } + s := getSessionByKey(key) if s.ID != 0 { - return &s + return s } return nil } @@ -241,6 +251,7 @@ func Login(r *http.Request, username string, password string) (*Session, bool) { log.SignIn(username, log.Action.LoginDenied(), r) log.Save() }() + incrementInvalidLogins(r) return nil, false } s := user.Login(password, "") @@ -276,6 +287,29 @@ func Login(r *http.Request, username string, password string) (*Session, bool) { }() } + incrementInvalidLogins(r) + + // Record metrics + IncrementMetric("uadmin/security/invalidlogin") + return nil, false +} + +// Login2FA login using username, password and otp for users with OTPRequired = true +func Login2FA(r *http.Request, username string, password string, otpPass string) *Session { + s, otpRequired := Login(r, username, password) + if s != nil { + if otpRequired && s.User.VerifyOTP(otpPass) { + s.PendingOTP = false + s.Save() + } else if otpRequired && !s.User.VerifyOTP(otpPass) && otpPass != "" { + incrementInvalidLogins(r) + } + return s + } + return nil +} + +func incrementInvalidLogins(r *http.Request) { // Increment password attempts and check if it reached // the maximum invalid password attempts ip := GetRemoteIP(r) @@ -286,17 +320,14 @@ func Login(r *http.Request, username string, password string) (*Session, bool) { rateLimitMap[ip] = time.Now().Add(time.Duration(PasswordTimeout)*time.Minute).Unix() * RateLimit rateLimitLock.Unlock() } - - // Record metrics - IncrementMetric("uadmin/security/invalidlogin") - return nil, false } // Login2FA login using username, password and otp for users with OTPRequired = true -func Login2FA(r *http.Request, username string, password string, otpPass string) *Session { - s, otpRequired := Login(r, username, password) - if s != nil { - if otpRequired && s.User.VerifyOTP(otpPass) { +func Login2FAKey(r *http.Request, key string, otpPass string) *Session { + s := getSessionByKey(key) + valid, otpPending := isValidSessionOTP(r, s) + if valid { + if otpPending && s.User.VerifyOTP(otpPass) { s.PendingOTP = false s.Save() } @@ -562,11 +593,11 @@ func getSession(r *http.Request) string { return "" } - jHeader, err := base64.RawURLEncoding.DecodeString(jwtParts[0]) + jHeader, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[0]) if err != nil { return "" } - jPayload, err := base64.RawURLEncoding.DecodeString(jwtParts[1]) + jPayload, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[1]) if err != nil { return "" } @@ -577,34 +608,48 @@ func getSession(r *http.Request) string { return "" } - // Verify the signature - alg := "HS256" - if v, ok := header["alg"].(string); ok { - alg = v - } - if _, ok := header["typ"]; ok { - if v, ok := header["typ"].(string); !ok || v != "JWT" { - return "" - } + // Get data from payload + payload := map[string]interface{}{} + err = json.Unmarshal(jPayload, &payload) + if err != nil { + return "" } - switch alg { - case "HS256": - hash := hmac.New(sha256.New, []byte(JWT)) - hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) - token := hash.Sum(nil) - b64Token := base64.RawURLEncoding.EncodeToString(token) - if b64Token != jwtParts[2] { - return "" + + // Verify issuer + if iss, ok := payload["iss"].(string); ok { + if iss != JWTIssuer { + accepted := false + for _, fiss := range AcceptedJWTIssuers { + if fiss == iss { + accepted = true + break + } + } + if !accepted { + return "" + } } - default: - // For now, only support HMAC-SHA256 + } else { return "" } - // Get data from payload - payload := map[string]interface{}{} - err = json.Unmarshal(jPayload, &payload) - if err != nil { + // verify audience + if aud, ok := payload["aud"].(string); ok { + if aud != JWTIssuer { + return "" + } + } else if aud, ok := payload["aud"].([]string); ok { + accepted := false + for _, audItem := range aud { + if audItem == JWTIssuer { + accepted = true + break + } + } + if !accepted { + return "" + } + } else { return "" } @@ -622,6 +667,36 @@ func getSession(r *http.Request) string { } session := user.GetActiveSession() + if session == nil { + return "" + } + + // TODO: verify exp + + // Verify the signature + alg := "HS256" + if v, ok := header["alg"].(string); ok { + alg = v + } + if _, ok := header["typ"]; ok { + if v, ok := header["typ"].(string); !ok || v != "JWT" { + return "" + } + } + switch alg { + case "HS256": + // TODO: allow third party JWT signature authentication + hash := hmac.New(sha256.New, []byte(JWT+session.Key)) + hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) + token := hash.Sum(nil) + b64Token := base64.RawURLEncoding.EncodeToString(token) + if b64Token != jwtParts[2] { + return "" + } + default: + // For now, only support HMAC-SHA256 + return "" + } return session.Key } } diff --git a/d_api.go b/d_api.go index 7193391a..d7b9883d 100644 --- a/d_api.go +++ b/d_api.go @@ -117,12 +117,21 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") urlParts := strings.Split(r.URL.Path, "/") + Trail(DEBUG, "%#v", urlParts) ctx := context.WithValue(r.Context(), CKey("dAPI"), true) r = r.WithContext(ctx) // Check if there is no command and show help if r.URL.Path == "" || r.URL.Path == "/" || len(urlParts) < 2 { + if s == nil { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "access denied", + }) + return + } if urlParts[0] == "$allmodels" { dAPIAllModelsHandler(w, r, s) return @@ -131,6 +140,12 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { return } + // auth dAPI + if urlParts[0] == "auth" { + dAPIAuthHandler(w, r, s) + return + } + // sanity check // check model name modelExists := false diff --git a/d_api_auth.go b/d_api_auth.go new file mode 100644 index 00000000..cb79ded9 --- /dev/null +++ b/d_api_auth.go @@ -0,0 +1,32 @@ +package uadmin + +import ( + "net/http" + "strings" +) + +func dAPIAuthHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Trim leading path + r.URL.Path = strings.TrimPrefix(r.URL.Path, "auth") + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") + r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") + + switch r.URL.Path { + case "login": + dAPILoginHandler(w, r, s) + case "logout": + dAPILogoutHandler(w, r, s) + case "signup": + dAPISignupHandler(w, r, s) + case "resetpassword": + dAPIResetPasswordHandler(w, r, s) + case "changepassword": + dAPIChangePasswordHandler(w, r, s) + default: + w.WriteHeader(http.StatusNotFound) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Unknown auth command: (" + r.URL.Path + ")", + }) + } +} diff --git a/d_api_change_password.go b/d_api_change_password.go new file mode 100644 index 00000000..c4d00b20 --- /dev/null +++ b/d_api_change_password.go @@ -0,0 +1,7 @@ +package uadmin + +import "net/http" + +func dAPIChangePasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + +} diff --git a/d_api_helper.go b/d_api_helper.go index eeac16d3..c54ad972 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -604,7 +604,7 @@ func getQueryM2M(params map[string]string, m interface{}, customSchema bool, mod } func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interface{}, params map[string]string, command string, model interface{}) error { - if params["$stat"] == "1" { + if params["$stat"] == "1" || params["$stat"] == "true" { start := r.Context().Value(CKey("start")) if start != nil { diff --git a/d_api_login.go b/d_api_login.go new file mode 100644 index 00000000..19e5704d --- /dev/null +++ b/d_api_login.go @@ -0,0 +1,61 @@ +package uadmin + +import "net/http" + +func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { + if s != nil { + Logout(r) + } + + // Get request variables + username := r.FormValue("username") + password := r.FormValue("password") + otp := r.FormValue("otp") + session := r.FormValue("session") + + optRequired := false + if otp != "" { + // Check if there is username and password or a session key + if session != "" { + w.WriteHeader(http.StatusAccepted) + s = Login2FAKey(r, session, otp) + } else { + s = Login2FA(r, username, password, otp) + } + } else { + s, optRequired = Login(r, username, password) + } + + if optRequired { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "OTP Required", + "session": s.Key, + }) + return + } + + if s == nil { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Invalid username or password", + }) + return + } + + jwt := SetSessionCookie(w, r, s) + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + "session": s.Key, + "jwt": jwt, + "user": map[string]interface{}{ + "username": s.User.Username, + "first_name": s.User.FirstName, + "last_name": s.User.LastName, + "group_id": s.User.UserGroupID, + "admin": s.User.Admin, + }, + }) +} diff --git a/d_api_logout.go b/d_api_logout.go new file mode 100644 index 00000000..37b313ec --- /dev/null +++ b/d_api_logout.go @@ -0,0 +1,7 @@ +package uadmin + +import "net/http" + +func dAPILogoutHandler(w http.ResponseWriter, r *http.Request, s *Session) { + +} diff --git a/d_api_read.go b/d_api_read.go index e7c6386a..0f231af3 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -173,7 +173,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { } } // Preload - if params["$preload"] == "1" { + if params["$preload"] == "1" || params["$preload"] == "true" { mList := reflect.ValueOf(m) for i := 0; i < mList.Elem().Len(); i++ { Preload(mList.Elem().Index(i).Addr().Interface()) @@ -205,7 +205,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { rowsCount = 1 } - if params["$preload"] == "1" { + if params["$preload"] == "1" || params["$preload"] == "true" { Preload(m.Interface()) } diff --git a/d_api_reset_password.go b/d_api_reset_password.go new file mode 100644 index 00000000..22034da9 --- /dev/null +++ b/d_api_reset_password.go @@ -0,0 +1,7 @@ +package uadmin + +import "net/http" + +func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + +} diff --git a/d_api_signup.go b/d_api_signup.go new file mode 100644 index 00000000..c1131139 --- /dev/null +++ b/d_api_signup.go @@ -0,0 +1,7 @@ +package uadmin + +import "net/http" + +func dAPISignupHandler(w http.ResponseWriter, r *http.Request, s *Session) { + +} diff --git a/get_schema.go b/get_schema.go index b4e9e250..281003f0 100644 --- a/get_schema.go +++ b/get_schema.go @@ -36,8 +36,13 @@ func getSchema(a interface{}) (s ModelSchema, ok bool) { s.ModelName = strings.ToLower(t.Name()) s.DisplayName = getDisplayName(t.Name()) s.TableName = db.Config.NamingStrategy.TableName(s.Name) + s.Category = func() string { + dashboard := DashboardMenu{} + Get(&dashboard, "url = ?", modelName) + return dashboard.Cat + }() - // Analize the fields of the model and add them to the fields list + // Analyze the fields of the model and add them to the fields list s.Fields = []F{} // Add inlines to schema diff --git a/global.go b/global.go index 264de224..c6292a20 100644 --- a/global.go +++ b/global.go @@ -352,7 +352,7 @@ var PasswordAttempts = 5 var PasswordTimeout = 15 // AllowedHosts is a comma separated list of allowed hosts for the server to work. The -// default value if only for development and production domain should be added before +// default value is only for development. Production domain should be added before // deployment var AllowedHosts = "0.0.0.0,127.0.0.1,localhost,::1" @@ -373,6 +373,12 @@ var TimeZone = "local" // TrailCacheSize is the number of bytes to keep in memory of trail logs var TrailCacheSize = 65536 +// AllowDAPISignup allows unauthenticated users to sign up +var AllowDAPISignup = false + +// DAPISignupGroupID is the default user group id new users get when they sign up +var DAPISignupGroupID = false + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/login_handler.go b/login_handler.go index 11e389e7..d83c49e6 100644 --- a/login_handler.go +++ b/login_handler.go @@ -58,10 +58,6 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { c.ErrExists = true c.Err = "Invalid username/password or inactive user" } else { - if session.PendingOTP { - Trail(INFO, "User: %s OTP: %s", session.User.Username, session.User.GetOTP()) - } - // Set session cookie SetSessionCookie(w, r, session) diff --git a/main_handler.go b/main_handler.go index f1df78c5..e17bef0e 100644 --- a/main_handler.go +++ b/main_handler.go @@ -9,6 +9,7 @@ import ( // mainHandler is the main handler for the admin func mainHandler(w http.ResponseWriter, r *http.Request) { if !CheckRateLimit(r) { + w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("Slow down. You are going too fast!")) return } diff --git a/openapi.go b/openapi.go new file mode 100644 index 00000000..a4a1ac62 --- /dev/null +++ b/openapi.go @@ -0,0 +1,560 @@ +package uadmin + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/uadmin/uadmin/openapi" +) + +var CustomOpenAPI func(*openapi.Schema) +var CustomOpenAPIJSON func([]byte) []byte + +func GenerateOpenAPISchema() { + s := openapi.GenerateBaseSchema() + + // Customize schema + s.Info.Title = SiteName + s.Info.Title + + // Add Models to /components/schema + for _, v := range Schema { + // Parse fields + fields := map[string]*openapi.SchemaObject{} + required := []string{} + parameters := []openapi.Parameter{} + writeParameters := []openapi.Parameter{} + for i := range v.Fields { + // Determine data type + fields[v.Fields[i].Name] = func() *openapi.SchemaObject { + switch v.Fields[i].Type { + case cID: + return &openapi.SchemaObject{ + Type: "integer", + } + case cSTRING: + return &openapi.SchemaObject{ + Type: "string", + } + case cBOOL: + return &openapi.SchemaObject{ + Type: "boolean", + } + case cCODE: + return &openapi.SchemaObject{ + Type: "boolean", + } + case cDATE: + return &openapi.SchemaObject{ + Type: "string", + } + case cEMAIL: + return &openapi.SchemaObject{ + Type: "string", + } + case cFILE: + return &openapi.SchemaObject{ + Type: "string", + } + case cIMAGE: + return &openapi.SchemaObject{ + Type: "string", + } + case cFK: + return &openapi.SchemaObject{ + Type: "object", + Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Fields[i].TypeName}, + } + case cHTML: + return &openapi.SchemaObject{ + Type: "string", + } + case cLINK: + return &openapi.SchemaObject{ + Type: "string", + } + case cLIST: + return &openapi.SchemaObject{ + Type: "string", + Enum: func() []interface{} { + vals := make([]interface{}, len(v.Fields[i].Choices)) + for j := range v.Fields[i].Choices { + vals[j] = v.Fields[i].Choices[j].V + } + return vals + }(), + } + case cM2M: + return &openapi.SchemaObject{ + Type: "array", + Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Fields[i].TypeName}, + } + case cMONEY: + return &openapi.SchemaObject{ + Type: "number", + } + case cNUMBER: + switch v.Fields[i].TypeName { + case "float64": + return &openapi.SchemaObject{ + Type: "number", + } + case "int": + return &openapi.SchemaObject{ + Type: "integer", + } + default: + return &openapi.SchemaObject{ + Type: "integer", + } + } + case cMULTILINGUAL: + return &openapi.SchemaObject{ + Type: "string", + } + case cPROGRESSBAR: + switch v.Fields[i].TypeName { + case "float64": + return &openapi.SchemaObject{ + Type: "number", + } + case "int": + return &openapi.SchemaObject{ + Type: "integer", + } + default: + return &openapi.SchemaObject{ + Type: "integer", + } + } + } + // If unknown, use string + return &openapi.SchemaObject{ + Type: "string", + } + }() + + // Set other schema properties + fields[v.Fields[i].Name].Description = v.Fields[i].Help + fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue + fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName + if val, ok := v.Fields[i].Max.(string); ok && val != "" { + fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) + } + if val, ok := v.Fields[i].Min.(string); ok && val != "" { + fields[v.Fields[i].Name].Minimum, _ = strconv.Atoi(val) + } + + // Add parameters + // skip method fields + if v.Fields[i].IsMethod { + continue + } + parameters = append(parameters, func() openapi.Parameter { + if v.Fields[i].Type == cID { + return openapi.Parameter{ + Ref: "#/components/parameters/QueryID", + } + } + return openapi.Parameter{ + Name: func() string { + if v.Fields[i].Type == cFK { + return v.Fields[i].ColumnName + "_id" + } else { + return v.Fields[i].ColumnName + } + }(), + In: "query", + Description: "Query for " + v.Fields[i].DisplayName, + Schema: func() *openapi.SchemaObject { + switch v.Fields[i].Type { + case cSTRING: + case cCODE: + case cEMAIL: + case cFILE: + case cIMAGE: + case cHTML: + case cLINK: + case cMULTILINGUAL: + case cPASSWORD: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/String", + } + case cID: + case cFK: + case cLIST: + case cMONEY: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Integer", + } + case cNUMBER: + case cPROGRESSBAR: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Number", + } + case cBOOL: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Boolean", + } + case cDATE: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/DateTime", + } + } + return &openapi.SchemaObject{ + Ref: "#/components/schemas/String", + } + }(), + } + }(), + ) + + if v.Fields[i].Type == cID { + continue + } + + writeParameters = append(writeParameters, func() openapi.Parameter { + return openapi.Parameter{ + Name: func() string { + if v.Fields[i].Type == cFK { + return "_" + v.Fields[i].ColumnName + "_id" + } else { + return "_" + v.Fields[i].ColumnName + } + }(), + In: "query", + Description: "Set value for " + v.Fields[i].DisplayName, + Schema: func() *openapi.SchemaObject { + switch v.Fields[i].Type { + case cSTRING: + case cCODE: + case cEMAIL: + case cFILE: + case cIMAGE: + case cHTML: + case cLINK: + case cMULTILINGUAL: + case cPASSWORD: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/String", + } + case cID: + case cFK: + case cLIST: + case cMONEY: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Integer", + } + case cNUMBER: + case cPROGRESSBAR: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Number", + } + case cBOOL: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/Boolean", + } + case cDATE: + return &openapi.SchemaObject{ + Ref: "#/components/schemas/DateTime", + } + } + return &openapi.SchemaObject{ + Ref: "#/components/schemas/String", + } + }(), + } + }(), + ) + + // Add required fields + if v.Fields[i].Required { + required = append(required, v.Fields[i].Name) + } + } + + // Add dAPI paths + // Read one + s.Paths[fmt.Sprintf("/api/d/%s/read/{id}", v.ModelName)] = openapi.Path{ + Summary: "Read one " + v.DisplayName, + Description: "Read one " + v.DisplayName, + Get: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Post: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/deleted", + }, + { + Ref: "#/components/parameters/m2m", + }, + { + Ref: "#/components/parameters/preload", + }, + { + Ref: "#/components/parameters/stat", + }, + }, + } + // Read Many + s.Paths[fmt.Sprintf("/api/d/%s/read", v.ModelName)] = openapi.Path{ + Summary: "Read one " + v.DisplayName, + Description: "Read one " + v.DisplayName, + Get: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Post: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Parameters: append(parameters, []openapi.Parameter{ + { + Ref: "#/components/parameters/limit", + }, + { + Ref: "#/components/parameters/offset", + }, + { + Ref: "#/components/parameters/order", + }, + { + Ref: "#/components/parameters/fields", + }, + { + Ref: "#/components/parameters/groupBy", + }, + { + Ref: "#/components/parameters/deleted", + }, + { + Ref: "#/components/parameters/join", + }, + { + Ref: "#/components/parameters/m2m", + }, + { + Ref: "#/components/parameters/q", + }, + { + Ref: "#/components/parameters/stat", + }, + { + Ref: "#/components/parameters/or", + }, + }...), + } + // Add One + s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ + Summary: "Add one " + v.DisplayName, + Description: "Add one " + v.DisplayName, + Get: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Post: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Parameters: append(writeParameters, []openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }...), + } + // Add One + s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ + Summary: "Add one " + v.DisplayName, + Description: "Add one " + v.DisplayName, + Get: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Post: &openapi.Operation{ + Tags: []string{v.Name, func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Ref: "#/components/schemas/" + v.Name, + }, + }, + }, + }, + }, + }, + Parameters: append(writeParameters, []openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }...), + } + + s.Components.Schemas[v.Name] = openapi.SchemaObject{ + Type: "object", + Properties: fields, + Required: required, + } + } + + // run custom OpenAPI handler + if CustomOpenAPI != nil { + CustomOpenAPI(s) + } + + buf := getOpenAPIJSON(s) + os.WriteFile("./openapi.json", buf, 0644) +} + +func getOpenAPIJSON(s *openapi.Schema) []byte { + buf, err := json.Marshal(*s) + if err != nil { + return nil + } + if CustomOpenAPIJSON != nil { + buf = CustomOpenAPIJSON(buf) + } + return buf +} diff --git a/openapi.json b/openapi.json deleted file mode 100644 index bf085f70..00000000 --- a/openapi.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "dAPI Overview", - "description": "dAPI Overview", - "version": "v3.0.0" - }, - "paths": { - "/api/d/user/read/{id}": { - "summary": "dAPI read one user", - "description": "returns an object for a single user that has the primary key `{id}`", - "get": { - "operationId": "readOneUser", - "responses": { - "200": { - "description": "Valid response", - "examples": { - "application/json": "" - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "description": "primary key of the record", - "required": true - } - ] - }, - "/api/d/user/read": { - "summary": "dAPI read multiple users", - "description": "returns an array of objects for a multiple users", - "get": { - "operationId": "readOneUser", - "responses": { - "200": { - "description": "Valid response", - "examples": { - "application/json": "" - } - } - } - }, - "parameters": [ - { - "name": "id", - "in": "query", - "description": "primary key of the record", - "required": true - }, - { - "name": "username", - "in": "query", - "description": "user name, can be appended with `__contains`, `__startswith`, `__endswith`, `__re`, `__icontains`, `__istartswith`, `__iendswith` or prepended with `!` to negate the condition", - "required": true - } - ] - } - }, - "consumes": [ - "application/json" - ] - } \ No newline at end of file diff --git a/openapi/callback.go b/openapi/callback.go new file mode 100644 index 00000000..9ffb812b --- /dev/null +++ b/openapi/callback.go @@ -0,0 +1,3 @@ +package openapi + +type Callback map[string]Path diff --git a/openapi/components.go b/openapi/components.go new file mode 100644 index 00000000..c7a3e183 --- /dev/null +++ b/openapi/components.go @@ -0,0 +1,14 @@ +package openapi + +type Components struct { + Schemas map[string]SchemaObject `json:"schemas,omitempty"` + Responses map[string]Response `json:"responses,omitempty"` + Parameters map[string]Parameter `json:"parameters,omitempty"` + Examples map[string]Example `json:"examples,omitempty"` + RequestBodies map[string]RequestBody `json:"requestBodies,omitempty"` + Headers map[string]Header `json:"headers,omitempty"` + SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty"` + Links map[string]Link `json:"links,omitempty"` + Callbacks map[string]Callback `json:"callbacks,omitempty"` + PathItems map[string]Path `json:"pathItems,omitempty"` +} diff --git a/openapi/contact.go b/openapi/contact.go new file mode 100644 index 00000000..3b643e20 --- /dev/null +++ b/openapi/contact.go @@ -0,0 +1,7 @@ +package openapi + +type Contact struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Email string `json:"email,omitempty"` +} diff --git a/openapi/discriminator.go b/openapi/discriminator.go new file mode 100644 index 00000000..867a5b45 --- /dev/null +++ b/openapi/discriminator.go @@ -0,0 +1,6 @@ +package openapi + +type Discriminator struct { + PropertyName string `json:"propertyName,omitempty"` + Mapping map[string]string `json:"mapping,omitempty"` +} diff --git a/openapi/encoding.go b/openapi/encoding.go new file mode 100644 index 00000000..1b6d13c3 --- /dev/null +++ b/openapi/encoding.go @@ -0,0 +1,9 @@ +package openapi + +type Encoding struct { + ContentType string `json:"contentType,omitempty"` + Headers map[string]Header `json:"headers,omitempty"` + Style string `json:"style,omitempty"` + Explode bool `json:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty"` +} diff --git a/openapi/example.go b/openapi/example.go new file mode 100644 index 00000000..366f6e30 --- /dev/null +++ b/openapi/example.go @@ -0,0 +1,9 @@ +package openapi + +type Example struct { + Ref string `json:"$ref,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Value interface{} `json:"value,omitempty"` + ExternalValue string `json:"externalValue,omitempty"` +} diff --git a/openapi/external_docs.go b/openapi/external_docs.go new file mode 100644 index 00000000..56956dce --- /dev/null +++ b/openapi/external_docs.go @@ -0,0 +1,6 @@ +package openapi + +type ExternalDocs struct { + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` +} diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go new file mode 100644 index 00000000..abe8be48 --- /dev/null +++ b/openapi/generate_schema.go @@ -0,0 +1,406 @@ +package openapi + +func GenerateBaseSchema() *Schema { + s := &Schema{ + OpenAPI: "3.0.0", + Info: &SchemaInfo{ + Title: " API documentation", + Description: "API documentation", + Version: "1.0.0", + }, + Components: &Components{ + Schemas: map[string]SchemaObject{ + "Integer": { + Type: "integer", + XFilters: []XModifier{ + {Modifier: "__gt", In: "suffix", Summary: "Greater than"}, + {Modifier: "__gte", In: "suffix", Summary: "Greater than or equal to"}, + {Modifier: "__lt", In: "suffix", Summary: "Less than"}, + {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, + {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, + {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, + {Modifier: "!", In: "", Summary: "Negates operator"}, + }, + XAggregator: []XModifier{ + {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, + {Modifier: "__avg", In: "suffix", Summary: "Returns the average value of a numeric field"}, + {Modifier: "__min", In: "suffix", Summary: "Returns the smallest value of a numeric field"}, + {Modifier: "__max", In: "suffix", Summary: "Returns the largest value of a numeric field"}, + {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, + }, + }, + "Number": { + Type: "number", + XFilters: []XModifier{ + {Modifier: "__gt", In: "suffix", Summary: "Greater than"}, + {Modifier: "__gte", In: "suffix", Summary: "Greater than or equal to"}, + {Modifier: "__lt", In: "suffix", Summary: "Less than"}, + {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, + {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, + {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, + {Modifier: "!", In: "", Summary: "Negates operator"}, + }, + XAggregator: []XModifier{ + {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, + {Modifier: "__avg", In: "suffix", Summary: "Returns the average value of a numeric field"}, + {Modifier: "__min", In: "suffix", Summary: "Returns the smallest value of a numeric field"}, + {Modifier: "__max", In: "suffix", Summary: "Returns the largest value of a numeric field"}, + {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, + }, + }, + "String": { + Type: "string", + XFilters: []XModifier{ + {Modifier: "__contains", In: "suffix", Summary: "Search for string values that contains"}, + {Modifier: "__startswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, + {Modifier: "__endswith", In: "suffix", Summary: "Search for string values that ends with a given substring"}, + {Modifier: "__re", In: "suffix", Summary: "Search for string values that matches regular expression"}, + {Modifier: "__icontains", In: "suffix", Summary: "Search for string values that contains"}, + {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, + {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, + {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, + {Modifier: "!", In: "", Summary: "Negates operator"}, + }, + XAggregator: []XModifier{ + {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, + }, + }, + "DateTime": { + Type: "string", + XFilters: []XModifier{ + {Modifier: "__contains", In: "suffix", Summary: "Search for string values that contains"}, + {Modifier: "__startswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, + {Modifier: "__endswith", In: "suffix", Summary: "Search for string values that ends with a given substring"}, + {Modifier: "__re", In: "suffix", Summary: "Search for string values that matches regular expression"}, + {Modifier: "__icontains", In: "suffix", Summary: "Search for string values that contains"}, + {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, + {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, + {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, + {Modifier: "!", In: "", Summary: "Negates operator"}, + }, + XAggregator: []XModifier{ + {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, + {Modifier: "__date", In: "suffix", Summary: "Returns DATE() of the field"}, + {Modifier: "__year", In: "suffix", Summary: "Returns YEAR() of the field"}, + {Modifier: "__month", In: "suffix", Summary: "Returns MONTH() of the field"}, + {Modifier: "__day", In: "suffix", Summary: "Returns DAY() of the field"}, + }, + }, + "Boolean": { + Type: "boolean", + XAggregator: []XModifier{ + {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, + }, + }, + "GeneralError": { + Type: "object", + Properties: map[string]*SchemaObject{ + "status": { + Type: "string", + }, + "err_msg": { + Type: "string", + }, + }, + }, + }, + Parameters: map[string]Parameter{ + "PathID": { + Name: "id", + In: "path", + Description: "Primary key of the record", + Required: true, + Schema: &SchemaObject{ + Type: "integer", + }, + }, + "QueryID": { + Name: "id", + In: "query", + Description: "Primary key of the record", + Required: false, + Schema: &SchemaObject{ + Ref: "#/components/schemas/Integer", + }, + }, + "CSRF": { + Name: "x-csrf-token", + In: "query", + Description: "Token for CSRF protection which should be set to the session token or JWT token", + Required: true, + Schema: &SchemaObject{ + Type: "string", + }, + }, + "limit": { + Name: "$limit", + In: "query", + Description: "Maximum number of records to return", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "integer", + }, + }, + "offset": { + Name: "$offset", + In: "query", + Description: "Starting point to read in the list of records", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "integer", + }, + }, + "order": { + Name: "$order", + In: "query", + Description: "Sort the results. Use '-' for descending order and comma for more field", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "array", + Items: &SchemaObject{ + Type: "string", + }, + }, + Examples: map[string]Example{ + "multiColumn": { + Summary: "An example multi-column sorting with ascending and descending", + Value: "$order=id,-name", + }, + }, + }, + "fields": { + Name: "$f", + In: "query", + Description: "Selecting fields to return in results", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "array", + Items: &SchemaObject{ + Type: "string", + }, + }, + Examples: map[string]Example{ + "multiColumn": { + Summary: "An example multi-column selection", + Value: "$f=id,name", + }, + "aggColumn": { + Summary: "An example multi-column selection with aggregation function", + Value: "$f=id__count,score__sum", + }, + "joinTable": { + Summary: "An example of multi-column selection from a different table using a join (see $join)", + Value: "$f=username,user_groups.group_name", + }, + }, + }, + "groupBy": { + Name: "$groupby", + In: "query", + Description: "Groups rows that have the same values into summary rows", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "array", + Items: &SchemaObject{ + Type: "string", + }, + }, + Examples: map[string]Example{ + "simple": { + Summary: "An example of grouping results based on category", + Value: "$groupby=category_id", + }, + "agg": { + Summary: "An example of grouping results based on year and month", + Value: "$groupby=date__year,date__month", + }, + }, + }, + "deleted": { + Name: "$deleted", + In: "query", + Description: "Returns results including deleted records", + Required: false, + AllowReserved: true, + AllowEmptyValue: true, + Schema: &SchemaObject{ + Type: "string", + }, + Examples: map[string]Example{ + "getDeleted": { + Summary: "An example of a query that returns deleted records", + Value: "$deleted=true", + }, + }, + }, + "join": { + Name: "$join", + In: "query", + Description: "Joins results from another model based on a foreign key", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "array", + Items: &SchemaObject{ + Type: "string", + }, + }, + Examples: map[string]Example{ + "getGroupName": { + Summary: "An example of a query with a left join from users to user_groups", + Value: "$join=user_groups__left__user_group_id", + }, + "getGroupNameInner": { + Summary: "An example of a query with a inner join from users to user_groups", + Value: "$join=user_groups__user_group_id", + }, + }, + }, + "m2m": { + Name: "$m2m", + In: "query", + Description: "Returns results from M2M fields", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "string", + Description: "0=don't get, 1/fill=get full records, id=get ids only", + }, + Examples: map[string]Example{ + "fillAll": { + Summary: "An example of a query that fills all m2m records", + Value: "$m2m=fill", + }, + "fillOne": { + Summary: "An example of a query that fills IDs from s specific m2m field called cards", + Value: "$m2m=cards__id", + }, + }, + }, + "q": { + Name: "$q", + In: "query", + Description: "Searches all string fields marked as Searchable", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "string", + }, + }, + "preload": { + Name: "$preload", + In: "query", + Description: "Fills the data from foreign keys", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "string", + }, + Examples: map[string]Example{ + "getDeleted": { + Summary: "An example of a query that fills foreign key object", + Value: "$preload=true", + }, + }, + }, + "next": { + Name: "$next", + In: "query", + Description: "Used in operation `method` to redirect the user to the specified path after the request. Value of `$back` will return the user back to the page", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "string", + }, + }, + "stat": { + Name: "$stat", + In: "query", + Description: "Returns the API call execution time in milliseconds", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "string", + }, + Examples: map[string]Example{ + "getDeleted": { + Summary: "An example of a query that measures the execution time", + Value: "$stat=1", + }, + }, + }, + "or": { + Name: "$or", + In: "query", + Description: "OR operator with multiple queries in the format of field=value. This `|` is used to separate the query parts and `+` is used for nested `AND` inside the the `OR` statement.", + Required: false, + AllowReserved: true, + Schema: &SchemaObject{ + Type: "array", + Items: &SchemaObject{ + Type: "string", + }, + }, + Examples: map[string]Example{ + "simple": { + Summary: "An example of a query that returns records with active=1 or admin=1", + Value: "$or=active=1|admin=1", + }, + "multiValueOr": { + Summary: "An example of a query that returns records where the name starts with the letter a or ends with the letter a", + Value: "$or=name__startswith=a|name__endswith=a", + }, + "nestedAnd": { + Summary: "An example of a query that returns records with admin=1 or (active=1 and username=john)", + Value: "$or=admin=1|active=1+username=john", + }, + }, + }, + }, + Responses: map[string]Response{ + "401": { + Description: "Access denied due to missing authentication or insufficient permissions", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Ref: "#/components/schemas/GeneralError", + }, + }, + }, + }, + }, + SecuritySchemes: map[string]SecurityScheme{ + "apiKeyCookie": { + Type: "apiKey", + Name: "session", + In: "cookie", + }, + "apiKeyQuery": { + Type: "apiKey", + Name: "session", + In: "query", + }, + "JWT": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + }, + }, + }, + Paths: map[string]Path{}, + Security: []SecurityRequirement{ + { + "apiKeyCookie": []string{}, + "apiKeyQuery": []string{}, + "JWT": []string{}, + }, + }, + } + + return s +} diff --git a/openapi/header.go b/openapi/header.go new file mode 100644 index 00000000..98d4caab --- /dev/null +++ b/openapi/header.go @@ -0,0 +1,5 @@ +package openapi + +type Header struct { + Parameter +} diff --git a/openapi/license.go b/openapi/license.go new file mode 100644 index 00000000..a0179f4c --- /dev/null +++ b/openapi/license.go @@ -0,0 +1,7 @@ +package openapi + +type License struct { + Name string `json:"name,omitempty"` + Identifier string `json:"identifier,omitempty"` + URL string `json:"url,omitempty"` +} diff --git a/openapi/link.go b/openapi/link.go new file mode 100644 index 00000000..0af33ddb --- /dev/null +++ b/openapi/link.go @@ -0,0 +1,11 @@ +package openapi + +type Link struct { + Ref string `json:"$ref,omitempty"` + OperationRef string `json:"operationRef,omitempty"` + OperationId string `json:"operationId,omitempty"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + RequestBody map[string]interface{} `json:"requestBody,omitempty"` + Description string `json:"description,omitempty"` + Server *Server `json:"server,omitempty"` +} diff --git a/openapi/media_type.go b/openapi/media_type.go new file mode 100644 index 00000000..32194e9c --- /dev/null +++ b/openapi/media_type.go @@ -0,0 +1,8 @@ +package openapi + +type MediaType struct { + Schema *SchemaObject `json:"schema,omitempty"` + Example *Example `json:"example,omitempty"` + Examples map[string]Example `json:"examples,omitempty"` + Encoding map[string]Encoding `json:"encoding,omitempty"` +} diff --git a/openapi/oauth_flow.go b/openapi/oauth_flow.go new file mode 100644 index 00000000..15570885 --- /dev/null +++ b/openapi/oauth_flow.go @@ -0,0 +1,8 @@ +package openapi + +type OAuthFlow struct { + AuthorizationUrl string `json:"authorizationUrl,omitempty"` + TokenUrl string `json:"tokenUrl,omitempty"` + RefreshUrl string `json:"refreshUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty"` +} diff --git a/openapi/oauth_flows.go b/openapi/oauth_flows.go new file mode 100644 index 00000000..b35e6e6b --- /dev/null +++ b/openapi/oauth_flows.go @@ -0,0 +1,8 @@ +package openapi + +type OAuthFlows struct { + Implicit *OAuthFlow `json:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty"` +} diff --git a/openapi/operation.go b/openapi/operation.go new file mode 100644 index 00000000..3322ff70 --- /dev/null +++ b/openapi/operation.go @@ -0,0 +1,16 @@ +package openapi + +type Operation struct { + Tags []string `json:"tags,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + OperationId string `json:"operationId,omitempty"` + Parameters []Parameter `json:"parameters,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty"` + Responses map[string]Response `json:"responses,omitempty"` + Callbacks map[string]Callback `json:"callbacks,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Security []SecurityRequirement `json:"security,omitempty"` + Servers []Server `json:"servers,omitempty"` +} diff --git a/openapi/parameter.go b/openapi/parameter.go new file mode 100644 index 00000000..7b8376ef --- /dev/null +++ b/openapi/parameter.go @@ -0,0 +1,18 @@ +package openapi + +type Parameter struct { + Ref string `json:"$ref,omitempty"` + Name string `json:"name,omitempty"` + In string `json:"in,omitempty"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + AllowEmptyValue bool `json:"allowEmptyValue,omitempty"` + Style string `json:"style,omitempty"` + Explode bool `json:"explode,omitempty"` + AllowReserved bool `json:"allowReserved,omitempty"` + Schema *SchemaObject `json:"schema,omitempty"` + Example *Example `json:"example,omitempty"` + Examples map[string]Example `json:"examples,omitempty"` + Content map[string]MediaType `json:"content,omitempty"` +} diff --git a/openapi/path.go b/openapi/path.go new file mode 100644 index 00000000..ad910d1a --- /dev/null +++ b/openapi/path.go @@ -0,0 +1,17 @@ +package openapi + +type Path struct { + Ref string `json:"$ref,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Get *Operation `json:"get,omitempty"` + Put *Operation `json:"put,omitempty"` + Post *Operation `json:"post,omitempty"` + Delete *Operation `json:"delete,omitempty"` + Options *Operation `json:"options,omitempty"` + Head *Operation `json:"head,omitempty"` + Patch *Operation `json:"patch,omitempty"` + Trace *Operation `json:"trace,omitempty"` + Servers []Server `json:"servers,omitempty"` + Parameters []Parameter `json:"parameters,omitempty"` +} diff --git a/openapi/request_body.go b/openapi/request_body.go new file mode 100644 index 00000000..1bef0eed --- /dev/null +++ b/openapi/request_body.go @@ -0,0 +1,8 @@ +package openapi + +type RequestBody struct { + Ref string `json:"$ref,omitempty"` + Description string `json:"description,omitempty"` + Content map[string]MediaType `json:"content,omitempty"` + Required bool `json:"required,omitempty"` +} diff --git a/openapi/response.go b/openapi/response.go new file mode 100644 index 00000000..ed28cbce --- /dev/null +++ b/openapi/response.go @@ -0,0 +1,9 @@ +package openapi + +type Response struct { + Ref string `json:"$ref,omitempty"` + Description string `json:"description,omitempty"` + Headers map[string]Header `json:"headers,omitempty"` + Content map[string]MediaType `json:"content,omitempty"` + Links map[string]Link `json:"links,omitempty"` +} diff --git a/openapi/schema.go b/openapi/schema.go new file mode 100644 index 00000000..3e98ce92 --- /dev/null +++ b/openapi/schema.go @@ -0,0 +1,14 @@ +package openapi + +type Schema struct { + OpenAPI string `json:"openapi,omitempty"` + Info *SchemaInfo `json:"info,omitempty"` + JSONSchemaDialect string `json:"jsonSchemaDialect,omitempty"` + Servers []Server `json:"servers,omitempty"` + Paths map[string]Path `json:"paths,omitempty"` + Webhooks map[string]Path `json:"webhooks,omitempty"` + Components *Components `json:"components,omitempty"` + Security []SecurityRequirement `json:"security,omitempty"` + Tags []Tag `json:"tags,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` +} diff --git a/openapi/schema_info.go b/openapi/schema_info.go new file mode 100644 index 00000000..f4dbfe35 --- /dev/null +++ b/openapi/schema_info.go @@ -0,0 +1,11 @@ +package openapi + +type SchemaInfo struct { + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + TermsOfService string `json:"termsOfService,omitempty"` + Contact *Contact `json:"contact,omitempty"` + License *License `json:"license,omitempty"` + Version string `json:"version,omitempty"` +} diff --git a/openapi/schema_object.go b/openapi/schema_object.go new file mode 100644 index 00000000..aac02a07 --- /dev/null +++ b/openapi/schema_object.go @@ -0,0 +1,24 @@ +package openapi + +type SchemaObject struct { + Ref string `json:"$ref,omitempty"` + Type string `json:"type,omitempty"` + Pattern string `json:"pattern,omitempty"` + Maximum int `json:"maximum,omitempty"` + Minimum int `json:"minimum,omitempty"` + Required []string `json:"required,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Default string `json:"default,omitempty"` + ReadOnly bool `json:"ReadOnly,omitempty"` + Examples []Example `json:"examples,omitempty"` + Items *SchemaObject `json:"items,omitempty"` + Properties map[string]*SchemaObject `json:"properties,omitempty"` + Discriminator *Discriminator `json:"discriminator,omitempty"` + XML *XML `json:"xml,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` + Example *Example `json:"example,omitempty"` + Enum []interface{} `json:"enum,omitempty"` + XFilters []XModifier `json:"x-filter,omitempty"` + XAggregator []XModifier `json:"x-aggregator,omitempty"` +} diff --git a/openapi/security_requirment.go b/openapi/security_requirment.go new file mode 100644 index 00000000..d13fa29b --- /dev/null +++ b/openapi/security_requirment.go @@ -0,0 +1,3 @@ +package openapi + +type SecurityRequirement map[string][]string diff --git a/openapi/security_scheme.go b/openapi/security_scheme.go new file mode 100644 index 00000000..ec7073a7 --- /dev/null +++ b/openapi/security_scheme.go @@ -0,0 +1,13 @@ +package openapi + +type SecurityScheme struct { + Ref string `json:"$ref,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + In string `json:"in,omitempty"` + Scheme string `json:"scheme,omitempty"` + BearerFormat string `json:"bearerFormat,omitempty"` + Flows *OAuthFlows `json:"flows,omitempty"` + OpenIdConnectUrl string `json:"openIdConnectUrl,omitempty"` +} diff --git a/openapi/server.go b/openapi/server.go new file mode 100644 index 00000000..1f1f9ee2 --- /dev/null +++ b/openapi/server.go @@ -0,0 +1,7 @@ +package openapi + +type Server struct { + URL string `json:"url,omitempty"` + Description string `json:"description,omitempty"` + Variables map[string]OpenAPIServerVariable `json:"variables,omitempty"` +} diff --git a/openapi/server_variable.go b/openapi/server_variable.go new file mode 100644 index 00000000..c9010333 --- /dev/null +++ b/openapi/server_variable.go @@ -0,0 +1,7 @@ +package openapi + +type OpenAPIServerVariable struct { + Enum []string `json:"enum,omitempty"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` +} diff --git a/openapi/tag.go b/openapi/tag.go new file mode 100644 index 00000000..832ee67d --- /dev/null +++ b/openapi/tag.go @@ -0,0 +1,7 @@ +package openapi + +type Tag struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` +} diff --git a/openapi/x_modifier.go b/openapi/x_modifier.go new file mode 100644 index 00000000..c29fc779 --- /dev/null +++ b/openapi/x_modifier.go @@ -0,0 +1,7 @@ +package openapi + +type XModifier struct { + Modifier string `json:"modifier,omitempty"` + In string `json:"in,omitempty"` + Summary string `json:"summary,omitempty"` +} diff --git a/openapi/xml.go b/openapi/xml.go new file mode 100644 index 00000000..c96bca8e --- /dev/null +++ b/openapi/xml.go @@ -0,0 +1,9 @@ +package openapi + +type XML struct { + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Prefix string `json:"prefix,omitempty"` + Attribute bool `json:"attribute,omitempty"` + Wrapped bool `json:"wrapped,omitempty"` +} diff --git a/openapi_template.json b/openapi_template.json new file mode 100644 index 00000000..c2653f66 --- /dev/null +++ b/openapi_template.json @@ -0,0 +1,711 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "dAPI documentation", + "description": "dAPI documentation", + "version": "v1.0.0" + }, + "components": { + "schemas": { + "Integer": { + "type": "integer", + "x-parameter-modifiers": [ + { + "modifier": "__gt", + "in": "suffix", + "description": "Greater than", + "type": "filter" + }, + { + "modifier": "__gte", + "in": "suffix", + "description": "Greater than or equal to", + "type": "filter" + }, + { + "modifier": "__lt", + "in": "suffix", + "description": "Less than", + "type": "filter" + }, + { + "modifier": "__lte", + "in": "suffix", + "description": "Less than or equal to", + "type": "filter" + }, + { + "modifier": "__in", + "in": "suffix", + "description": "Find a value matching any of these values", + "type": "filter" + }, + { + "modifier": "__between", + "in": "suffix", + "description": "Selects values within a given range", + "type": "filter" + }, + { + "modifier": "!", + "in": "prefix", + "description": "Negates the operator", + "type": "filter" + }, + { + "modifier": "__sum", + "in": "suffix", + "description": "Returns the total sum of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__avg", + "in": "suffix", + "description": "Returns the average value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__min", + "in": "suffix", + "description": "Returns the smallest value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__max", + "in": "suffix", + "description": "Returns the largest value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__count", + "in": "suffix", + "description": "Returns the number of rows", + "type": "aggregator" + } + ] + }, + "Float": { + "type": "number", + "x-parameter-modifiers": [ + { + "modifier": "__gt", + "in": "suffix", + "description": "Greater than", + "type": "filter" + }, + { + "modifier": "__gte", + "in": "suffix", + "description": "Greater than or equal to", + "type": "filter" + }, + { + "modifier": "__lt", + "in": "suffix", + "description": "Less than", + "type": "filter" + }, + { + "modifier": "__lte", + "in": "suffix", + "description": "Less than or equal to", + "type": "filter" + }, + { + "modifier": "__in", + "in": "suffix", + "description": "Find a value matching any of these values", + "type": "filter" + }, + { + "modifier": "__between", + "in": "suffix", + "description": "Selects values within a given range", + "type": "filter" + }, + { + "modifier": "!", + "in": "prefix", + "description": "Negates the operator", + "type": "filter" + }, + { + "modifier": "__sum", + "in": "suffix", + "description": "Returns the total sum of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__avg", + "in": "suffix", + "description": "Returns the average value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__min", + "in": "suffix", + "description": "Returns the smallest value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__max", + "in": "suffix", + "description": "Returns the largest value of a numeric field", + "type": "aggregator" + }, + { + "modifier": "__count", + "in": "suffix", + "description": "Returns the number of rows", + "type": "aggregator" + } + ] + }, + "String": { + "type": "string", + "x-parameter-modifiers": [ + { + "modifier": "__contains", + "in": "suffix", + "description": "Search for string values that contains", + "type": "filter" + }, + { + "modifier": "__startswith", + "in": "suffix", + "description": "Search for string values that starts with a given substring", + "type": "filter" + }, + { + "modifier": "__endswith", + "in": "suffix", + "description": "Search for string values that ends with a given substring", + "type": "filter" + }, + { + "modifier": "__re", + "in": "suffix", + "description": "Search for string values that matches regular expression", + "type": "filter" + }, + { + "modifier": "__icontains", + "in": "suffix", + "description": "Search for string values that contains", + "type": "filter" + }, + { + "modifier": "__istartswith", + "in": "suffix", + "description": "Search for string values that starts with a given substring", + "type": "filter" + }, + { + "modifier": "__iendswith", + "in": "suffix", + "description": "Search for string values that ends with a given substring", + "type": "filter" + }, + { + "modifier": "__in", + "in": "suffix", + "description": "Find a value matching any of these values", + "type": "filter" + }, + { + "modifier": "!", + "in": "prefix", + "description": "Negates operator", + "type": "filter" + }, + { + "modifier": "__count", + "in": "suffix", + "description": "Returns the number of rows", + "type": "aggregator" + } + ] + }, + "DateTime": { + "type": "string", + "x-parameter-modifiers": [ + { + "modifier": "__contains", + "in": "suffix", + "description": "Search for string values that contains", + "type": "filter" + }, + { + "modifier": "__startswith", + "in": "suffix", + "description": "Search for string values that starts with a given substring", + "type": "filter" + }, + { + "modifier": "__endswith", + "in": "suffix", + "description": "Search for string values that ends with a given substring", + "type": "filter" + }, + { + "modifier": "__re", + "in": "suffix", + "description": "Search for string values that matches regular expression", + "type": "filter" + }, + { + "modifier": "__icontains", + "in": "suffix", + "description": "Search for string values that contains", + "type": "filter" + }, + { + "modifier": "__istartswith", + "in": "suffix", + "description": "Search for string values that starts with a given substring", + "type": "filter" + }, + { + "modifier": "__iendswith", + "in": "suffix", + "description": "Search for string values that ends with a given substring", + "type": "filter" + }, + { + "modifier": "__in", + "in": "suffix", + "description": "Find a value matching any of these values", + "type": "filter" + }, + { + "modifier": "!", + "in": "prefix", + "description": "Negates the operator", + "type": "filter" + }, + { + "modifier": "__count", + "in": "suffix", + "description": "Returns the number of rows", + "type": "aggregator" + }, + { + "modifier": "__date", + "in": "suffix", + "description": "Returns DATE() of the field", + "type": "aggregator" + }, + { + "modifier": "__year", + "in": "suffix", + "description": "Returns YEAR() of the field", + "type": "aggregator" + }, + { + "modifier": "__month", + "in": "suffix", + "description": "Returns MONTH() of the field", + "type": "aggregator" + }, + { + "modifier": "__day", + "in": "suffix", + "description": "Returns DAY() of the field", + "type": "aggregator" + } + ] + } + }, + "parameters": { + "pathID": { + "name": "id", + "in": "path", + "description": "primary key of the record", + "required": true, + "schema": { + "type": "integer" + } + }, + "queryID": { + "name": "id", + "in": "query", + "description": "primary key of the record", + "required": false, + "schema": { + "$rf": "#components/schemas/Integer" + } + }, + "model": { + "name": "model", + "in": "path", + "description": "name of a model registered in the system", + "required": true, + "schema": { + "type": "string" + } + }, + "limit": { + "name": "$limit", + "in": "query", + "description": "Maximum number of records to return", + "required": false, + "allowReserved": true, + "schema": { + "type": "integer" + } + }, + "offset": { + "name": "$offset", + "in": "query", + "description": "Starting point to read in the list of records", + "required": false, + "allowReserved": true, + "schema": { + "type": "integer" + } + }, + "order": { + "name": "$order", + "in": "query", + "description": "Sort the results. Use '-' for descending order and comma for more field", + "required": false, + "allowReserved": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "examples": { + "multiColumn": { + "summary": "An example multi-column sorting with ascending and descending", + "value": "$order=id,-name" + } + } + }, + "fields": { + "name": "$f", + "in": "query", + "description": "Selecting fields to return in results", + "required": false, + "allowReserved": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "examples": { + "multiColumn": { + "summary": "An example multi-column selection", + "value": "$f=id,name" + }, + "aggColumn": { + "summary": "An example multi-column selection with aggregation function", + "value": "$f=id__count,score__sum" + }, + "joinTable": { + "summary": "An example of multi-column selection from a different table using a join (see $join)", + "value": "$f=username,user_groups.group_name" + } + } + }, + "groupBy": { + "name": "$groupby", + "in": "query", + "description": "Groups rows that have the same values into summary rows", + "required": false, + "allowReserved": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "examples": { + "simple": { + "summary": "An example of grouping results based on category", + "value": "$groupby=category_id" + }, + "agg": { + "summary": "An example of grouping results based on year and month", + "value": "$groupby=date__year,date__month" + } + } + }, + "deleted": { + "name": "$deleted", + "in": "query", + "description": "Returns results including deleted records", + "required": false, + "allowReserved": true, + "schema": { + "type": "boolean" + }, + "examples": { + "getDeleted": { + "summary": "An example of a query that returns deleted records", + "value": "$deleted=1" + } + } + }, + "join": { + "name": "$join", + "in": "query", + "description": "Joins results from another model based on a foreign key", + "required": false, + "allowReserved": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "examples": { + "getGroupName": { + "summary": "An example of a query with a left join from users to user_groups", + "value": "$join=user_groups__left__user_group_id" + }, + "getGroupNameInner": { + "summary": "An example of a query with a inner join from users to user_groups", + "value": "$join=user_groups__user_group_id" + } + } + }, + "m2m": { + "name": "$m2m", + "in": "query", + "description": "Returns results from M2M fields", + "required": false, + "allowReserved": true, + "schema": { + "type": "string", + "description": "0=don't get, 1/fill=get full records, id=get ids only" + }, + "examples": { + "fillAll": { + "summary": "An example of a query that fills all m2m records", + "value": "$m2m=fill" + }, + "fillOne": { + "summary": "An example of a query that fills IDs from s specific m2m field called cards", + "value": "$m2m=cards__id" + } + } + }, + "q": { + "name": "$q", + "in": "query", + "description": "Searches all string fields marked as Searchable", + "required": false, + "allowReserved": true, + "schema": { + "type": "string" + } + }, + "preload": { + "name": "$preload", + "in": "query", + "description": "Fills the data from foreign keys", + "required": false, + "allowReserved": true, + "schema": { + "type": "boolean" + }, + "examples": { + "getDeleted": { + "summary": "An example of a query that fills foreign key object", + "value": "$preload=1" + } + } + }, + "next": { + "name": "$next", + "in": "query", + "description": "Used in operation `method` to redirect the user to the specified path after the request. Value of `$back` will return the user back to the page", + "required": false, + "allowReserved": true, + "schema": { + "type": "string" + } + }, + "stat": { + "name": "$stat", + "in": "query", + "description": "Returns the API call execution time in milliseconds", + "required": false, + "allowReserved": true, + "schema": { + "type": "boolean" + }, + "examples": { + "getDeleted": { + "summary": "An example of a query that measures the execution time", + "value": "$stat=1" + } + } + }, + "or": { + "name": "$or", + "in": "query", + "description": "OR operator with multiple queries in the format of field=value. This `|` is used to separate the query parts and `+` is used for nested `AND` inside the the `OR` statement.", + "required": false, + "allowReserved": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "examples": { + "simple": { + "summary": "An example of a query that returns records with active=1 or admin=1", + "value": "$or=active=1|admin=1" + }, + "multiValueOr": { + "summary": "An example of a query that returns records where the name starts with the letter a or ends with the letter a", + "value": "$or=name__startswith=a|name__endswith=a" + }, + "nestedAnd": { + "summary": "An example of a query that returns records with admin=1 or (active=1 and username=john)", + "value": "$or=admin=1|active=1+username=john" + } + } + } + } + }, + "paths": { + "/api/d/{model}/read/{id}": { + "summary": "read one record", + "description": "returns an object for a single user that has the primary key `{id}`", + "get": { + "operationId": "readOneRecordGet", + "responses": { + "200": { + "description": "Valid response", + "content": { + "examples": { + "standard": { + "value":"" + } + } + } + } + } + }, + "post": { + "operationId": "readOneRecordPost", + "responses": { + "200": { + "description": "Valid response", + "content": { + "examples": { + "standard": { + "value":"" + } + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/model" + }, + { + "$ref": "#/components/parameters/pathID" + }, + { + "$ref": "#/components/parameters/deleted" + }, + { + "$ref": "#/components/parameters/m2m" + }, + { + "$ref": "#/components/parameters/preload" + }, + { + "$ref": "#/components/parameters/stat" + } + ] + }, + "/api/d/{model}/read": { + "summary": "dAPI read multiple records", + "description": "returns an array of objects for a multiple records", + "get": { + "operationId": "readMultiRecordGet", + "responses": { + "200": { + "description": "Valid response", + "content": { + "examples": { + "standard": { + "value":"" + } + } + } + } + } + }, + "post": { + "operationId": "readMultiRecordPost", + "responses": { + "200": { + "description": "Valid response", + "content": { + "examples": { + "standard": { + "value":"" + } + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/model" + }, + { + "$ref": "#/components/parameters/queryID" + }, + { + "$ref": "#/components/parameters/limit" + }, + { + "$ref": "#/components/parameters/offset" + }, + { + "$ref": "#/components/parameters/order" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/groupBy" + }, + { + "$ref": "#/components/parameters/deleted" + }, + { + "$ref": "#/components/parameters/join" + }, + { + "$ref": "#/components/parameters/m2m" + }, + { + "$ref": "#/components/parameters/q" + }, + { + "$ref": "#/components/parameters/next" + }, + { + "$ref": "#/components/parameters/stat" + }, + { + "$ref": "#/components/parameters/or" + } + ] + } + } +} \ No newline at end of file diff --git a/register.go b/register.go index f92a10b1..b5cd1fd2 100644 --- a/register.go +++ b/register.go @@ -1,6 +1,8 @@ package uadmin import ( + "crypto/sha512" + "encoding/base64" "io/ioutil" "net/http" "os" @@ -17,6 +19,12 @@ type HideInDashboarder interface { HideInDashboard() bool } +// SchemaCategory used to check if a model should be hidden in +// dashboard +type SchemaCategory interface { + SchemaCategory() string +} + // CustomTranslation is where you can register custom translation files. // To register a custom translation file, always assign it with it's key // in the this format "category/name". For example: @@ -24,7 +32,7 @@ type HideInDashboarder interface { // uadmin.CustomTranslation = append(uadmin.CustomTranslation, "ui/billing") // // This will register the file and you will be able to use it if `uadmin.Tf`. -// By default there is only one registed custom translation wich is "uadmin/system". +// By default there is only one registered custom translation which is "uadmin/system". var CustomTranslation = []string{ "uadmin/system", } @@ -77,7 +85,6 @@ func Register(m ...interface{}) { dashboardMenus := []DashboardMenu{} All(&dashboardMenus) var modelExists bool - cat := "" Schema = map[string]ModelSchema{} for i := range modelList { modelExists = false @@ -91,6 +98,17 @@ func Register(m ...interface{}) { hideItem = hider.HideInDashboard() } + // Get Category Name + cat := "System" + // Check if the model is a system model + if i >= SMCount { + if category, ok := modelList[i].(SchemaCategory); ok { + cat = category.SchemaCategory() + } else { + cat = "" + } + } + // Register Dashboard menu // First check if the model is already in dashboard dashboardIndex := 0 @@ -104,13 +122,6 @@ func Register(m ...interface{}) { // If not in dashboard, then add it if !modelExists { - // Check if the model is a system model - if i < SMCount { - cat = "System" - } else { - // TODO: Add SetCategory(string) method - cat = "" - } dashboard := DashboardMenu{ MenuName: inflection.Plural(strings.Join(helper.SplitCamelCase(t.Name()), " ")), URL: name, @@ -119,11 +130,15 @@ func Register(m ...interface{}) { } Save(&dashboard) } else { - // If model exists, synchnorize it if changed + // If model exists, synchronize it if changed if hideItem != dashboardMenus[dashboardIndex].Hidden { dashboardMenus[dashboardIndex].Hidden = hideItem Save(&dashboardMenus[dashboardIndex]) } + if cat != dashboardMenus[dashboardIndex].Cat { + dashboardMenus[dashboardIndex].Cat = cat + Save(&dashboardMenus[dashboardIndex]) + } } } @@ -160,6 +175,13 @@ func Register(m ...interface{}) { JWT = string(buf) } } + JWTIssuer = func() string { + hash := sha512.New() + hash.Write([]byte(JWT)) + buf := hash.Sum(nil) + b64 := base64.RawURLEncoding.EncodeToString(buf) + return b64[:8] + }() // Check if salt is there or generate it users := []User{} diff --git a/schema.go b/schema.go index 4198d2e5..07641f09 100644 --- a/schema.go +++ b/schema.go @@ -15,6 +15,7 @@ type ModelSchema struct { DisplayName string // Display Name of the model e.g. Order Items ModelName string // URL e.g. orderitem TableName string // DB table name e.g. order_items + Category string ModelID uint Inlines []*ModelSchema InlinesData []listData @@ -28,7 +29,7 @@ type ModelSchema struct { } // FieldByName returns a field from a ModelSchema by name or nil if -// it doen't exist +// it doesn't exist func (s ModelSchema) FieldByName(a string) *F { for i := range s.Fields { if strings.EqualFold(s.Fields[i].Name, a) { @@ -103,19 +104,6 @@ func (s ModelSchema) MarshalJSON() ([]byte, error) { }) } -// ApprovalAction is a selection of approval actions -type ApprovalAction int - -// Approved is an accepted change -func (ApprovalAction) Approved() ApprovalAction { - return 1 -} - -// Rejected is a rejected change -func (ApprovalAction) Rejected() ApprovalAction { - return 2 -} - // F is a field type F struct { Name string diff --git a/user.go b/user.go index 913b7c03..46e08ec9 100644 --- a/user.go +++ b/user.go @@ -89,6 +89,7 @@ func (u *User) Login(pass string, otp string) *Session { s.LastLogin = time.Now() if u.OTPRequired { if otp == "" { + Trail(INFO, "OTP login for: %s is %s", u.Username, u.GetOTP()) s.PendingOTP = true } else { s.PendingOTP = !u.VerifyOTP(otp) From fe3adceafef2e60b4e7c7b40309b5346ce606a55 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 22 Nov 2022 22:58:26 +0400 Subject: [PATCH 014/109] BUG FIX: trail none type was not implemeted for windows --- colors/colors_windows.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/colors/colors_windows.go b/colors/colors_windows.go index b8f387d4..c8c51088 100644 --- a/colors/colors_windows.go +++ b/colors/colors_windows.go @@ -68,6 +68,9 @@ const Warning = "[ " + FGYellowB + "WARNING" + FGNormal + "] " // Error CLI Display const Error = "[ " + FGRedB + "ERROR" + FGNormal + " ] " +// None CLI Display +const None = "" + // Debug CLI Display const Debug = "[ " + FGCyanB + "DEBUG" + FGNormal + " ] " From 5e8916e9ac42a5ea83ded476ee6accbc0ee5ed73 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 23 Nov 2022 08:23:30 +0400 Subject: [PATCH 015/109] BUG FIX:dAPI nested AND doesn't work in GET method --- d_api_helper.go | 6 +++++- d_api_logout.go | 13 ++++++++++++- d_api_read.go | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index c54ad972..62ce8239 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -29,7 +29,11 @@ func getURLArgs(r *http.Request) map[string]string { continue } - val, _ := url.QueryUnescape(paramParts[1]) + // unescape URL except for $or because it uses + for nested AND + val := paramParts[1] + if paramParts[0] != "$or" { + val, _ = url.QueryUnescape(paramParts[1]) + } if _, ok := params[paramParts[0]]; ok { params[paramParts[0]] += "," + val } else { diff --git a/d_api_logout.go b/d_api_logout.go index 37b313ec..671206fe 100644 --- a/d_api_logout.go +++ b/d_api_logout.go @@ -3,5 +3,16 @@ package uadmin import "net/http" func dAPILogoutHandler(w http.ResponseWriter, r *http.Request, s *Session) { - + if s == nil { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Already logged out", + }) + return + } + Logout(r) + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/d_api_read.go b/d_api_read.go index 0f231af3..f7dc1de6 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -114,6 +114,8 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { SQL += " OFFSET " + offset } + Trail(DEBUG, SQL) + Trail(DEBUG, "%#v", args) if DebugDB { Trail(DEBUG, SQL) Trail(DEBUG, "%#v", args) From c67ec7b1b73acda82bd450f160d3728b044fd0c2 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 23 Nov 2022 08:49:51 +0400 Subject: [PATCH 016/109] BUG FIX: dAPI edit row count in edit one was always returning 0 --- d_api_edit.go | 3 ++- d_api_read.go | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/d_api_edit.go b/d_api_edit.go index 7c1206d9..ada2444a 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -167,6 +167,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { }) return } + rowsAffected := db.RowsAffected // Process M2M db = GetDB().Begin() @@ -200,7 +201,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { returnDAPIJSON(w, r, map[string]interface{}{ "status": "ok", - "rows_count": db.RowsAffected, + "rows_count": rowsAffected, }, params, "edit", model.Interface()) } else { // Error: Unknown format diff --git a/d_api_read.go b/d_api_read.go index f7dc1de6..0f231af3 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -114,8 +114,6 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { SQL += " OFFSET " + offset } - Trail(DEBUG, SQL) - Trail(DEBUG, "%#v", args) if DebugDB { Trail(DEBUG, SQL) Trail(DEBUG, "%#v", args) From a2e8833064e0bc9f70ee0da0f2bc1563e5bf32d3 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 23 Nov 2022 14:21:16 +0400 Subject: [PATCH 017/109] release v0.9.1 --- admin.go | 5 +- d_api_auth.go | 8 ++ global.go | 5 +- openapi.go | 211 +++++++++++++++++-------------------- openapi/generate_schema.go | 2 +- openapi/schema_object.go | 2 + 6 files changed, 118 insertions(+), 115 deletions(-) diff --git a/admin.go b/admin.go index f03ee98e..8456fbf9 100644 --- a/admin.go +++ b/admin.go @@ -166,6 +166,10 @@ func JSONMarshal(v interface{}, safeEncoding bool) ([]byte, error) { // ReturnJSON returns json to the client func ReturnJSON(w http.ResponseWriter, r *http.Request, v interface{}) { + // Set content type in header + w.Header().Set("Content-Type", "application/json") + + // Marshal content b, err := json.MarshalIndent(v, "", " ") if err != nil { response := map[string]interface{}{ @@ -173,7 +177,6 @@ func ReturnJSON(w http.ResponseWriter, r *http.Request, v interface{}) { "error_msg": fmt.Sprintf("unable to encode JSON. %s", err), } b, _ = json.MarshalIndent(response, "", " ") - w.Header().Set("Content-Type", "application/json") w.Write(b) return } diff --git a/d_api_auth.go b/d_api_auth.go index cb79ded9..16ebbdd0 100644 --- a/d_api_auth.go +++ b/d_api_auth.go @@ -6,6 +6,14 @@ import ( ) func dAPIAuthHandler(w http.ResponseWriter, r *http.Request, s *Session) { + if DisableDAPIAuth { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "dAPI auth is disabled", + }) + + } // Trim leading path r.URL.Path = strings.TrimPrefix(r.URL.Path, "auth") r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") diff --git a/global.go b/global.go index c6292a20..c73bd3e3 100644 --- a/global.go +++ b/global.go @@ -80,7 +80,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.0" +const Version = "0.9.1" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. @@ -373,6 +373,9 @@ var TimeZone = "local" // TrailCacheSize is the number of bytes to keep in memory of trail logs var TrailCacheSize = 65536 +// DisableDAPIAuth enables or disables access to dAPI auth API +var DisableDAPIAuth = true + // AllowDAPISignup allows unauthenticated users to sign up var AllowDAPISignup = false diff --git a/openapi.go b/openapi.go index a4a1ac62..e9ae80f6 100644 --- a/openapi.go +++ b/openapi.go @@ -9,10 +9,18 @@ import ( "github.com/uadmin/uadmin/openapi" ) +// CustomOpenAPI is a handler to be called to customize OpenAPI schema +// Use of OpenAPI schema generation is under development and should not be used in production var CustomOpenAPI func(*openapi.Schema) + +// CustomOpenAPIJSON is a handler to be called to customize OpenAPI schema JSON output +// Use of OpenAPI schema generation is under development and should not be used in production var CustomOpenAPIJSON func([]byte) []byte +// GenerateOpenAPISchema generates API schema for dAPI that is compatible with OpenAPI 3.1.0 +// Use of OpenAPI schema generation is under development and should not be used in production func GenerateOpenAPISchema() { + Trail(WARNING, "Use of OpenAPI schema generation is under development and should not be used in production") s := openapi.GenerateBaseSchema() // Customize schema @@ -63,8 +71,7 @@ func GenerateOpenAPISchema() { } case cFK: return &openapi.SchemaObject{ - Type: "object", - Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Fields[i].TypeName}, + Ref: "#/components/schemas/" + v.Fields[i].TypeName, } case cHTML: return &openapi.SchemaObject{ @@ -76,11 +83,15 @@ func GenerateOpenAPISchema() { } case cLIST: return &openapi.SchemaObject{ - Type: "string", - Enum: func() []interface{} { - vals := make([]interface{}, len(v.Fields[i].Choices)) + Type: "integer", + OneOf: func() []*openapi.SchemaObject { + vals := make([]*openapi.SchemaObject, len(v.Fields[i].Choices)) for j := range v.Fields[i].Choices { - vals[j] = v.Fields[i].Choices[j].V + vals[j] = &openapi.SchemaObject{ + Type: "integer", + Title: v.Fields[i].Choices[j].V, + Const: v.Fields[i].Choices[j].K, + } } return vals }(), @@ -128,13 +139,21 @@ func GenerateOpenAPISchema() { Type: "integer", } } + default: + return &openapi.SchemaObject{ + Type: "string", + } } - // If unknown, use string - return &openapi.SchemaObject{ - Type: "string", - } + }() + // If the field is a foreign key, then add the ID field for it + if v.Fields[i].Type == cFK { + fields[v.Fields[i].Name+"ID"] = &openapi.SchemaObject{ + Type: "integer", + } + } + // Set other schema properties fields[v.Fields[i].Name].Description = v.Fields[i].Help fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue @@ -170,25 +189,48 @@ func GenerateOpenAPISchema() { Schema: func() *openapi.SchemaObject { switch v.Fields[i].Type { case cSTRING: + fallthrough case cCODE: + fallthrough case cEMAIL: + fallthrough case cFILE: + fallthrough case cIMAGE: + fallthrough case cHTML: + fallthrough case cLINK: + fallthrough case cMULTILINGUAL: + fallthrough case cPASSWORD: return &openapi.SchemaObject{ Ref: "#/components/schemas/String", } - case cID: case cFK: - case cLIST: - case cMONEY: return &openapi.SchemaObject{ Ref: "#/components/schemas/Integer", } + case cLIST: + return &openapi.SchemaObject{ + Type: "integer", + OneOf: func() []*openapi.SchemaObject { + vals := make([]*openapi.SchemaObject, len(v.Fields[i].Choices)) + for j := range v.Fields[i].Choices { + vals[j] = &openapi.SchemaObject{ + Type: "integer", + Title: v.Fields[i].Choices[j].V, + Const: v.Fields[i].Choices[j].K, + } + } + return vals + }(), + } + case cMONEY: + fallthrough case cNUMBER: + fallthrough case cPROGRESSBAR: return &openapi.SchemaObject{ Ref: "#/components/schemas/Number", @@ -201,9 +243,8 @@ func GenerateOpenAPISchema() { return &openapi.SchemaObject{ Ref: "#/components/schemas/DateTime", } - } - return &openapi.SchemaObject{ - Ref: "#/components/schemas/String", + default: + return &openapi.SchemaObject{Ref: "#/components/schemas/String"} } }(), } @@ -228,40 +269,51 @@ func GenerateOpenAPISchema() { Schema: func() *openapi.SchemaObject { switch v.Fields[i].Type { case cSTRING: + fallthrough case cCODE: + fallthrough case cEMAIL: + fallthrough case cFILE: + fallthrough case cIMAGE: + fallthrough case cHTML: + fallthrough case cLINK: + fallthrough case cMULTILINGUAL: + fallthrough case cPASSWORD: return &openapi.SchemaObject{ - Ref: "#/components/schemas/String", + Type: "string", } - case cID: case cFK: + fallthrough case cLIST: + fallthrough case cMONEY: return &openapi.SchemaObject{ - Ref: "#/components/schemas/Integer", + Type: "integer", } case cNUMBER: + fallthrough case cPROGRESSBAR: return &openapi.SchemaObject{ - Ref: "#/components/schemas/Number", + Type: "number", } case cBOOL: return &openapi.SchemaObject{ - Ref: "#/components/schemas/Boolean", + Type: "boolean", } case cDATE: return &openapi.SchemaObject{ - Ref: "#/components/schemas/DateTime", + Type: "string", + } + default: + return &openapi.SchemaObject{ + Type: "string", } - } - return &openapi.SchemaObject{ - Ref: "#/components/schemas/String", } }(), } @@ -293,28 +345,11 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, - }, - }, - }, - }, - }, - }, - Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, - Responses: map[string]openapi.Response{ - "200": { - Description: v.DisplayName + " record", - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "result": {Ref: "#/components/schemas/" + v.Name}, + "status": {Type: "string"}, + }, }, }, }, @@ -357,28 +392,14 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, - }, - }, - }, - }, - }, - }, - Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, - Responses: map[string]openapi.Response{ - "200": { - Description: v.DisplayName + " record", - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "result": { + Type: "array", + Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Name}, + }, + "status": {Type: "string"}, + }, }, }, }, @@ -425,27 +446,6 @@ func GenerateOpenAPISchema() { s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ Summary: "Add one " + v.DisplayName, Description: "Add one " + v.DisplayName, - Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, - Responses: map[string]openapi.Response{ - "200": { - Description: v.DisplayName + " record", - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, - }, - }, - }, - }, - }, - }, Post: &openapi.Operation{ Tags: []string{v.Name, func() string { if v.Category != "" { @@ -460,7 +460,15 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "id": { + Type: "array", + Items: &openapi.SchemaObject{Type: "integer"}, + }, + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, }, }, }, @@ -480,27 +488,6 @@ func GenerateOpenAPISchema() { s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ Summary: "Add one " + v.DisplayName, Description: "Add one " + v.DisplayName, - Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, - Responses: map[string]openapi.Response{ - "200": { - Description: v.DisplayName + " record", - Content: map[string]openapi.MediaType{ - "application/json": { - Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, - }, - }, - }, - }, - }, - }, Post: &openapi.Operation{ Tags: []string{v.Name, func() string { if v.Category != "" { diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index abe8be48..65e3e80c 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -2,7 +2,7 @@ package openapi func GenerateBaseSchema() *Schema { s := &Schema{ - OpenAPI: "3.0.0", + OpenAPI: "3.1.0", Info: &SchemaInfo{ Title: " API documentation", Description: "API documentation", diff --git a/openapi/schema_object.go b/openapi/schema_object.go index aac02a07..acc61dfd 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -19,6 +19,8 @@ type SchemaObject struct { ExternalDocs *ExternalDocs `json:"externalDocs,omitempty"` Example *Example `json:"example,omitempty"` Enum []interface{} `json:"enum,omitempty"` + OneOf []*SchemaObject `json:"oneOf,omitempty"` + Const interface{} `json:"const,omitempty"` XFilters []XModifier `json:"x-filter,omitempty"` XAggregator []XModifier `json:"x-aggregator,omitempty"` } From 73f7fe63533972add0f52e250664cd6478e87ee2 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 25 Nov 2022 05:25:39 +0400 Subject: [PATCH 018/109] dAPI auth and OpenAPI schema for it --- auth.go | 8 +- d_api.go | 1 - d_api_auth.go | 3 +- d_api_change_password.go | 48 +++ d_api_login.go | 16 +- d_api_logout.go | 12 +- d_api_reset_password.go | 162 +++++++++- d_api_signup.go | 78 +++++ forgot_password_handler.go | 40 ++- forgot_password_handler_test.go | 4 +- global.go | 38 ++- login_handler.go | 2 +- openapi.go | 190 ++++++++++-- openapi/auth_paths.go | 449 ++++++++++++++++++++++++++++ openapi/generate_schema.go | 26 +- openapi/schema_object.go | 3 +- password_reset_handler.go | 9 + templates/uadmin/default/trail.html | 5 +- user.go | 15 +- 19 files changed, 1034 insertions(+), 75 deletions(-) create mode 100644 openapi/auth_paths.go diff --git a/auth.go b/auth.go index 8388c199..ed958d31 100644 --- a/auth.go +++ b/auth.go @@ -228,7 +228,7 @@ func getSessionFromRequest(r *http.Request) *Session { key := getSession(r) s := getSessionByKey(key) - if s.ID != 0 { + if s != nil && s.ID != 0 { return s } return nil @@ -734,6 +734,12 @@ func GetRemoteIP(r *http.Request) string { return r.RemoteAddr } +func verifyPassword(hash string, plain string) error { + password := []byte(plain + Salt) + hashedPassword := []byte(hash) + return bcrypt.CompareHashAndPassword(hashedPassword, password) +} + // sanitizeFileName is a function to sanitize file names to pretect // from path traversal attacks using ../ func sanitizeFileName(v string) string { diff --git a/d_api.go b/d_api.go index d7b9883d..3ab72570 100644 --- a/d_api.go +++ b/d_api.go @@ -117,7 +117,6 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") urlParts := strings.Split(r.URL.Path, "/") - Trail(DEBUG, "%#v", urlParts) ctx := context.WithValue(r.Context(), CKey("dAPI"), true) r = r.WithContext(ctx) diff --git a/d_api_auth.go b/d_api_auth.go index 16ebbdd0..6481486c 100644 --- a/d_api_auth.go +++ b/d_api_auth.go @@ -12,8 +12,9 @@ func dAPIAuthHandler(w http.ResponseWriter, r *http.Request, s *Session) { "status": "error", "err_msg": "dAPI auth is disabled", }) - + return } + // Trim leading path r.URL.Path = strings.TrimPrefix(r.URL.Path, "auth") r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") diff --git a/d_api_change_password.go b/d_api_change_password.go index c4d00b20..19a07171 100644 --- a/d_api_change_password.go +++ b/d_api_change_password.go @@ -3,5 +3,53 @@ package uadmin import "net/http" func dAPIChangePasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + if s == nil { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "", + }) + return + } + if CheckCSRF(r) { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing CSRF token", + }) + return + } + + oldPassword := r.FormValue("old_password") + newPassword := r.FormValue("new_password") + + // Check if there is a new password + if newPassword == "" { + w.WriteHeader(http.StatusBadRequest) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing new password", + }) + return + } + + // Verify old password + err := verifyPassword(s.User.Password, oldPassword) + if err != nil { + incrementInvalidLogins(r) + w.WriteHeader(401) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "invalid password", + }) + return + } + + s.User.Password = newPassword + s.User.Save() + + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/d_api_login.go b/d_api_login.go index 19e5704d..551669bb 100644 --- a/d_api_login.go +++ b/d_api_login.go @@ -3,9 +3,7 @@ package uadmin import "net/http" func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { - if s != nil { - Logout(r) - } + _ = s // Get request variables username := r.FormValue("username") @@ -17,7 +15,6 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { if otp != "" { // Check if there is username and password or a session key if session != "" { - w.WriteHeader(http.StatusAccepted) s = Login2FAKey(r, session, otp) } else { s = Login2FA(r, username, password, otp) @@ -27,7 +24,7 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if optRequired { - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(http.StatusAccepted) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "OTP Required", @@ -37,14 +34,17 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if s == nil { - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(http.StatusUnauthorized) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Invalid username or password", + "err_msg": "Invalid credentials", }) return } + // Preload the user to get the group name + Preload(&s.User) + jwt := SetSessionCookie(w, r, s) ReturnJSON(w, r, map[string]interface{}{ "status": "ok", @@ -54,7 +54,7 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { "username": s.User.Username, "first_name": s.User.FirstName, "last_name": s.User.LastName, - "group_id": s.User.UserGroupID, + "group_name": s.User.UserGroup.GroupName, "admin": s.User.Admin, }, }) diff --git a/d_api_logout.go b/d_api_logout.go index 671206fe..30eb7506 100644 --- a/d_api_logout.go +++ b/d_api_logout.go @@ -7,10 +7,20 @@ func dAPILogoutHandler(w http.ResponseWriter, r *http.Request, s *Session) { w.WriteHeader(http.StatusUnauthorized) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Already logged out", + "err_msg": "User not logged in", }) return } + + if CheckCSRF(r) { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Missing CSRF token", + }) + return + } + Logout(r) ReturnJSON(w, r, map[string]interface{}{ "status": "ok", diff --git a/d_api_reset_password.go b/d_api_reset_password.go index 22034da9..124046dd 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -1,7 +1,167 @@ package uadmin -import "net/http" +import ( + "net/http" + "time" +) func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Get parameters + username := r.FormValue("username") + email := r.FormValue("email") + otp := r.FormValue("otp") + password := r.FormValue("password") + // check if there is an email or a username + if username == "" && email == "" { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "No username nor email", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // get user + user := User{} + if username != "" { + Get(&user, "username = ? AND active = ?", username, true) + } else { + Get(&user, "email = ? AND active = ?", email, true) + } + + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetRequest(), r) + log.Save() + }() + + // check if the user exists and active + if user.ID == 0 || (user.ExpiresOn != nil || user.ExpiresOn.After(time.Now())) { + w.WriteHeader(404) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "username or email do not match any active user", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // If there is no otp, then we assume this is a request to send a password + // reset email + if otp == "" { + err := forgotPasswordHandler(&s.User, r, CustomResetPasswordLink, ResetPasswordMessage) + + if err != nil { + w.WriteHeader(403) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": err.Error(), + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + // log the request + w.WriteHeader(http.StatusAccepted) + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + r.Form.Set("reset-status", "Email was sent with the OTP") + log.PasswordReset(user.Username, log.Action.PasswordResetSuccessful(), r) + log.Save() + }() + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) + return + } + + // Since there is an OTP, we can check it and reset the password + // Check if there is a a new password + if password == "" { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "missing password", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // check OTP + if !user.VerifyOTP(otp) { + incrementInvalidLogins(r) + w.WriteHeader(401) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "invalid or expired OTP", + }) + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + log.PasswordReset("", log.Action.PasswordResetDenied(), r) + log.Save() + }() + return + } + + // reset the password + user.Password = password + user.Save() + + // log the request + go func() { + log := &Log{} + if password != "" { + r.Form.Set("password", "*****") + } + r.Form.Set("reset-status", "Successfully changed the password") + log.PasswordReset("", log.Action.PasswordResetSuccessful(), r) + log.Save() + }() + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/d_api_signup.go b/d_api_signup.go index c1131139..1a03570a 100644 --- a/d_api_signup.go +++ b/d_api_signup.go @@ -3,5 +3,83 @@ package uadmin import "net/http" func dAPISignupHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Check if signup API is allowed + if !AllowDAPISignup { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Signup API is disabled", + }) + return + } + // get variables from request + username := r.FormValue("username") + email := r.FormValue("email") + firstName := r.FormValue("first_name") + lastName := r.FormValue("last_name") + password := r.FormValue("password") + + // set the username to email if there is no username + if username == "" && email != "" { + username = email + } + + // check if password is empty + if password == "" { + w.WriteHeader(http.StatusBadRequest) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "password is empty", + }) + return + } + + // create user object + user := User{ + Username: username, + FirstName: firstName, + LastName: lastName, + Password: password, + Email: email, + Active: DAPISignupActive, + Admin: false, + RemoteAccess: DAPISignupAllowRemote, + UserGroupID: uint(DAPISignupGroupID), + } + + // run custom validation + if SignupValidationHandler != nil { + err := SignupValidationHandler(&user) + w.WriteHeader(http.StatusBadRequest) + if err != nil { + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": err.Error(), + }) + return + } + } + + // Save user record + user.Save() + + // Check if the record was not saved, that means the username is taken + if user.ID == 0 { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "username taken", + }) + } + + // if the user is active, then login in + if user.Active { + dAPILoginHandler(w, r, s) + return + } + + ReturnJSON(w, r, map[string]interface{}{ + "status": "ok", + }) } diff --git a/forgot_password_handler.go b/forgot_password_handler.go index a9a9d314..0428f7d4 100644 --- a/forgot_password_handler.go +++ b/forgot_password_handler.go @@ -8,22 +8,25 @@ import ( ) // forgotPasswordHandler ! -func forgotPasswordHandler(u *User, r *http.Request) error { +func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) error { if u.Email == "" { return fmt.Errorf("unable to reset password, the user does not have an email") } - msg := `Dear {NAME}, + if msg == "" { + msg = `Dear {NAME}, -Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. - -If you want to reset your password, click this link: -{URL} - -If you didn't request a password reset, you can ignore this message. + Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. + + If you want to reset your password, click this link: + {URL} + + If you didn't request a password reset, you can ignore this message. + + Regards, + {WEBSITE} Support + ` + } -Regards, -{WEBSITE} Support -` // Check if the host name is in the allowed hosts list allowed := false var host string @@ -46,10 +49,17 @@ Regards, } urlParts := strings.Split(r.Header.Get("origin"), "://") - link := urlParts[0] + "://" + r.Host + RootURL + "resetpassword?u=" + fmt.Sprint(u.ID) + "&key=" + u.GetOTP() - msg = strings.Replace(msg, "{NAME}", u.String(), -1) - msg = strings.Replace(msg, "{WEBSITE}", SiteName, -1) - msg = strings.Replace(msg, "{URL}", link, -1) + if link == "" { + link = "{PROTOCOL}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" + } + link = strings.ReplaceAll(link, "{PROTOCOL}", urlParts[0]) + link = strings.ReplaceAll(link, "{HOST}", RootURL) + link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) + link = strings.ReplaceAll(link, "{OTP}", u.GetOTP()) + + msg = strings.ReplaceAll(msg, "{NAME}", u.String()) + msg = strings.ReplaceAll(msg, "{WEBSITE}", SiteName) + msg = strings.ReplaceAll(msg, "{URL}", link) subject := "Password reset for " + SiteName err = SendEmail([]string{u.Email}, []string{}, []string{}, subject, msg) diff --git a/forgot_password_handler_test.go b/forgot_password_handler_test.go index 541db364..314401b5 100644 --- a/forgot_password_handler_test.go +++ b/forgot_password_handler_test.go @@ -12,13 +12,13 @@ func (t *UAdminTests) TestForgotPasswordHandler() { user := User{} Get(&user, "id = ?", 1) - err := forgotPasswordHandler(&user, r) + err := forgotPasswordHandler(&user, r, "", "") if err == nil { t.Errorf("forgotPasswordHandler didn't return an error on a user with no email") } user.Email = "user@example.com" - err = forgotPasswordHandler(&user, r) + err = forgotPasswordHandler(&user, r, "", "") if err != nil { t.Errorf("forgotPasswordHandler returned an error. %s", err) } diff --git a/global.go b/global.go index c73bd3e3..e39fa500 100644 --- a/global.go +++ b/global.go @@ -380,7 +380,43 @@ var DisableDAPIAuth = true var AllowDAPISignup = false // DAPISignupGroupID is the default user group id new users get when they sign up -var DAPISignupGroupID = false +// to leave new signed up users without a group, use value 0 for this variable +var DAPISignupGroupID = 0 + +// DAPISignupActive controls if new signed up users are activate automatically +var DAPISignupActive = true + +// DAPISignupAllowRemote controls if new signed up users are can login over the internet +var DAPISignupAllowRemote = true + +// SignupValidationHandler can be used to validate or customize new +// signed up users. Note that the password in the password field +// is passed in plain text. Do not plain text passwords anywhere. +var SignupValidationHandler func(user *User) error + +// CustomResetPasswordLink is the link sent to the user's email to reset their password +// the string may include the following place holder: +// "{PROTOCOL}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" +var CustomResetPasswordLink = "" + +// ResetPasswordMessage is a message that can be sent to the user email when +// a password reset request sends an email. This message may include the +// following place holders: +// {NAME}: user real name +// {WEBSITE}: website name +// {URL}: link to the password reset page +var ResetPasswordMessage = `Dear {NAME}, + +Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. + +If you want to reset your password, click this link: +{URL} + +If you didn't request a password reset, you can ignore this message. + +Regards, +{WEBSITE} Support +` // Private Global Variables // Regex diff --git a/login_handler.go b/login_handler.go index d83c49e6..19053791 100644 --- a/login_handler.go +++ b/login_handler.go @@ -39,7 +39,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { IncrementMetric("uadmin/security/passwordreset/emailsent") c.ErrExists = true c.Err = "Password recovery request sent. Please check email to reset your password" - forgotPasswordHandler(&user, r) + forgotPasswordHandler(&user, r, "", "") } else { IncrementMetric("uadmin/security/passwordreset/invalidemail") c.ErrExists = true diff --git a/openapi.go b/openapi.go index e9ae80f6..1e150097 100644 --- a/openapi.go +++ b/openapi.go @@ -71,7 +71,10 @@ func GenerateOpenAPISchema() { } case cFK: return &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Fields[i].TypeName, + AllOf: []*openapi.SchemaObject{ + {Ref: "#/components/schemas/" + v.Fields[i].TypeName}, + {}, + }, } case cHTML: return &openapi.SchemaObject{ @@ -155,14 +158,20 @@ func GenerateOpenAPISchema() { } // Set other schema properties - fields[v.Fields[i].Name].Description = v.Fields[i].Help - fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue - fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName - if val, ok := v.Fields[i].Max.(string); ok && val != "" { - fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) - } - if val, ok := v.Fields[i].Min.(string); ok && val != "" { - fields[v.Fields[i].Name].Minimum, _ = strconv.Atoi(val) + if v.Fields[i].Type != cFK { + fields[v.Fields[i].Name].Description = v.Fields[i].Help + fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue + fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName + if val, ok := v.Fields[i].Max.(string); ok && val != "" { + fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) + } + if val, ok := v.Fields[i].Min.(string); ok && val != "" { + fields[v.Fields[i].Name].Minimum, _ = strconv.Atoi(val) + } + } else { + fields[v.Fields[i].Name].AllOf[1].Description = v.Fields[i].Help + fields[v.Fields[i].Name].AllOf[1].Default = v.Fields[i].DefaultValue + fields[v.Fields[i].Name].AllOf[1].Title = v.Fields[i].DisplayName } // Add parameters @@ -332,7 +341,7 @@ func GenerateOpenAPISchema() { Summary: "Read one " + v.DisplayName, Description: "Read one " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -376,10 +385,10 @@ func GenerateOpenAPISchema() { } // Read Many s.Paths[fmt.Sprintf("/api/d/%s/read", v.ModelName)] = openapi.Path{ - Summary: "Read one " + v.DisplayName, - Description: "Read one " + v.DisplayName, + Summary: "Read many " + v.DisplayName, + Description: "Read many " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -388,7 +397,7 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " records", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -447,7 +456,7 @@ func GenerateOpenAPISchema() { Summary: "Add one " + v.DisplayName, Description: "Add one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -456,7 +465,7 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " record added", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -475,21 +484,21 @@ func GenerateOpenAPISchema() { }, }, }, - Parameters: append(writeParameters, []openapi.Parameter{ + Parameters: append([]openapi.Parameter{ { Ref: "#/components/parameters/CSRF", }, { Ref: "#/components/parameters/stat", }, - }...), + }, writeParameters...), } - // Add One - s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ - Summary: "Add one " + v.DisplayName, - Description: "Add one " + v.DisplayName, + // Edit One + s.Paths[fmt.Sprintf("/api/d/%s/edit/{id}", v.ModelName)] = openapi.Path{ + Summary: "Edit one " + v.DisplayName, + Description: "Edit one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { + Tags: []string{func() string { if v.Category != "" { return v.Category } else { @@ -498,25 +507,152 @@ func GenerateOpenAPISchema() { }()}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " record edited", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ - Ref: "#/components/schemas/" + v.Name, + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, }, }, }, }, }, }, - Parameters: append(writeParameters, []openapi.Parameter{ + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, { Ref: "#/components/parameters/CSRF", }, { Ref: "#/components/parameters/stat", }, - }...), + }, writeParameters...), + } + // Edit Many + s.Paths[fmt.Sprintf("/api/d/%s/edit", v.ModelName)] = openapi.Path{ + Summary: "Edit many " + v.DisplayName, + Description: "Edit many " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " records edited", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, append(writeParameters, parameters...)...), + } + // Delete One + s.Paths[fmt.Sprintf("/api/d/%s/delete/{id}", v.ModelName)] = openapi.Path{ + Summary: "Delete one " + v.DisplayName, + Description: "Delete one " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record deleted", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, + } + // Delete Many + s.Paths[fmt.Sprintf("/api/d/%s/delete", v.ModelName)] = openapi.Path{ + Summary: "Delete many " + v.DisplayName, + Description: "Delete many " + v.DisplayName, + Post: &openapi.Operation{ + Tags: []string{func() string { + if v.Category != "" { + return v.Category + } else { + return "Other" + } + }()}, + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " records deleted", + Content: map[string]openapi.MediaType{ + "application/json": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: map[string]*openapi.SchemaObject{ + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, parameters...), } s.Components.Schemas[v.Name] = openapi.SchemaObject{ diff --git a/openapi/auth_paths.go b/openapi/auth_paths.go new file mode 100644 index 00000000..a8c73bee --- /dev/null +++ b/openapi/auth_paths.go @@ -0,0 +1,449 @@ +package openapi + +func getAuthPaths() map[string]Path { + return map[string]Path{ + // Login auth API + "/api/d/auth/login": { + Summary: "Login", + Description: "Login API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful login", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "jwt": {Type: "string"}, + "session": {Type: "string"}, + "user": { + Type: "object", + Properties: map[string]*SchemaObject{ + "username": {Type: "string"}, + "admin": {Type: "boolean"}, + "first_name": {Type: "string"}, + "last_name": {Type: "string"}, + "group_name": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + "202": { + Description: "Username and password are correct but MFA is required and OTP was not provided", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + "session": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "Invalid credentials", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Description: "Required for username/password login and single step MFA. But not required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Description: "Required for username/password login and single step MFA. But not required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "otp", + In: "query", + Description: "Not required for username/password login. Required for the second step in a two-step MFA and required single-step for MFA", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "session", + In: "query", + Description: "Only required during the second step of a two-step MFA authentication", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Logout auth API + "/api/d/auth/logout": { + Summary: "Logout", + Description: "Logout API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful logout", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "User not logged in", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + }, + }, + + // Signup auth API + "/api/d/auth/signup": { + Summary: "Signup", + Description: "Signup API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful signup", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "jwt": {Type: "string"}, + "session": {Type: "string"}, + "user": { + Type: "object", + Properties: map[string]*SchemaObject{ + "username": {Type: "string"}, + "admin": {Type: "boolean"}, + "first_name": {Type: "string"}, + "last_name": {Type: "string"}, + "group_name": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + "400": { + Description: "Invalid or missing signup data. More about the error in err_msg.", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Required: true, + Description: "Username can be any string or an email", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Required: true, + Description: "Password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "first_name", + In: "query", + Required: true, + Description: "First name", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "last_name", + In: "query", + Required: true, + Description: "Last name", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "email", + In: "query", + Required: true, + Description: "Email", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Reset password auth API + "/api/d/auth/resetpassword": { + Summary: "Reset Password", + Description: "Reset Password API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful password reset", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "202": { + Description: "Password reset email sent", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "400": { + Description: "Missing password rest data. More about the error in err_msg.", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "Invalid or expired OTP", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "403": { + Description: "User does not have an email", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "404": { + Description: "username or email do not match any active user", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Name: "username", + In: "query", + Description: "Username or email is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "email", + In: "query", + Required: true, + Description: "Username or email is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Required: true, + Description: "New password which is required in the second step with the OTP", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "otp", + In: "query", + Required: true, + Description: "OTP is required in the second step with the new password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + + // Change password auth API + "/api/d/auth/changepassword": { + Summary: "Change Password", + Description: "Change Password API", + Post: &Operation{ + Tags: []string{"Auth"}, + Responses: map[string]Response{ + "200": { + Description: "Successful password reset", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + }, + }, + }, + }, + }, + "400": { + Description: "Missing new password", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + "401": { + Description: "current password is invalid", + Content: map[string]MediaType{ + "application/json": { + Schema: &SchemaObject{ + Type: "object", + Properties: map[string]*SchemaObject{ + "status": {Type: "string"}, + "err_msg": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Parameters: []Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Name: "old_password", + In: "query", + Required: true, + Description: "Current user password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "new_password", + In: "query", + Required: true, + Description: "New password", + Schema: &SchemaObject{ + Type: "string", + }, + }, + }, + }, + } +} diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 65e3e80c..27dc7474 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -2,12 +2,26 @@ package openapi func GenerateBaseSchema() *Schema { s := &Schema{ - OpenAPI: "3.1.0", + OpenAPI: "3.0.3", Info: &SchemaInfo{ Title: " API documentation", Description: "API documentation", Version: "1.0.0", }, + Tags: []Tag{ + { + Name: "Auth", + Description: "Authentication API", + }, + { + Name: "System", + Description: "CRUD APIs for uAdmin core models", + }, + { + Name: "Other", + Description: "CRUD APIs for models with no category", + }, + }, Components: &Components{ Schemas: map[string]SchemaObject{ "Integer": { @@ -19,7 +33,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, @@ -38,7 +52,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__lte", In: "suffix", Summary: "Less than or equal to"}, {Modifier: "__in", In: "suffix", Summary: "Find a value matching any of these values"}, {Modifier: "__between", In: "suffix", Summary: "Selects values within a given range"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__sum", In: "suffix", Summary: "Returns the total sum of a numeric field"}, @@ -59,7 +73,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, @@ -76,7 +90,7 @@ func GenerateBaseSchema() *Schema { {Modifier: "__istartswith", In: "suffix", Summary: "Search for string values that starts with a given substring"}, {Modifier: "__iendswith", In: "", Summary: "Search for string values that ends with a given substring"}, {Modifier: "__in", In: "", Summary: "Find a value matching any of these values"}, - {Modifier: "!", In: "", Summary: "Negates operator"}, + {Modifier: "!", In: "prefix", Summary: "Negates operator"}, }, XAggregator: []XModifier{ {Modifier: "__count", In: "suffix", Summary: "Returns the number of rows"}, @@ -392,7 +406,7 @@ func GenerateBaseSchema() *Schema { }, }, }, - Paths: map[string]Path{}, + Paths: getAuthPaths(), Security: []SecurityRequirement{ { "apiKeyCookie": []string{}, diff --git a/openapi/schema_object.go b/openapi/schema_object.go index acc61dfd..bdae2e6b 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -20,7 +20,8 @@ type SchemaObject struct { Example *Example `json:"example,omitempty"` Enum []interface{} `json:"enum,omitempty"` OneOf []*SchemaObject `json:"oneOf,omitempty"` - Const interface{} `json:"const,omitempty"` + AllOf []*SchemaObject `json:"allOf,omitempty"` + Const interface{} `json:"x-const,omitempty"` XFilters []XModifier `json:"x-filter,omitempty"` XAggregator []XModifier `json:"x-aggregator,omitempty"` } diff --git a/password_reset_handler.go b/password_reset_handler.go index e6dab260..b6f686d6 100644 --- a/password_reset_handler.go +++ b/password_reset_handler.go @@ -31,6 +31,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { if user.ID == 0 { go func() { log := &Log{} + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } r.Form.Set("reset-status", "invalid user id") log.PasswordReset(userID, log.Action.PasswordResetDenied(), r) log.Save() @@ -42,6 +45,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { if !user.VerifyOTP(otpCode) { go func() { log := &Log{} + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } r.Form.Set("reset-status", "invalid otp code: "+otpCode) log.PasswordReset(user.Username, log.Action.PasswordResetDenied(), r) log.Save() @@ -61,6 +67,9 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { go func() { log := &Log{} r.Form.Set("reset-status", "Successfully changed the password") + if r.Form.Get("password") != "" { + r.Form.Set("password", "*****") + } log.PasswordReset(user.Username, log.Action.PasswordResetSuccessful(), r) log.Save() }() diff --git a/templates/uadmin/default/trail.html b/templates/uadmin/default/trail.html index a1cfa6a7..02c0830b 100644 --- a/templates/uadmin/default/trail.html +++ b/templates/uadmin/default/trail.html @@ -1,7 +1,7 @@ - {{.SiteName}} - {{Tf "uadmin/system" .Language.Code "Dashboard"}} + {{.SiteName}} - {{Tf "uadmin/system" .Language.Code "Trail"}} @@ -19,7 +19,8 @@ "\x1b[0m": "" } var justLoaded = true; - $.ajax('/uadmin-portal/api/trail/', { + var RootURL = '{{.RootURL}}'; + $.ajax(RootURL+'api/trail/', { xhrFields: { onprogress: function(e) { diff --git a/user.go b/user.go index 46e08ec9..342daffe 100644 --- a/user.go +++ b/user.go @@ -4,8 +4,6 @@ import ( "fmt" "strings" "time" - - "golang.org/x/crypto/bcrypt" ) // User ! @@ -36,7 +34,12 @@ func (u User) String() string { // Save ! func (u *User) Save() { - if !strings.HasPrefix(u.Password, "$2a$") && len(u.Password) != 60 { + err := u.Validate() + if len(err) != 0 { + return + } + + if !strings.HasPrefix(u.Password, "$2a$") || len(u.Password) != 60 { u.Password = hashPass(u.Password) } if u.OTPSeed == "" { @@ -70,9 +73,7 @@ func (u *User) Login(pass string, otp string) *Session { return nil } - password := []byte(pass + Salt) - hashedPassword := []byte(u.Password) - err := bcrypt.CompareHashAndPassword(hashedPassword, password) + err := verifyPassword(u.Password, pass) if err == nil && u.ID != 0 { s := u.GetActiveSession() if s == nil { @@ -163,7 +164,7 @@ func (u *User) GetDashboardMenu() (menus []DashboardMenu) { // HasAccess returns the user level permission to a model. The modelName // the the URL of the model func (u *User) HasAccess(modelName string) UserPermission { - Trail(WARNING, "User.HasAccess will be deprecated in version 0.6.0. Use User.GetAccess instead.") + Trail(WARNING, "User.HasAccess was deprecated in version 0.6.0. Use User.GetAccess instead.") return u.hasAccess(modelName) } From 08a60b22b9babb7e4b5c436ba3268786b86601e9 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 25 Nov 2022 13:35:21 +0400 Subject: [PATCH 019/109] BUG FIXES: auth dAPI and send email dAPI --- auth.go | 36 ++++++++++++++++++++++++++++++++++-- d_api_change_password.go | 2 +- d_api_reset_password.go | 18 +++++++++--------- forgot_password_handler.go | 15 +++++++++------ global.go | 4 ++-- openapi/auth_paths.go | 11 ++++------- send_email.go | 5 +++-- 7 files changed, 62 insertions(+), 29 deletions(-) diff --git a/auth.go b/auth.go index ed958d31..6587529c 100644 --- a/auth.go +++ b/auth.go @@ -707,11 +707,17 @@ func getSession(r *http.Request) string { // user from a request func GetRemoteIP(r *http.Request) string { ips := r.Header.Get("X-Forwarded-For") + splitIps := strings.Split(ips, ",") - if len(splitIps) > 0 { + if ips != "" { + // trim IP list + for i := range splitIps { + splitIps[i] = strings.TrimSpace(splitIps[i]) + } + // get last IP in list since ELB prepends other user defined IPs, meaning the last one is the actual client IP. - netIP := net.ParseIP(splitIps[len(splitIps)-1]) + netIP := net.ParseIP(splitIps[0]) if netIP != nil { return netIP.String() } @@ -734,6 +740,32 @@ func GetRemoteIP(r *http.Request) string { return r.RemoteAddr } +// GetHostName is a function that returns the host name from a request +func GetHostName(r *http.Request) string { + host := r.Header.Get("X-Forwarded-Host") + if host != "" { + return host + } + return r.Host +} + +// GetSchema is a function that returns the schema for a request (http, https) +func GetSchema(r *http.Request) string { + schema := r.Header.Get("X-Forwarded-Proto") + if schema != "" { + return schema + } + + if r.URL.Scheme != "" { + return r.URL.Scheme + } + + if r.TLS != nil { + return "https" + } + return "http" +} + func verifyPassword(hash string, plain string) error { password := []byte(plain + Salt) hashedPassword := []byte(hash) diff --git a/d_api_change_password.go b/d_api_change_password.go index 19a07171..81d1bd30 100644 --- a/d_api_change_password.go +++ b/d_api_change_password.go @@ -7,7 +7,7 @@ func dAPIChangePasswordHandler(w http.ResponseWriter, r *http.Request, s *Sessio w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "", + "err_msg": "User not logged in", }) return } diff --git a/d_api_reset_password.go b/d_api_reset_password.go index 124046dd..9cc79ae6 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -7,17 +7,17 @@ import ( func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Get parameters - username := r.FormValue("username") + uid := r.FormValue("uid") email := r.FormValue("email") otp := r.FormValue("otp") password := r.FormValue("password") // check if there is an email or a username - if username == "" && email == "" { + if email == "" && uid == "" { w.WriteHeader(400) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "No username nor email", + "err_msg": "No email or uid", }) // log the request go func() { @@ -33,10 +33,10 @@ func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session // get user user := User{} - if username != "" { - Get(&user, "username = ? AND active = ?", username, true) - } else { + if email != "" { Get(&user, "email = ? AND active = ?", email, true) + } else { + Get(&user, "id = ? AND active = ?", uid, true) } // log the request @@ -50,11 +50,11 @@ func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session }() // check if the user exists and active - if user.ID == 0 || (user.ExpiresOn != nil || user.ExpiresOn.After(time.Now())) { + if user.ID == 0 || (user.ExpiresOn != nil && user.ExpiresOn.After(time.Now())) { w.WriteHeader(404) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "username or email do not match any active user", + "err_msg": "email or uid do not match any active user", }) // log the request go func() { @@ -71,7 +71,7 @@ func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session // If there is no otp, then we assume this is a request to send a password // reset email if otp == "" { - err := forgotPasswordHandler(&s.User, r, CustomResetPasswordLink, ResetPasswordMessage) + err := forgotPasswordHandler(&user, r, CustomResetPasswordLink, ResetPasswordMessage) if err != nil { w.WriteHeader(403) diff --git a/forgot_password_handler.go b/forgot_password_handler.go index 0428f7d4..0d99b1ce 100644 --- a/forgot_password_handler.go +++ b/forgot_password_handler.go @@ -13,7 +13,7 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er return fmt.Errorf("unable to reset password, the user does not have an email") } if msg == "" { - msg = `Dear {NAME}, + msg = `

Dear {NAME},

Have you forgotten your password to access {WEBSITE}. Don't worry we got your back. Please follow the link below to reset your password. @@ -32,7 +32,7 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er var host string var allowedHost string var err error - if host, _, err = net.SplitHostPort(r.Host); err != nil { + if host, _, err = net.SplitHostPort(GetHostName(r)); err != nil { host = r.Host } for _, v := range strings.Split(AllowedHosts, ",") { @@ -41,19 +41,21 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er } if allowedHost == host { allowed = true + break } } + host = GetHostName(r) if !allowed { Trail(CRITICAL, "Reset password request for host: (%s) which is not in AllowedHosts settings", host) return nil } - urlParts := strings.Split(r.Header.Get("origin"), "://") + schema := GetSchema(r) if link == "" { - link = "{PROTOCOL}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" + link = "{SCHEMA}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" } - link = strings.ReplaceAll(link, "{PROTOCOL}", urlParts[0]) - link = strings.ReplaceAll(link, "{HOST}", RootURL) + link = strings.ReplaceAll(link, "{SCHEMA}", schema) + link = strings.ReplaceAll(link, "{HOST}", host) link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) link = strings.ReplaceAll(link, "{OTP}", u.GetOTP()) @@ -61,6 +63,7 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er msg = strings.ReplaceAll(msg, "{WEBSITE}", SiteName) msg = strings.ReplaceAll(msg, "{URL}", link) subject := "Password reset for " + SiteName + err = SendEmail([]string{u.Email}, []string{}, []string{}, subject, msg) return err diff --git a/global.go b/global.go index e39fa500..044e8ce9 100644 --- a/global.go +++ b/global.go @@ -80,7 +80,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.1" +const Version = "0.9.2" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. @@ -396,7 +396,7 @@ var SignupValidationHandler func(user *User) error // CustomResetPasswordLink is the link sent to the user's email to reset their password // the string may include the following place holder: -// "{PROTOCOL}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" +// "{SCHEMA}://{HOST}/resetpassword?u={USER_ID}&key={OTP}" var CustomResetPasswordLink = "" // ResetPasswordMessage is a message that can be sent to the user email when diff --git a/openapi/auth_paths.go b/openapi/auth_paths.go index a8c73bee..f3e49ca5 100644 --- a/openapi/auth_paths.go +++ b/openapi/auth_paths.go @@ -334,9 +334,9 @@ func getAuthPaths() map[string]Path { }, Parameters: []Parameter{ { - Name: "username", + Name: "uid", In: "query", - Description: "Username or email is required", + Description: "Email or uid is required", Schema: &SchemaObject{ Type: "string", }, @@ -344,8 +344,7 @@ func getAuthPaths() map[string]Path { { Name: "email", In: "query", - Required: true, - Description: "Username or email is required", + Description: "Email or uid is required", Schema: &SchemaObject{ Type: "string", }, @@ -353,7 +352,6 @@ func getAuthPaths() map[string]Path { { Name: "password", In: "query", - Required: true, Description: "New password which is required in the second step with the OTP", Schema: &SchemaObject{ Type: "string", @@ -362,8 +360,7 @@ func getAuthPaths() map[string]Path { { Name: "otp", In: "query", - Required: true, - Description: "OTP is required in the second step with the new password", + Description: "OTP is required in the second step with a new password", Schema: &SchemaObject{ Type: "string", }, diff --git a/send_email.go b/send_email.go index 5d9bbd2b..d105498e 100644 --- a/send_email.go +++ b/send_email.go @@ -30,7 +30,8 @@ func SendEmail(to, cc, bcc []string, subject, body string, attachments ...string // prepare body by splitting it into lines of length 73 followed by = body = strings.ReplaceAll(body, "\n", "
") - body = strings.Join(splitString(body, 73), "=\n") + body = strings.ReplaceAll(body, "=", "=3D") + body = strings.Join(splitString(body, 73), "=\r\n") // Construct the email MIME := "MIME-version: 1.0;\r\nContent-Type: text/html; charset=\"utf-8\";\r\nContent-Transfer-Encoding: quoted-printable\r\n" @@ -70,7 +71,7 @@ func SendEmail(to, cc, bcc []string, subject, body string, attachments ...string msg += "Subject: " + subject + "\r\n" msg += MIME + "\r\n" msg += body - msg += "\r\n" + msg += "\r\n\r\n" // Append CC and BCC if cc != nil { to = append(to, cc...) From 4eb601b251d4bfabc4a01a8d1a06079e4ed9894a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 25 Nov 2022 13:42:31 +0400 Subject: [PATCH 020/109] add go install to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 07fa8ae3..e7859081 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ Social Media: ## Installation ```bash -$ go get -u github.com/uadmin/uadmin/... +go get -u github.com/uadmin/uadmin/ +go install github.com/uadmin/uadmin/cmd/uadmin@latest ``` To test if your installation is fine, run the `uadmin` command line: From ad0276bc8572f3e0e1aa4118458b7649699b44d1 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 26 Nov 2022 09:42:45 +0400 Subject: [PATCH 021/109] DOCS: corrected SchemaCategory docs --- register.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/register.go b/register.go index b5cd1fd2..2cf25253 100644 --- a/register.go +++ b/register.go @@ -19,8 +19,8 @@ type HideInDashboarder interface { HideInDashboard() bool } -// SchemaCategory used to check if a model should be hidden in -// dashboard +// SchemaCategory provides a default category for the model. This can be +// customized later from the UI type SchemaCategory interface { SchemaCategory() string } From 0da272fbcbb0d95285d224dfbf1657322ecb3d38 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 27 Nov 2022 12:25:43 +0400 Subject: [PATCH 022/109] add deprecated to model schema --- get_schema.go | 1 + openapi.go | 29 +++++++++++++++++++++++++++++ openapi/schema_object.go | 4 +++- schema.go | 3 +++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/get_schema.go b/get_schema.go index 281003f0..212c966d 100644 --- a/get_schema.go +++ b/get_schema.go @@ -136,6 +136,7 @@ func getSchema(a interface{}) (s ModelSchema, ok bool) { _, f.Approval = tagMap["approval"] _, f.WebCam = tagMap["webcam"] _, f.Stringer = tagMap["stringer"] + _, f.Deprecated = tagMap["deprecated"] f.Min = tagMap["min"] f.Max = tagMap["max"] f.Format = tagMap["format"] diff --git a/openapi.go b/openapi.go index 1e150097..92aa7d29 100644 --- a/openapi.go +++ b/openapi.go @@ -162,6 +162,28 @@ func GenerateOpenAPISchema() { fields[v.Fields[i].Name].Description = v.Fields[i].Help fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName + fields[v.Fields[i].Name].ReadOnly = v.Fields[i].ReadOnly != "" + fields[v.Fields[i].Name].Pattern = v.Fields[i].Pattern + fields[v.Fields[i].Name].Format = func() string { + switch v.Fields[i].Type { + case cDATE: + return "date-time" + case cPASSWORD: + return "password" + case cEMAIL: + return "email" + case cHTML: + return "html" + default: + return "" + } + }() + fields[v.Fields[i].Name].Deprecated = func() *bool { + if v.Fields[i].Deprecated { + return &v.Fields[i].Deprecated + } + return nil + }() if val, ok := v.Fields[i].Max.(string); ok && val != "" { fields[v.Fields[i].Name].Maximum, _ = strconv.Atoi(val) } @@ -172,6 +194,13 @@ func GenerateOpenAPISchema() { fields[v.Fields[i].Name].AllOf[1].Description = v.Fields[i].Help fields[v.Fields[i].Name].AllOf[1].Default = v.Fields[i].DefaultValue fields[v.Fields[i].Name].AllOf[1].Title = v.Fields[i].DisplayName + fields[v.Fields[i].Name].ReadOnly = v.Fields[i].ReadOnly != "" + fields[v.Fields[i].Name].Deprecated = func() *bool { + if v.Fields[i].Deprecated { + return &v.Fields[i].Deprecated + } + return nil + }() } // Add parameters diff --git a/openapi/schema_object.go b/openapi/schema_object.go index bdae2e6b..7514c92b 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -10,7 +10,8 @@ type SchemaObject struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Default string `json:"default,omitempty"` - ReadOnly bool `json:"ReadOnly,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + Format string `json:"format,omitempty"` Examples []Example `json:"examples,omitempty"` Items *SchemaObject `json:"items,omitempty"` Properties map[string]*SchemaObject `json:"properties,omitempty"` @@ -22,6 +23,7 @@ type SchemaObject struct { OneOf []*SchemaObject `json:"oneOf,omitempty"` AllOf []*SchemaObject `json:"allOf,omitempty"` Const interface{} `json:"x-const,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` XFilters []XModifier `json:"x-filter,omitempty"` XAggregator []XModifier `json:"x-aggregator,omitempty"` } diff --git a/schema.go b/schema.go index 07641f09..79f054e1 100644 --- a/schema.go +++ b/schema.go @@ -146,6 +146,7 @@ type F struct { ApprovalID uint WebCam bool Stringer bool + Deprecated bool } // MarshalJSON customizes F json export @@ -191,6 +192,7 @@ func (f F) MarshalJSON() ([]byte, error) { ApprovalID uint WebCam bool Stringer bool + Deprecated bool }{ Name: f.Name, DisplayName: f.DisplayName, @@ -244,6 +246,7 @@ func (f F) MarshalJSON() ([]byte, error) { ApprovalID: f.ApprovalID, WebCam: f.WebCam, Stringer: f.Stringer, + Deprecated: f.Deprecated, }) } From 055825f2b8b6b39530ad46e2c9dae7c4ffee5d5c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 27 Nov 2022 12:32:42 +0400 Subject: [PATCH 023/109] add deprecated to model schema --- get_schema.go | 1 + 1 file changed, 1 insertion(+) diff --git a/get_schema.go b/get_schema.go index 212c966d..b4bca0d2 100644 --- a/get_schema.go +++ b/get_schema.go @@ -136,6 +136,7 @@ func getSchema(a interface{}) (s ModelSchema, ok bool) { _, f.Approval = tagMap["approval"] _, f.WebCam = tagMap["webcam"] _, f.Stringer = tagMap["stringer"] + Trail(DEBUG, "deprecated: %v", tagMap["deprecated"]) _, f.Deprecated = tagMap["deprecated"] f.Min = tagMap["min"] f.Max = tagMap["max"] From 8d8454356843b25ad20f53ba4724f6dd9a0b8a62 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 27 Nov 2022 13:03:38 +0400 Subject: [PATCH 024/109] add deprecated to model schema --- get_schema.go | 1 - openapi.go | 14 ++++++++++++-- openapi/schema_object.go | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/get_schema.go b/get_schema.go index b4bca0d2..212c966d 100644 --- a/get_schema.go +++ b/get_schema.go @@ -136,7 +136,6 @@ func getSchema(a interface{}) (s ModelSchema, ok bool) { _, f.Approval = tagMap["approval"] _, f.WebCam = tagMap["webcam"] _, f.Stringer = tagMap["stringer"] - Trail(DEBUG, "deprecated: %v", tagMap["deprecated"]) _, f.Deprecated = tagMap["deprecated"] f.Min = tagMap["min"] f.Max = tagMap["max"] diff --git a/openapi.go b/openapi.go index 92aa7d29..50c03ea9 100644 --- a/openapi.go +++ b/openapi.go @@ -162,7 +162,12 @@ func GenerateOpenAPISchema() { fields[v.Fields[i].Name].Description = v.Fields[i].Help fields[v.Fields[i].Name].Default = v.Fields[i].DefaultValue fields[v.Fields[i].Name].Title = v.Fields[i].DisplayName - fields[v.Fields[i].Name].ReadOnly = v.Fields[i].ReadOnly != "" + fields[v.Fields[i].Name].ReadOnly = func() *bool { + if val := v.Fields[i].ReadOnly != ""; val { + return &val + } + return nil + }() fields[v.Fields[i].Name].Pattern = v.Fields[i].Pattern fields[v.Fields[i].Name].Format = func() string { switch v.Fields[i].Type { @@ -194,7 +199,12 @@ func GenerateOpenAPISchema() { fields[v.Fields[i].Name].AllOf[1].Description = v.Fields[i].Help fields[v.Fields[i].Name].AllOf[1].Default = v.Fields[i].DefaultValue fields[v.Fields[i].Name].AllOf[1].Title = v.Fields[i].DisplayName - fields[v.Fields[i].Name].ReadOnly = v.Fields[i].ReadOnly != "" + fields[v.Fields[i].Name].ReadOnly = func() *bool { + if val := v.Fields[i].ReadOnly != ""; val { + return &val + } + return nil + }() fields[v.Fields[i].Name].Deprecated = func() *bool { if v.Fields[i].Deprecated { return &v.Fields[i].Deprecated diff --git a/openapi/schema_object.go b/openapi/schema_object.go index 7514c92b..8aa308cf 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -10,7 +10,7 @@ type SchemaObject struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Default string `json:"default,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty"` Format string `json:"format,omitempty"` Examples []Example `json:"examples,omitempty"` Items *SchemaObject `json:"items,omitempty"` From e2b5491bd05a4e156414e486f258dcecd516ce66 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 27 Nov 2022 13:25:19 +0400 Subject: [PATCH 025/109] cange control paprameters for dAPI from array to string --- openapi/generate_schema.go | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 27dc7474..d4d8b53c 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -173,10 +173,7 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", }, Examples: map[string]Example{ "multiColumn": { @@ -192,10 +189,7 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", }, Examples: map[string]Example{ "multiColumn": { @@ -219,10 +213,7 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", }, Examples: map[string]Example{ "simple": { @@ -259,10 +250,7 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", }, Examples: map[string]Example{ "getGroupName": { @@ -355,10 +343,7 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", }, Examples: map[string]Example{ "simple": { From 9a575541d505bb63d2f1c003665793201513f73e Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sun, 27 Nov 2022 13:56:11 +0400 Subject: [PATCH 026/109] Add custom tags to OpenAPI schema --- openapi.go | 80 +++++++++++++++----------------------- openapi/generate_schema.go | 2 + openapi/parameter.go | 1 + 3 files changed, 34 insertions(+), 49 deletions(-) diff --git a/openapi.go b/openapi.go index 50c03ea9..5dff28e6 100644 --- a/openapi.go +++ b/openapi.go @@ -33,6 +33,30 @@ func GenerateOpenAPISchema() { required := []string{} parameters := []openapi.Parameter{} writeParameters := []openapi.Parameter{} + + // Add tag to schema if it doesn't exist + tag := "Other" + if v.Category != "" { + tag = v.Category + + // check if it exists + tagExists := false + for i := range s.Tags { + if s.Tags[i].Name == tag { + tagExists = true + break + } + + // if it doesn't exist, add it + if !tagExists { + s.Tags = append(s.Tags, openapi.Tag{ + Name: tag, + Description: "CRUD APIs for " + tag + " models", + }) + } + } + } + for i := range v.Fields { // Determine data type fields[v.Fields[i].Name] = func() *openapi.SchemaObject { @@ -380,13 +404,7 @@ func GenerateOpenAPISchema() { Summary: "Read one " + v.DisplayName, Description: "Read one " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record", @@ -427,13 +445,7 @@ func GenerateOpenAPISchema() { Summary: "Read many " + v.DisplayName, Description: "Read many " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records", @@ -495,13 +507,7 @@ func GenerateOpenAPISchema() { Summary: "Add one " + v.DisplayName, Description: "Add one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record added", @@ -537,13 +543,7 @@ func GenerateOpenAPISchema() { Summary: "Edit one " + v.DisplayName, Description: "Edit one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record edited", @@ -578,13 +578,7 @@ func GenerateOpenAPISchema() { Summary: "Edit many " + v.DisplayName, Description: "Edit many " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records edited", @@ -619,13 +613,7 @@ func GenerateOpenAPISchema() { Summary: "Delete one " + v.DisplayName, Description: "Delete one " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record deleted", @@ -660,13 +648,7 @@ func GenerateOpenAPISchema() { Summary: "Delete many " + v.DisplayName, Description: "Delete many " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records deleted", diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index d4d8b53c..e958d07b 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -233,8 +233,10 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, AllowEmptyValue: true, + Default: "", Schema: &SchemaObject{ Type: "string", + Enum: []interface{}{"", "true"}, }, Examples: map[string]Example{ "getDeleted": { diff --git a/openapi/parameter.go b/openapi/parameter.go index 7b8376ef..f179eb38 100644 --- a/openapi/parameter.go +++ b/openapi/parameter.go @@ -15,4 +15,5 @@ type Parameter struct { Example *Example `json:"example,omitempty"` Examples map[string]Example `json:"examples,omitempty"` Content map[string]MediaType `json:"content,omitempty"` + Default interface{} `json:"default,omitempty"` } From ac55711aea19a54e7beb5d26478b986ce5e623b6 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 28 Nov 2022 06:40:11 +0400 Subject: [PATCH 027/109] BUG FIX: Remove duplicate records in OpenAPI tags --- openapi.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openapi.go b/openapi.go index 5dff28e6..e13520e4 100644 --- a/openapi.go +++ b/openapi.go @@ -46,14 +46,14 @@ func GenerateOpenAPISchema() { tagExists = true break } + } - // if it doesn't exist, add it - if !tagExists { - s.Tags = append(s.Tags, openapi.Tag{ - Name: tag, - Description: "CRUD APIs for " + tag + " models", - }) - } + // if it doesn't exist, add it + if !tagExists { + s.Tags = append(s.Tags, openapi.Tag{ + Name: tag, + Description: "CRUD APIs for " + tag + " models", + }) } } From d3891e5d99c6bcbb6d4b75031eeed592b6e9f198 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 28 Nov 2022 15:11:53 +0400 Subject: [PATCH 028/109] add default empty string to parameters in OpenAPI schema --- openapi/generate_schema.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index e958d07b..a1bac3a7 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -172,6 +172,7 @@ func GenerateBaseSchema() *Schema { Description: "Sort the results. Use '-' for descending order and comma for more field", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -188,6 +189,7 @@ func GenerateBaseSchema() *Schema { Description: "Selecting fields to return in results", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -212,6 +214,7 @@ func GenerateBaseSchema() *Schema { Description: "Groups rows that have the same values into summary rows", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -251,6 +254,7 @@ func GenerateBaseSchema() *Schema { Description: "Joins results from another model based on a foreign key", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -271,6 +275,7 @@ func GenerateBaseSchema() *Schema { Description: "Returns results from M2M fields", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", Description: "0=don't get, 1/fill=get full records, id=get ids only", @@ -292,6 +297,7 @@ func GenerateBaseSchema() *Schema { Description: "Searches all string fields marked as Searchable", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -302,6 +308,7 @@ func GenerateBaseSchema() *Schema { Description: "Fills the data from foreign keys", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -318,6 +325,7 @@ func GenerateBaseSchema() *Schema { Description: "Used in operation `method` to redirect the user to the specified path after the request. Value of `$back` will return the user back to the page", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -328,6 +336,7 @@ func GenerateBaseSchema() *Schema { Description: "Returns the API call execution time in milliseconds", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, @@ -344,6 +353,7 @@ func GenerateBaseSchema() *Schema { Description: "OR operator with multiple queries in the format of field=value. This `|` is used to separate the query parts and `+` is used for nested `AND` inside the the `OR` statement.", Required: false, AllowReserved: true, + Default: "", Schema: &SchemaObject{ Type: "string", }, From 7d3d5489de87bdcd30b3531aa5092535a29ff754 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 28 Nov 2022 15:14:15 +0400 Subject: [PATCH 029/109] add enums to OpenAPI schema parametes that need it --- openapi/generate_schema.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index a1bac3a7..99ba9991 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -278,6 +278,7 @@ func GenerateBaseSchema() *Schema { Default: "", Schema: &SchemaObject{ Type: "string", + Enum: []interface{}{"0", "fill", "id"}, Description: "0=don't get, 1/fill=get full records, id=get ids only", }, Examples: map[string]Example{ @@ -311,6 +312,7 @@ func GenerateBaseSchema() *Schema { Default: "", Schema: &SchemaObject{ Type: "string", + Enum: []interface{}{"true", "false"}, }, Examples: map[string]Example{ "getDeleted": { @@ -338,6 +340,7 @@ func GenerateBaseSchema() *Schema { AllowReserved: true, Default: "", Schema: &SchemaObject{ + Enum: []interface{}{"true", "false"}, Type: "string", }, Examples: map[string]Example{ From fb182c4a93ff687e1abb85a1803f633f0e60bed0 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 28 Nov 2022 15:24:51 +0400 Subject: [PATCH 030/109] OpenAPI schema: remove default from parameters and add it to schem --- openapi/generate_schema.go | 48 +++++++++++++++++++------------------- openapi/parameter.go | 1 - 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 99ba9991..8ff951dc 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -172,9 +172,9 @@ func GenerateBaseSchema() *Schema { Description: "Sort the results. Use '-' for descending order and comma for more field", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, Examples: map[string]Example{ "multiColumn": { @@ -189,9 +189,9 @@ func GenerateBaseSchema() *Schema { Description: "Selecting fields to return in results", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, Examples: map[string]Example{ "multiColumn": { @@ -214,9 +214,9 @@ func GenerateBaseSchema() *Schema { Description: "Groups rows that have the same values into summary rows", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, Examples: map[string]Example{ "simple": { @@ -236,10 +236,10 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, AllowEmptyValue: true, - Default: "", Schema: &SchemaObject{ - Type: "string", - Enum: []interface{}{"", "true"}, + Type: "string", + Default: "", + Enum: []interface{}{"", "true"}, }, Examples: map[string]Example{ "getDeleted": { @@ -254,9 +254,9 @@ func GenerateBaseSchema() *Schema { Description: "Joins results from another model based on a foreign key", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, Examples: map[string]Example{ "getGroupName": { @@ -275,10 +275,10 @@ func GenerateBaseSchema() *Schema { Description: "Returns results from M2M fields", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ Type: "string", Enum: []interface{}{"0", "fill", "id"}, + Default: "", Description: "0=don't get, 1/fill=get full records, id=get ids only", }, Examples: map[string]Example{ @@ -298,9 +298,9 @@ func GenerateBaseSchema() *Schema { Description: "Searches all string fields marked as Searchable", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, }, "preload": { @@ -309,10 +309,10 @@ func GenerateBaseSchema() *Schema { Description: "Fills the data from foreign keys", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", - Enum: []interface{}{"true", "false"}, + Type: "string", + Default: "", + Enum: []interface{}{"true", "false"}, }, Examples: map[string]Example{ "getDeleted": { @@ -327,9 +327,9 @@ func GenerateBaseSchema() *Schema { Description: "Used in operation `method` to redirect the user to the specified path after the request. Value of `$back` will return the user back to the page", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, }, "stat": { @@ -338,10 +338,10 @@ func GenerateBaseSchema() *Schema { Description: "Returns the API call execution time in milliseconds", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Enum: []interface{}{"true", "false"}, - Type: "string", + Type: "string", + Default: "", + Enum: []interface{}{"true", "false"}, }, Examples: map[string]Example{ "getDeleted": { @@ -356,9 +356,9 @@ func GenerateBaseSchema() *Schema { Description: "OR operator with multiple queries in the format of field=value. This `|` is used to separate the query parts and `+` is used for nested `AND` inside the the `OR` statement.", Required: false, AllowReserved: true, - Default: "", Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, Examples: map[string]Example{ "simple": { diff --git a/openapi/parameter.go b/openapi/parameter.go index f179eb38..7b8376ef 100644 --- a/openapi/parameter.go +++ b/openapi/parameter.go @@ -15,5 +15,4 @@ type Parameter struct { Example *Example `json:"example,omitempty"` Examples map[string]Example `json:"examples,omitempty"` Content map[string]MediaType `json:"content,omitempty"` - Default interface{} `json:"default,omitempty"` } From b4971ff06d9059e16b79ec78445e04c2bea1318a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 29 Nov 2022 07:46:22 +0400 Subject: [PATCH 031/109] make OpenAPI schema human readable --- openapi.go | 2 +- openapi/schema_object.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openapi.go b/openapi.go index e13520e4..f6c19785 100644 --- a/openapi.go +++ b/openapi.go @@ -693,7 +693,7 @@ func GenerateOpenAPISchema() { } func getOpenAPIJSON(s *openapi.Schema) []byte { - buf, err := json.Marshal(*s) + buf, err := json.MarshalIndent(*s, "", " ") if err != nil { return nil } diff --git a/openapi/schema_object.go b/openapi/schema_object.go index 8aa308cf..8b4a2304 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -9,7 +9,7 @@ type SchemaObject struct { Required []string `json:"required,omitempty"` Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` - Default string `json:"default,omitempty"` + Default interface{} `json:"default,omitempty"` ReadOnly *bool `json:"readOnly,omitempty"` Format string `json:"format,omitempty"` Examples []Example `json:"examples,omitempty"` From 63a6c063a7f7e77ae115e1fd40c9d022b8685b62 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 5 Dec 2022 21:30:02 +0400 Subject: [PATCH 032/109] make file and image write parameters in OpenAPI schema binray --- openapi.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openapi.go b/openapi.go index f6c19785..f83d0d16 100644 --- a/openapi.go +++ b/openapi.go @@ -346,10 +346,6 @@ func GenerateOpenAPISchema() { fallthrough case cEMAIL: fallthrough - case cFILE: - fallthrough - case cIMAGE: - fallthrough case cHTML: fallthrough case cLINK: @@ -360,6 +356,13 @@ func GenerateOpenAPISchema() { return &openapi.SchemaObject{ Type: "string", } + case cFILE: + fallthrough + case cIMAGE: + return &openapi.SchemaObject{ + Type: "string", + Format: "binary", + } case cFK: fallthrough case cLIST: From a38fbb04db0c69a4a9ab15f1fd7a3cc39b8b1ffb Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 6 Dec 2022 15:56:16 +0400 Subject: [PATCH 033/109] change OpenAPI schema CSRF to header --- openapi/generate_schema.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 8ff951dc..51d660d9 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -138,9 +138,9 @@ func GenerateBaseSchema() *Schema { }, }, "CSRF": { - Name: "x-csrf-token", - In: "query", - Description: "Token for CSRF protection which should be set to the session token or JWT token", + Name: "X-CSRF-TOKEN", + In: "header", + Description: "Token for CSRF protection which should be set to the session token", Required: true, Schema: &SchemaObject{ Type: "string", From 34e1ff73a9358c926c6e99f89e4cb896a9535e4f Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 6 Dec 2022 22:33:14 +0400 Subject: [PATCH 034/109] try multipart parse before parse --- auth.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 6587529c..9c093efb 100644 --- a/auth.go +++ b/auth.go @@ -578,7 +578,10 @@ func getSession(r *http.Request) string { return r.FormValue("session") } if r.Method == "POST" { - r.ParseForm() + err := r.ParseMultipartForm(2 << 10) + if err != nil { + r.ParseForm() + } if r.FormValue("session") != "" { return r.FormValue("session") } From e30dec4ade146456c67e49b57b105aa645d1b4b3 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 7 Dec 2022 09:21:02 +0400 Subject: [PATCH 035/109] add empty enum option for OpenAPI schema --- openapi/generate_schema.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 51d660d9..46225210 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -277,7 +277,7 @@ func GenerateBaseSchema() *Schema { AllowReserved: true, Schema: &SchemaObject{ Type: "string", - Enum: []interface{}{"0", "fill", "id"}, + Enum: []interface{}{"", "0", "fill", "id"}, Default: "", Description: "0=don't get, 1/fill=get full records, id=get ids only", }, @@ -312,7 +312,7 @@ func GenerateBaseSchema() *Schema { Schema: &SchemaObject{ Type: "string", Default: "", - Enum: []interface{}{"true", "false"}, + Enum: []interface{}{"", "true", "false"}, }, Examples: map[string]Example{ "getDeleted": { @@ -341,7 +341,7 @@ func GenerateBaseSchema() *Schema { Schema: &SchemaObject{ Type: "string", Default: "", - Enum: []interface{}{"true", "false"}, + Enum: []interface{}{"", "true", "false"}, }, Examples: map[string]Example{ "getDeleted": { From 10c684a83d362b02d0332f5a8f2282009b78d246 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 7 Dec 2022 22:07:24 +0400 Subject: [PATCH 036/109] business logic now runs on dAPI add and edit --- d_api_add.go | 8 ++++++++ d_api_edit.go | 23 +++++++++++++++++------ d_api_upload.go | 18 +++++++++--------- db_helper.go | 7 +++++++ schema.go | 11 +++++++++++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/d_api_add.go b/d_api_add.go index 41bf8792..0f6d621b 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -154,6 +154,14 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { createAPIAddLog(q, args, GetDB().Config.NamingStrategy.ColumnName("", model.Type().Name()), createdIDs[i], s, r) } } + // Execute business logic + // if _, ok := model.Addr().Interface().(saver); ok { + // for _, id := range createdIDs { + // model, _ = NewModel(modelName, false) + // Get(model.Addr().Interface(), "id = ?", id) + // model.Addr().Interface().(saver).Save() + // } + // } } else { // Error: Unknown format ReturnJSON(w, r, map[string]interface{}{ diff --git a/d_api_edit.go b/d_api_edit.go index ada2444a..dfdc5eaa 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -103,9 +103,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { q, args := getFilters(r, params, tableName, &schema) modelArray, _ := NewModelArray(modelName, true) - if log { - db.Model(model.Interface()).Where(q, args...).Scan(modelArray.Interface()) - } + db.Model(model.Interface()).Where(q, args...).Scan(modelArray.Interface()) db = db.Model(model.Interface()).Where(q, args...).Updates(writeMap) if db.Error != nil { ReturnJSON(w, r, map[string]interface{}{ @@ -153,12 +151,20 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { createAPIEditLog(modelName, modelArray.Elem().Index(i).Interface(), &s.User, r) } } + + // Execute business logic + if _, ok := model.Addr().Interface().(saver); ok { + for i := 0; i < modelArray.Len(); i++ { + id := GetID(modelArray.Index(i)) + model, _ = NewModel(modelName, false) + Get(model.Addr().Interface(), "id = ?", id) + model.Addr().Interface().(saver).Save() + } + } } else if len(urlParts) == 3 { // Edit One m, _ := NewModel(modelName, true) - if log { - db.Model(model.Interface()).Where("id = ?", urlParts[2]).Scan(m.Interface()) - } + db.Model(model.Interface()).Where("id = ?", urlParts[2]).Scan(m.Interface()) db = db.Model(model.Interface()).Where("id = ?", urlParts[2]).Updates(writeMap) if db.Error != nil { ReturnJSON(w, r, map[string]interface{}{ @@ -199,6 +205,11 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { createAPIEditLog(modelName, m.Interface(), &s.User, r) } + // Execute business logic + if modelSaver, ok := m.Interface().(saver); ok { + modelSaver.Save() + } + returnDAPIJSON(w, r, map[string]interface{}{ "status": "ok", "rows_count": rowsAffected, diff --git a/d_api_upload.go b/d_api_upload.go index a5022f4c..dcbb93bf 100644 --- a/d_api_upload.go +++ b/d_api_upload.go @@ -11,22 +11,22 @@ func dAPIUpload(w http.ResponseWriter, r *http.Request, schema *ModelSchema) (ma return fileList, nil } + // make a list of files + kList := []string{} for k := range r.MultipartForm.File { + kList = append(kList, k) + } + + for _, k := range kList { // Process File - // Check if the file is type file or image - var field *F - for i := range schema.Fields { - if schema.Fields[i].ColumnName == k[1:] { - field = &schema.Fields[i] - r.MultipartForm.File[k[1:]] = r.MultipartForm.File[k] - break - } - } + var field *F = schema.FieldByColumnName(k[1:]) if field == nil { Trail(WARNING, "dAPIUpload received a file that has no field: %s", k) continue } + r.MultipartForm.File[k[1:]] = r.MultipartForm.File[k] + s := r.Context().Value(CKey("session")) var session *Session if s != nil { diff --git a/db_helper.go b/db_helper.go index f623b2ff..2406d56a 100644 --- a/db_helper.go +++ b/db_helper.go @@ -9,6 +9,13 @@ func fixQueryEnclosure(v string) string { return v } +func trimEnclosure(v string) string { + if Database.Type == "postgres" { + return strings.ReplaceAll(v, "\"", "") + } + return strings.ReplaceAll(v, "`", "") +} + func columnEnclosure() string { if Database.Type == "postgres" { return "\"" diff --git a/schema.go b/schema.go index 79f054e1..b728a486 100644 --- a/schema.go +++ b/schema.go @@ -39,6 +39,17 @@ func (s ModelSchema) FieldByName(a string) *F { return &F{} } +// FieldByName returns a field from a ModelSchema by name or nil if +// it doesn't exist +func (s ModelSchema) FieldByColumnName(a string) *F { + for i := range s.Fields { + if strings.EqualFold(s.Fields[i].ColumnName, a) { + return &s.Fields[i] + } + } + return nil +} + // GetFormTheme returns the theme for this model or the // global theme if there is no assigned theme for the model func (s *ModelSchema) GetFormTheme() string { From ab11b5fdf357f045c57fc966dcb81159c3398270 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 7 Dec 2022 22:12:02 +0400 Subject: [PATCH 037/109] limit dAPI all models to admin users only --- d_api.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/d_api.go b/d_api.go index 3ab72570..0b46670b 100644 --- a/d_api.go +++ b/d_api.go @@ -132,6 +132,13 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { return } if urlParts[0] == "$allmodels" { + if !s.User.Admin { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "access denied", + }) + } dAPIAllModelsHandler(w, r, s) return } From d1d44356877b196929e77743400c85cad0980e31 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 7 Dec 2022 23:05:17 +0400 Subject: [PATCH 038/109] Add REST-like API to dAPI --- d_api.go | 120 +++++++++++++++++++++++++++++++++--------------- d_api_add.go | 5 +- d_api_delete.go | 10 ++-- d_api_edit.go | 14 +++--- d_api_method.go | 16 +++---- d_api_read.go | 10 ++-- d_api_schema.go | 8 ++-- 7 files changed, 112 insertions(+), 71 deletions(-) diff --git a/d_api.go b/d_api.go index 0b46670b..ea9b9a70 100644 --- a/d_api.go +++ b/d_api.go @@ -3,6 +3,7 @@ package uadmin import ( "context" "net/http" + "strconv" "strings" ) @@ -115,14 +116,33 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { r.URL.Path = strings.TrimPrefix(r.URL.Path, RootURL+"api/d") r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") + r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") urlParts := strings.Split(r.URL.Path, "/") ctx := context.WithValue(r.Context(), CKey("dAPI"), true) r = r.WithContext(ctx) + // auth dAPI + if urlParts[0] == "auth" { + dAPIAuthHandler(w, r, s) + return + } + + if urlParts[0] == "$allmodels" { + if !s.User.Admin { + w.WriteHeader(http.StatusForbidden) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "access denied", + }) + } + dAPIAllModelsHandler(w, r, s) + return + } + // Check if there is no command and show help - if r.URL.Path == "" || r.URL.Path == "/" || len(urlParts) < 2 { + if r.URL.Path == "" || r.URL.Path == "help" { if s == nil { w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ @@ -131,24 +151,8 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { }) return } - if urlParts[0] == "$allmodels" { - if !s.User.Admin { - w.WriteHeader(http.StatusForbidden) - ReturnJSON(w, r, map[string]interface{}{ - "status": "error", - "err_msg": "access denied", - }) - } - dAPIAllModelsHandler(w, r, s) - return - } - w.Write([]byte(dAPIHelp)) - return - } - // auth dAPI - if urlParts[0] == "auth" { - dAPIAuthHandler(w, r, s) + w.Write([]byte(dAPIHelp)) return } @@ -160,6 +164,15 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { if urlParts[0] == k { modelExists = true model = v + + // add model to context + ctx := context.WithValue(r.Context(), CKey("modelName"), urlParts[0]) + r = r.WithContext(ctx) + + // trim model name from URL + r.URL.Path = strings.TrimPrefix(r.URL.Path, urlParts[0]) + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") + break } } @@ -171,69 +184,100 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { }) return } + //check command commandExists := false - for _, i := range []string{"read", "add", "edit", "delete", "schema", "method"} { - if urlParts[1] == i { - commandExists = true - break + command := "" + secondPartIsANumber := false + if len(urlParts) > 1 { + if _, err := strconv.Atoi(urlParts[1]); err == nil { + secondPartIsANumber = true + } + } + if len(urlParts) > 1 && !secondPartIsANumber { + for _, i := range []string{"read", "add", "edit", "delete", "schema", "method"} { + if urlParts[1] == i { + commandExists = true + command = i + + // trim command from URL + r.URL.Path = strings.TrimPrefix(r.URL.Path, urlParts[1]) + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") + + break + } + } + } else { + commandExists = true + switch r.Method { + case http.MethodGet: + command = "read" + case http.MethodPost: + command = "add" + case http.MethodPut: + command = "edit" + case http.MethodDelete: + command = "delete" } } + if !commandExists { w.WriteHeader(404) ReturnJSON(w, r, map[string]string{ "status": "error", - "err_msg": "Invalid command (" + urlParts[1] + ")", + "err_msg": "Invalid command (" + command + ")", }) return } - r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") - // Route the request to the correct handler based on the command - if urlParts[1] == "read" { + if command == "read" { + // check if there is a prequery if preQuery, ok := model.(APIPreQueryReader); ok && !preQuery.APIPreQueryRead(w, r) { } else { dAPIReadHandler(w, r, s) } return } - if urlParts[1] == "add" { + if command == "add" { if preQuery, ok := model.(APIPreQueryAdder); ok && !preQuery.APIPreQueryAdd(w, r) { } else { dAPIAddHandler(w, r, s) } + return } - if urlParts[1] == "edit" { + if command == "edit" { // check if there is a prequery if preQuery, ok := model.(APIPreQueryEditor); ok && !preQuery.APIPreQueryEdit(w, r) { } else { dAPIEditHandler(w, r, s) } + return } - if urlParts[1] == "delete" { + if command == "delete" { // check if there is a prequery if preQuery, ok := model.(APIPreQueryDeleter); ok && !preQuery.APIPreQueryDelete(w, r) { } else { dAPIDeleteHandler(w, r, s) } + return } - if urlParts[1] == "schema" { + if command == "schema" { // check if there is a prequery if preQuery, ok := model.(APIPreQuerySchemer); ok && !preQuery.APIPreQuerySchema(w, r) { } else { dAPISchemaHandler(w, r, s) } + return } - if urlParts[1] == "method" { + if command == "method" { dAPIMethodHandler(w, r, s) - } - - if r.URL.Query().Get("$next") != "" { - if strings.HasPrefix(r.URL.Query().Get("$next"), "$back") && r.Header.Get("Referer") != "" { - http.Redirect(w, r, r.Header.Get("Referer")+strings.TrimPrefix(r.URL.Query().Get("$next"), "$back"), http.StatusSeeOther) - } else { - http.Redirect(w, r, r.URL.Query().Get("$next"), http.StatusSeeOther) + if r.URL.Query().Get("$next") != "" { + if strings.HasPrefix(r.URL.Query().Get("$next"), "$back") && r.Header.Get("Referer") != "" { + http.Redirect(w, r, r.Header.Get("Referer")+strings.TrimPrefix(r.URL.Query().Get("$next"), "$back"), http.StatusSeeOther) + } else { + http.Redirect(w, r, r.URL.Query().Get("$next"), http.StatusSeeOther) + } } } } diff --git a/d_api_add.go b/d_api_add.go index 0f6d621b..a2e20662 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -13,8 +13,7 @@ import ( func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { var rowsCount int64 - urlParts := strings.Split(r.URL.Path, "/") - modelName := urlParts[0] + modelName := r.Context().Value(CKey("modelName")).(string) model, _ := NewModel(modelName, false) schema, _ := getSchema(modelName) tableName := schema.TableName @@ -77,7 +76,7 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { params["_"+k] = v } - if len(urlParts) == 2 { + if r.URL.Path == "" { // Add One/Many q, args, m2mFields := getAddFilters(params, &schema) diff --git a/d_api_delete.go b/d_api_delete.go index b4d9b9f8..a69676c4 100644 --- a/d_api_delete.go +++ b/d_api_delete.go @@ -10,7 +10,7 @@ import ( func dAPIDeleteHandler(w http.ResponseWriter, r *http.Request, s *Session) { var rowsCount int64 urlParts := strings.Split(r.URL.Path, "/") - modelName := urlParts[0] + modelName := r.Context().Value(CKey("modelName")).(string) model, _ := NewModel(modelName, false) schema, _ := getSchema(modelName) tableName := schema.TableName @@ -59,7 +59,7 @@ func dAPIDeleteHandler(w http.ResponseWriter, r *http.Request, s *Session) { log = logDeleter.APILogDelete(r) } - if len(urlParts) == 2 { + if r.URL.Path == "" { // Delete Multiple q, args := getFilters(r, params, tableName, &schema) @@ -125,15 +125,15 @@ func dAPIDeleteHandler(w http.ResponseWriter, r *http.Request, s *Session) { "status": "ok", "rows_count": rowsCount, }, params, "delete", model.Interface()) - } else if len(urlParts) == 3 { + } else if len(urlParts) == 1 { // Delete One m, _ := NewModel(modelName, true) db := GetDB() if log { - db.Model(model.Interface()).Where("id = ?", urlParts[2]).Scan(m.Interface()) + db.Model(model.Interface()).Where("id = ?", urlParts[0]).Scan(m.Interface()) } - db = db.Where("id = ?", urlParts[2]).Delete(model.Addr().Interface()) + db = db.Where("id = ?", urlParts[0]).Delete(model.Addr().Interface()) if db.Error != nil { ReturnJSON(w, r, map[string]interface{}{ "status": "error", diff --git a/d_api_edit.go b/d_api_edit.go index dfdc5eaa..33978034 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -9,7 +9,7 @@ import ( func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { urlParts := strings.Split(r.URL.Path, "/") - modelName := urlParts[0] + modelName := r.Context().Value(CKey("modelName")).(string) model, _ := NewModel(modelName, false) schema, _ := getSchema(modelName) tableName := schema.TableName @@ -98,7 +98,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { db := GetDB() - if len(urlParts) == 2 { + if r.URL.Path == "" { // Edit multiple q, args := getFilters(r, params, tableName, &schema) @@ -161,11 +161,11 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { model.Addr().Interface().(saver).Save() } } - } else if len(urlParts) == 3 { + } else if len(urlParts) == 1 { // Edit One m, _ := NewModel(modelName, true) - db.Model(model.Interface()).Where("id = ?", urlParts[2]).Scan(m.Interface()) - db = db.Model(model.Interface()).Where("id = ?", urlParts[2]).Updates(writeMap) + db.Model(model.Interface()).Where("id = ?", urlParts[0]).Scan(m.Interface()) + db = db.Model(model.Interface()).Where("id = ?", urlParts[0]).Updates(writeMap) if db.Error != nil { ReturnJSON(w, r, map[string]interface{}{ "status": "error", @@ -185,7 +185,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql := sqlDialect[Database.Type]["deleteM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - db = db.Exec(sql, urlParts[2]) + db = db.Exec(sql, urlParts[0]) if v == "" { continue @@ -196,7 +196,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { sql = sqlDialect[Database.Type]["insertM2M"] sql = strings.Replace(sql, "{TABLE1}", table1, -1) sql = strings.Replace(sql, "{TABLE2}", table2, -1) - db = db.Exec(sql, urlParts[2], id) + db = db.Exec(sql, urlParts[0], id) } } db.Commit() diff --git a/d_api_method.go b/d_api_method.go index cf7d3299..7f60a544 100644 --- a/d_api_method.go +++ b/d_api_method.go @@ -9,12 +9,12 @@ import ( func dAPIMethodHandler(w http.ResponseWriter, r *http.Request, s *Session) { urlParts := strings.Split(r.URL.Path, "/") - modelName := urlParts[0] + modelName := r.Context().Value(CKey("modelName")).(string) model, _ := NewModel(modelName, true) params := getURLArgs(r) - if len(urlParts) < 4 { + if len(urlParts) < 2 { w.WriteHeader(400) ReturnJSON(w, r, map[string]interface{}{ "status": "error", @@ -31,31 +31,31 @@ func dAPIMethodHandler(w http.ResponseWriter, r *http.Request, s *Session) { return } - f := model.MethodByName(urlParts[2]) + f := model.MethodByName(urlParts[0]) if !f.IsValid() { - f = model.Elem().MethodByName(urlParts[2]) + f = model.Elem().MethodByName(urlParts[0]) } if !f.IsValid() { w.WriteHeader(404) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Method (" + urlParts[2] + ") doesn't exist.", + "err_msg": "Method (" + urlParts[0] + ") doesn't exist.", }) return } - Get(model.Interface(), "id = ?", urlParts[3]) + Get(model.Interface(), "id = ?", urlParts[1]) if GetID(model) == 0 { w.WriteHeader(404) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "ID doesn't exist (" + urlParts[3] + ").", + "err_msg": "ID doesn't exist (" + urlParts[1] + ").", }) return } - ret := model.MethodByName(urlParts[2]).Call([]reflect.Value{}) + ret := model.MethodByName(urlParts[0]).Call([]reflect.Value{}) // Return if the method has a return value if len(ret) != 0 { diff --git a/d_api_read.go b/d_api_read.go index 0f231af3..12cf94ce 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -11,7 +11,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { var rowsCount int64 urlParts := strings.Split(r.URL.Path, "/") - modelName := urlParts[0] + modelName := r.Context().Value(CKey("modelName")).(string) model, _ := NewModel(modelName, false) params := getURLArgs(r) schema, _ := getSchema(modelName) @@ -52,7 +52,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { log = logReader.APILogRead(r) } - if len(urlParts) == 2 { + if r.URL.Path == "" { // Read Multiple var m interface{} @@ -193,10 +193,10 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { } }() return - } else if len(urlParts) == 3 { + } else if len(urlParts) == 1 { // Read One m, _ := NewModel(modelName, true) - Get(m.Interface(), "id = ?", urlParts[2]) + Get(m.Interface(), "id = ?", urlParts[0]) rowsCount = 0 var i interface{} @@ -215,7 +215,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { }, params, "read", model.Interface()) go func() { if log { - createAPIReadLog(modelName, int(GetID(m)), rowsCount, map[string]string{"id": urlParts[2]}, &s.User, r) + createAPIReadLog(modelName, int(GetID(m)), rowsCount, map[string]string{"id": urlParts[0]}, &s.User, r) } }() } else { diff --git a/d_api_schema.go b/d_api_schema.go index 28377316..d7778fee 100644 --- a/d_api_schema.go +++ b/d_api_schema.go @@ -3,13 +3,11 @@ package uadmin import ( "encoding/json" "net/http" - "strings" ) func dAPISchemaHandler(w http.ResponseWriter, r *http.Request, s *Session) { - urlParts := strings.Split(r.URL.Path, "/") - model, _ := NewModel(urlParts[0], false) - modelName := GetDB().Config.NamingStrategy.ColumnName("", model.Type().Name()) + modelName := r.Context().Value(CKey("modelName")).(string) + model, _ := NewModel(modelName, false) params := getURLArgs(r) // Check permission @@ -40,7 +38,7 @@ func dAPISchemaHandler(w http.ResponseWriter, r *http.Request, s *Session) { return } - schema, _ := getSchema(urlParts[0]) + schema, _ := getSchema(modelName) // Get Language lang := r.URL.Query().Get("language") From c54b29de4de1e1e1f65d75d141e1c9198e961624 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 00:17:34 +0400 Subject: [PATCH 039/109] OpenAPI schema exports only REST-like APIs from dAPI --- d_api_helper.go | 18 -- openapi.go | 465 +++++++++++++++++++++++++----------------------- 2 files changed, 238 insertions(+), 245 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index 62ce8239..37f64420 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -66,24 +66,6 @@ func getURLArgs(r *http.Request) map[string]string { return params } -// type dbScanner struct { -// Value interface{} -// } - -// func (d *dbScanner) Scan(src interface{}) error { -// d.Value = src -// return nil -// } - -// func makeResultReceiver(length int) []interface{} { -// result := make([]interface{}, 0, length) -// for i := 0; i < length; i++ { -// current := dbScanner{} -// result = append(result, ¤t.Value) -// } -// return result -// } - func getFilters(r *http.Request, params map[string]string, tableName string, schema *ModelSchema) (query string, args []interface{}) { qParts := []string{} args = []interface{}{} diff --git a/openapi.go b/openapi.go index f83d0d16..24bf68ea 100644 --- a/openapi.go +++ b/openapi.go @@ -32,7 +32,7 @@ func GenerateOpenAPISchema() { fields := map[string]*openapi.SchemaObject{} required := []string{} parameters := []openapi.Parameter{} - writeParameters := []openapi.Parameter{} + writeParameters := map[string]*openapi.SchemaObject{} // Add tag to schema if it doesn't exist tag := "Other" @@ -327,73 +327,66 @@ func GenerateOpenAPISchema() { continue } - writeParameters = append(writeParameters, func() openapi.Parameter { - return openapi.Parameter{ - Name: func() string { - if v.Fields[i].Type == cFK { - return "_" + v.Fields[i].ColumnName + "_id" - } else { - return "_" + v.Fields[i].ColumnName - } - }(), - In: "query", - Description: "Set value for " + v.Fields[i].DisplayName, - Schema: func() *openapi.SchemaObject { - switch v.Fields[i].Type { - case cSTRING: - fallthrough - case cCODE: - fallthrough - case cEMAIL: - fallthrough - case cHTML: - fallthrough - case cLINK: - fallthrough - case cMULTILINGUAL: - fallthrough - case cPASSWORD: - return &openapi.SchemaObject{ - Type: "string", - } - case cFILE: - fallthrough - case cIMAGE: - return &openapi.SchemaObject{ - Type: "string", - Format: "binary", - } - case cFK: - fallthrough - case cLIST: - fallthrough - case cMONEY: - return &openapi.SchemaObject{ - Type: "integer", - } - case cNUMBER: - fallthrough - case cPROGRESSBAR: - return &openapi.SchemaObject{ - Type: "number", - } - case cBOOL: - return &openapi.SchemaObject{ - Type: "boolean", - } - case cDATE: - return &openapi.SchemaObject{ - Type: "string", - } - default: - return &openapi.SchemaObject{ - Type: "string", - } - } - }(), + writeParameterName := func() string { + if v.Fields[i].Type == cFK { + return "_" + v.Fields[i].ColumnName + "_id" + } else { + return "_" + v.Fields[i].ColumnName } - }(), - ) + }() + writeParameters[writeParameterName] = func() *openapi.SchemaObject { + switch v.Fields[i].Type { + case cSTRING: + fallthrough + case cCODE: + fallthrough + case cEMAIL: + fallthrough + case cHTML: + fallthrough + case cLINK: + fallthrough + case cMULTILINGUAL: + fallthrough + case cPASSWORD: + return &openapi.SchemaObject{ + Type: "string", + } + case cFILE: + fallthrough + case cIMAGE: + return &openapi.SchemaObject{ + Type: "string", + Format: "binary", + } + case cFK: + fallthrough + case cLIST: + fallthrough + case cMONEY: + return &openapi.SchemaObject{ + Type: "integer", + } + case cNUMBER: + fallthrough + case cPROGRESSBAR: + return &openapi.SchemaObject{ + Type: "number", + } + case cBOOL: + return &openapi.SchemaObject{ + Type: "boolean", + } + case cDATE: + return &openapi.SchemaObject{ + Type: "string", + } + default: + return &openapi.SchemaObject{ + Type: "string", + } + } + }() // Add required fields if v.Fields[i].Required { @@ -403,11 +396,13 @@ func GenerateOpenAPISchema() { // Add dAPI paths // Read one - s.Paths[fmt.Sprintf("/api/d/%s/read/{id}", v.ModelName)] = openapi.Path{ - Summary: "Read one " + v.DisplayName, - Description: "Read one " + v.DisplayName, + s.Paths[fmt.Sprintf("/api/d/%s/{id}", v.ModelName)] = openapi.Path{ + Summary: "Single record operations for " + v.DisplayName, + Description: "Single record operations for " + v.DisplayName, Get: &openapi.Operation{ - Tags: []string{tag}, + Tags: []string{tag}, + Summary: "Read one record from " + v.DisplayName, + Description: "Read one record from " + v.DisplayName, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record", @@ -424,105 +419,78 @@ func GenerateOpenAPISchema() { }, }, }, - }, - Parameters: []openapi.Parameter{ - { - Ref: "#/components/parameters/PathID", - }, - { - Ref: "#/components/parameters/deleted", - }, - { - Ref: "#/components/parameters/m2m", - }, - { - Ref: "#/components/parameters/preload", - }, - { - Ref: "#/components/parameters/stat", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/deleted", + }, + { + Ref: "#/components/parameters/m2m", + }, + { + Ref: "#/components/parameters/preload", + }, + { + Ref: "#/components/parameters/stat", + }, }, }, - } - // Read Many - s.Paths[fmt.Sprintf("/api/d/%s/read", v.ModelName)] = openapi.Path{ - Summary: "Read many " + v.DisplayName, - Description: "Read many " + v.DisplayName, - Get: &openapi.Operation{ - Tags: []string{tag}, + Put: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Edit one record from " + v.DisplayName, + Description: "Edit one record from " + v.DisplayName, + RequestBody: &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Schema: &openapi.SchemaObject{ + Type: "Object", + Properties: writeParameters, + }, + }, + }, + }, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " records", + Description: v.DisplayName + " record edited", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ Type: "object", Properties: map[string]*openapi.SchemaObject{ - "result": { - Type: "array", - Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Name}, - }, - "status": {Type: "string"}, + "rows_count": {Type: "integer"}, + "status": {Type: "string"}, }, }, }, }, }, }, - }, - Parameters: append(parameters, []openapi.Parameter{ - { - Ref: "#/components/parameters/limit", - }, - { - Ref: "#/components/parameters/offset", - }, - { - Ref: "#/components/parameters/order", - }, - { - Ref: "#/components/parameters/fields", - }, - { - Ref: "#/components/parameters/groupBy", - }, - { - Ref: "#/components/parameters/deleted", - }, - { - Ref: "#/components/parameters/join", - }, - { - Ref: "#/components/parameters/m2m", - }, - { - Ref: "#/components/parameters/q", - }, - { - Ref: "#/components/parameters/stat", - }, - { - Ref: "#/components/parameters/or", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, }, - }...), - } - // Add One - s.Paths[fmt.Sprintf("/api/d/%s/add", v.ModelName)] = openapi.Path{ - Summary: "Add one " + v.DisplayName, - Description: "Add one " + v.DisplayName, - Post: &openapi.Operation{ - Tags: []string{tag}, + }, + Delete: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Delete one " + v.DisplayName, + Description: "Delete one " + v.DisplayName, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record added", + Description: v.DisplayName + " record deleted", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ Type: "object", Properties: map[string]*openapi.SchemaObject{ - "id": { - Type: "array", - Items: &openapi.SchemaObject{Type: "integer"}, - }, "rows_count": {Type: "integer"}, "status": {Type: "string"}, }, @@ -531,65 +499,108 @@ func GenerateOpenAPISchema() { }, }, }, - }, - Parameters: append([]openapi.Parameter{ - { - Ref: "#/components/parameters/CSRF", - }, - { - Ref: "#/components/parameters/stat", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, }, - }, writeParameters...), + }, } - // Edit One - s.Paths[fmt.Sprintf("/api/d/%s/edit/{id}", v.ModelName)] = openapi.Path{ - Summary: "Edit one " + v.DisplayName, - Description: "Edit one " + v.DisplayName, - Post: &openapi.Operation{ - Tags: []string{tag}, + // Read Many + s.Paths[fmt.Sprintf("/api/d/%s", v.ModelName)] = openapi.Path{ + Summary: "Add one and multi-record operations for " + v.DisplayName, + Description: "Add one and multi-record operations for " + v.DisplayName, + Get: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Read many records from " + v.DisplayName, + Description: "Read many records from " + v.DisplayName, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record edited", + Description: v.DisplayName + " records", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ Type: "object", Properties: map[string]*openapi.SchemaObject{ - "rows_count": {Type: "integer"}, - "status": {Type: "string"}, + "result": { + Type: "array", + Items: &openapi.SchemaObject{Ref: "#/components/schemas/" + v.Name}, + }, + "status": {Type: "string"}, }, }, }, }, }, }, + Parameters: append(parameters, []openapi.Parameter{ + { + Ref: "#/components/parameters/limit", + }, + { + Ref: "#/components/parameters/offset", + }, + { + Ref: "#/components/parameters/order", + }, + { + Ref: "#/components/parameters/fields", + }, + { + Ref: "#/components/parameters/groupBy", + }, + { + Ref: "#/components/parameters/deleted", + }, + { + Ref: "#/components/parameters/join", + }, + { + Ref: "#/components/parameters/m2m", + }, + { + Ref: "#/components/parameters/q", + }, + { + Ref: "#/components/parameters/stat", + }, + { + Ref: "#/components/parameters/or", + }, + }...), }, - Parameters: append([]openapi.Parameter{ - { - Ref: "#/components/parameters/PathID", - }, - { - Ref: "#/components/parameters/CSRF", - }, - { - Ref: "#/components/parameters/stat", - }, - }, writeParameters...), - } - // Edit Many - s.Paths[fmt.Sprintf("/api/d/%s/edit", v.ModelName)] = openapi.Path{ - Summary: "Edit many " + v.DisplayName, - Description: "Edit many " + v.DisplayName, Post: &openapi.Operation{ - Tags: []string{tag}, + Tags: []string{tag}, + Summary: "Add one " + v.DisplayName, + Description: "Add one " + v.DisplayName, + RequestBody: &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Schema: &openapi.SchemaObject{ + Type: "Object", + Properties: writeParameters, + }, + }, + }, + }, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " records edited", + Description: v.DisplayName + " record added", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ Type: "object", Properties: map[string]*openapi.SchemaObject{ + "id": { + Type: "array", + Items: &openapi.SchemaObject{Type: "integer"}, + }, "rows_count": {Type: "integer"}, "status": {Type: "string"}, }, @@ -598,28 +609,32 @@ func GenerateOpenAPISchema() { }, }, }, - }, - Parameters: append([]openapi.Parameter{ - { - Ref: "#/components/parameters/PathID", - }, - { - Ref: "#/components/parameters/CSRF", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, }, - { - Ref: "#/components/parameters/stat", + }, + Put: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Edit many " + v.DisplayName, + Description: "Edit many " + v.DisplayName, + RequestBody: &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Schema: &openapi.SchemaObject{ + Type: "Object", + Properties: writeParameters, + }, + }, + }, }, - }, append(writeParameters, parameters...)...), - } - // Delete One - s.Paths[fmt.Sprintf("/api/d/%s/delete/{id}", v.ModelName)] = openapi.Path{ - Summary: "Delete one " + v.DisplayName, - Description: "Delete one " + v.DisplayName, - Post: &openapi.Operation{ - Tags: []string{tag}, Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record deleted", + Description: v.DisplayName + " records edited", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -633,25 +648,22 @@ func GenerateOpenAPISchema() { }, }, }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, parameters...), }, - Parameters: []openapi.Parameter{ - { - Ref: "#/components/parameters/PathID", - }, - { - Ref: "#/components/parameters/CSRF", - }, - { - Ref: "#/components/parameters/stat", - }, - }, - } - // Delete Many - s.Paths[fmt.Sprintf("/api/d/%s/delete", v.ModelName)] = openapi.Path{ - Summary: "Delete many " + v.DisplayName, - Description: "Delete many " + v.DisplayName, - Post: &openapi.Operation{ - Tags: []string{tag}, + Delete: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Delete many " + v.DisplayName, + Description: "Delete many " + v.DisplayName, Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records deleted", @@ -668,17 +680,16 @@ func GenerateOpenAPISchema() { }, }, }, + Parameters: append([]openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, parameters...), }, - Parameters: append([]openapi.Parameter{ - { - Ref: "#/components/parameters/CSRF", - }, - { - Ref: "#/components/parameters/stat", - }, - }, parameters...), } - s.Components.Schemas[v.Name] = openapi.SchemaObject{ Type: "object", Properties: fields, From 822db2834efbb6ac1c95be0831735dfdbe3b0c4d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 00:33:38 +0400 Subject: [PATCH 040/109] improve OpenAPI operation names --- openapi.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openapi.go b/openapi.go index 24bf68ea..e399a316 100644 --- a/openapi.go +++ b/openapi.go @@ -401,8 +401,8 @@ func GenerateOpenAPISchema() { Description: "Single record operations for " + v.DisplayName, Get: &openapi.Operation{ Tags: []string{tag}, - Summary: "Read one record from " + v.DisplayName, - Description: "Read one record from " + v.DisplayName, + Summary: "Read one " + v.DisplayName + " record", + Description: "Read one " + v.DisplayName + " record", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record", @@ -439,8 +439,8 @@ func GenerateOpenAPISchema() { }, Put: &openapi.Operation{ Tags: []string{tag}, - Summary: "Edit one record from " + v.DisplayName, - Description: "Edit one record from " + v.DisplayName, + Summary: "Edit one " + v.DisplayName + " record", + Description: "Edit one " + v.DisplayName + " record", RequestBody: &openapi.RequestBody{ Content: map[string]openapi.MediaType{ "multipart/form-data": { @@ -481,8 +481,8 @@ func GenerateOpenAPISchema() { }, Delete: &openapi.Operation{ Tags: []string{tag}, - Summary: "Delete one " + v.DisplayName, - Description: "Delete one " + v.DisplayName, + Summary: "Delete one " + v.DisplayName + " record", + Description: "Delete one " + v.DisplayName + " record", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record deleted", @@ -518,8 +518,8 @@ func GenerateOpenAPISchema() { Description: "Add one and multi-record operations for " + v.DisplayName, Get: &openapi.Operation{ Tags: []string{tag}, - Summary: "Read many records from " + v.DisplayName, - Description: "Read many records from " + v.DisplayName, + Summary: "Read many " + v.DisplayName + " record", + Description: "Read many " + v.DisplayName + " record", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records", @@ -577,8 +577,8 @@ func GenerateOpenAPISchema() { }, Post: &openapi.Operation{ Tags: []string{tag}, - Summary: "Add one " + v.DisplayName, - Description: "Add one " + v.DisplayName, + Summary: "Add one " + v.DisplayName + " record", + Description: "Add one " + v.DisplayName + " record", RequestBody: &openapi.RequestBody{ Content: map[string]openapi.MediaType{ "multipart/form-data": { @@ -620,8 +620,8 @@ func GenerateOpenAPISchema() { }, Put: &openapi.Operation{ Tags: []string{tag}, - Summary: "Edit many " + v.DisplayName, - Description: "Edit many " + v.DisplayName, + Summary: "Edit many " + v.DisplayName + " record", + Description: "Edit many " + v.DisplayName + " record", RequestBody: &openapi.RequestBody{ Content: map[string]openapi.MediaType{ "multipart/form-data": { @@ -662,8 +662,8 @@ func GenerateOpenAPISchema() { }, Delete: &openapi.Operation{ Tags: []string{tag}, - Summary: "Delete many " + v.DisplayName, - Description: "Delete many " + v.DisplayName, + Summary: "Delete many " + v.DisplayName + " record", + Description: "Delete many " + v.DisplayName + " record", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records deleted", From 1e065f9438eed0fa2e98048a9a3e0e26734196ee Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 01:01:10 +0400 Subject: [PATCH 041/109] fix type in OpenAPI schema --- openapi.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi.go b/openapi.go index e399a316..d4930c91 100644 --- a/openapi.go +++ b/openapi.go @@ -445,7 +445,7 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "multipart/form-data": { Schema: &openapi.SchemaObject{ - Type: "Object", + Type: "object", Properties: writeParameters, }, }, @@ -583,7 +583,7 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "multipart/form-data": { Schema: &openapi.SchemaObject{ - Type: "Object", + Type: "object", Properties: writeParameters, }, }, @@ -626,7 +626,7 @@ func GenerateOpenAPISchema() { Content: map[string]openapi.MediaType{ "multipart/form-data": { Schema: &openapi.SchemaObject{ - Type: "Object", + Type: "object", Properties: writeParameters, }, }, From 0081c18b2c059ac8e055418173c11443bfe781ec Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 01:17:40 +0400 Subject: [PATCH 042/109] fix type in OpenAPI schema --- openapi.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/openapi.go b/openapi.go index d4930c91..2d969d08 100644 --- a/openapi.go +++ b/openapi.go @@ -518,8 +518,8 @@ func GenerateOpenAPISchema() { Description: "Add one and multi-record operations for " + v.DisplayName, Get: &openapi.Operation{ Tags: []string{tag}, - Summary: "Read many " + v.DisplayName + " record", - Description: "Read many " + v.DisplayName + " record", + Summary: "Read many " + v.DisplayName + " records", + Description: "Read many " + v.DisplayName + " records", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records", @@ -620,8 +620,8 @@ func GenerateOpenAPISchema() { }, Put: &openapi.Operation{ Tags: []string{tag}, - Summary: "Edit many " + v.DisplayName + " record", - Description: "Edit many " + v.DisplayName + " record", + Summary: "Edit many " + v.DisplayName + " records", + Description: "Edit many " + v.DisplayName + " records", RequestBody: &openapi.RequestBody{ Content: map[string]openapi.MediaType{ "multipart/form-data": { @@ -649,9 +649,6 @@ func GenerateOpenAPISchema() { }, }, Parameters: append([]openapi.Parameter{ - { - Ref: "#/components/parameters/PathID", - }, { Ref: "#/components/parameters/CSRF", }, @@ -662,8 +659,8 @@ func GenerateOpenAPISchema() { }, Delete: &openapi.Operation{ Tags: []string{tag}, - Summary: "Delete many " + v.DisplayName + " record", - Description: "Delete many " + v.DisplayName + " record", + Summary: "Delete many " + v.DisplayName + " records", + Description: "Delete many " + v.DisplayName + " records", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " records deleted", From 868d5f201047d0054d3ebd945f5ccad8e51c976c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 01:26:50 +0400 Subject: [PATCH 043/109] enable business logic for dAPI add --- d_api_add.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/d_api_add.go b/d_api_add.go index a2e20662..1eff0702 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -154,13 +154,13 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { } } // Execute business logic - // if _, ok := model.Addr().Interface().(saver); ok { - // for _, id := range createdIDs { - // model, _ = NewModel(modelName, false) - // Get(model.Addr().Interface(), "id = ?", id) - // model.Addr().Interface().(saver).Save() - // } - // } + if _, ok := model.Addr().Interface().(saver); ok { + for _, id := range createdIDs { + model, _ = NewModel(modelName, false) + Get(model.Addr().Interface(), "id = ?", id) + model.Addr().Interface().(saver).Save() + } + } } else { // Error: Unknown format ReturnJSON(w, r, map[string]interface{}{ From da29c302731126f790c99e65b5c164fab4ef29b7 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 8 Dec 2022 21:29:27 +0400 Subject: [PATCH 044/109] Add a custom email sender handler --- global.go | 8 ++++++++ send_email.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/global.go b/global.go index 044e8ce9..9e5f6cde 100644 --- a/global.go +++ b/global.go @@ -418,6 +418,14 @@ Regards, {WEBSITE} Support ` +// CustomEmailHandler allows to customize or even override the default email sending method. +// The return of of the function is a boolean and an error. All parameters are of pointers to +// allow customization of the parameters that will be passed to the default email sender. +// - The boolean will determine whether to proceed or not. If true, the process will proceed with +// the default method of sending the email +// - The error will be reported to Trail as type uadmin.ERROR +var CustomEmailHandler func(to, cc, bcc *[]string, subject, body *string, attachments ...*string) (bool, error) + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/send_email.go b/send_email.go index d105498e..65131fa1 100644 --- a/send_email.go +++ b/send_email.go @@ -14,6 +14,20 @@ import ( // SendEmail sends email using system configured variables func SendEmail(to, cc, bcc []string, subject, body string, attachments ...string) (err error) { + if CustomEmailHandler != nil { + attachmentsPointers := make([]*string, len(attachments)) + for i := range attachments { + attachmentsPointers[i] = &attachments[i] + } + var proceed bool + proceed, err = CustomEmailHandler(&to, &cc, &bcc, &subject, &body, attachmentsPointers...) + if err != nil { + Trail(ERROR, "Error in CustomEmailHandler. %s", err) + if !proceed { + return + } + } + } if EmailFrom == "" || EmailUsername == "" || EmailPassword == "" || EmailSMTPServer == "" || EmailSMTPServerPort == 0 { errMsg := "Email not sent because email global variables are not set" Trail(WARNING, errMsg) From d73b671eb6d38829de503741430ff906d2ace560 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 12 Dec 2022 08:38:50 +0400 Subject: [PATCH 045/109] add CORS support for API --- admin.go | 5 +++++ cors_handler.go | 37 +++++++++++++++++++++++++++++++++++++ d_api.go | 5 +++++ d_api_add.go | 3 ++- global.go | 13 +++++++++++++ register.go | 6 +++++- 6 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 cors_handler.go diff --git a/admin.go b/admin.go index 8456fbf9..1f11fb44 100644 --- a/admin.go +++ b/admin.go @@ -180,6 +180,11 @@ func ReturnJSON(w http.ResponseWriter, r *http.Request, v interface{}) { w.Write(b) return } + + if CustomizeJSON != nil { + b = CustomizeJSON(w, r, v, b) + } + w.Write(b) } diff --git a/cors_handler.go b/cors_handler.go new file mode 100644 index 00000000..d2c00ae5 --- /dev/null +++ b/cors_handler.go @@ -0,0 +1,37 @@ +package uadmin + +import ( + "net/http" + "strings" +) + +func CORSHandler(f func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // Add CORS headers + if len(AllowedCORSOrigins) == 0 { + if r.Header.Get("Origin") != "" { + w.Header().Add("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Header().Add("Access-Control-Allow-Credentials", "true") + } else { + w.Header().Add("Access-Control-Allow-Origin", "*") + } + } else { + w.Header().Add("Access-Control-Allow-Origin", strings.Join(AllowedCORSOrigins, ", ")) + w.Header().Add("Access-Control-Allow-Credentials", "true") + } + + // Handle preflight requests + if r.Method == http.MethodOptions { + // Allow all known methods + w.Header().Add("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, HEAD") + + // Allow requested headers + w.Header().Add("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers")) + + return + } + + // run the handler + f(w, r) + } +} diff --git a/d_api.go b/d_api.go index ea9b9a70..be8ba959 100644 --- a/d_api.go +++ b/d_api.go @@ -114,6 +114,11 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { r.ParseForm() } + // Add Custom headers + for k, v := range CustomDAPIHeaders { + w.Header().Add(k, v) + } + r.URL.Path = strings.TrimPrefix(r.URL.Path, RootURL+"api/d") r.URL.Path = strings.TrimPrefix(r.URL.Path, "/") r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") diff --git a/d_api_add.go b/d_api_add.go index 1eff0702..619093fc 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -92,7 +92,8 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { argsPlaceHolder = append(argsPlaceHolder, "?") } - db = db.Exec("INSERT INTO "+tableName+" ("+q[i]+") VALUES ("+strings.Join(argsPlaceHolder, ",")+")", args[i]...) + SQL := "INSERT INTO " + tableName + " (" + q[i] + ") VALUES (" + strings.Join(argsPlaceHolder, ",") + ")" + db = db.Exec(SQL, args[i]...) rowsCount += db.RowsAffected } id := []int{} diff --git a/global.go b/global.go index 9e5f6cde..03b9e127 100644 --- a/global.go +++ b/global.go @@ -1,6 +1,7 @@ package uadmin import ( + "net/http" "os" "regexp" ) @@ -426,6 +427,18 @@ Regards, // - The error will be reported to Trail as type uadmin.ERROR var CustomEmailHandler func(to, cc, bcc *[]string, subject, body *string, attachments ...*string) (bool, error) +// CustomDAPIHeaders are extra handlers that would be added to dAPI responses +var CustomDAPIHeaders = map[string]string{} + +// EnableDAPICORS controller whether dAPI is uses CORS protocol to allow cross-origan requests +var EnableDAPICORS bool + +// AllowedCORSOrigins is a list of allowed CORS origins +var AllowedCORSOrigins []string + +// CustomizeJSON is a function to allow customization of JSON returns +var CustomizeJSON func(http.ResponseWriter, *http.Request, interface{}, []byte) []byte + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/register.go b/register.go index 2cf25253..caea0246 100644 --- a/register.go +++ b/register.go @@ -371,7 +371,11 @@ func registerHandlers() { } // dAPI handler - http.HandleFunc(RootURL+"api/", Handler(apiHandler)) + if EnableDAPICORS { + http.HandleFunc(RootURL+"api/", CORSHandler(Handler(apiHandler))) + } else { + http.HandleFunc(RootURL+"api/", Handler(apiHandler)) + } handlersRegistered = true } From 15180a9c2e7c4d9aec402fe5bde119f7848145dd Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 12 Dec 2022 09:10:19 +0400 Subject: [PATCH 046/109] support multiple domains in CORS --- cors_handler.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/cors_handler.go b/cors_handler.go index d2c00ae5..24cb2bd6 100644 --- a/cors_handler.go +++ b/cors_handler.go @@ -16,8 +16,21 @@ func CORSHandler(f func(w http.ResponseWriter, r *http.Request)) func(w http.Res w.Header().Add("Access-Control-Allow-Origin", "*") } } else { - w.Header().Add("Access-Control-Allow-Origin", strings.Join(AllowedCORSOrigins, ", ")) + // allowedOrigin := false + // for i := range AllowedCORSOrigins { + // if strings.EqualFold(r.Header.Get("Origin"), AllowedCORSOrigins[i]) { + // allowedOrigin = true + // break + // } + // } + // if allowedOrigin { + // w.Header().Add("Access-Control-Allow-Origin", r.Header.Get("Origin")) + // w.Header().Add("Access-Control-Allow-Credentials", "true") + // } else { + w.Header().Add("Access-Control-Allow-Origin", strings.Join(AllowedCORSOrigins, "|")) w.Header().Add("Access-Control-Allow-Credentials", "true") + // } + } // Handle preflight requests From b49258f27489d18f3d7f134ab2c2c7825d7eb5ab Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 12 Dec 2022 09:20:21 +0400 Subject: [PATCH 047/109] support multiple domains in CORS --- cors_handler.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/cors_handler.go b/cors_handler.go index 24cb2bd6..162cf6a2 100644 --- a/cors_handler.go +++ b/cors_handler.go @@ -16,20 +16,20 @@ func CORSHandler(f func(w http.ResponseWriter, r *http.Request)) func(w http.Res w.Header().Add("Access-Control-Allow-Origin", "*") } } else { - // allowedOrigin := false - // for i := range AllowedCORSOrigins { - // if strings.EqualFold(r.Header.Get("Origin"), AllowedCORSOrigins[i]) { - // allowedOrigin = true - // break - // } - // } - // if allowedOrigin { - // w.Header().Add("Access-Control-Allow-Origin", r.Header.Get("Origin")) - // w.Header().Add("Access-Control-Allow-Credentials", "true") - // } else { - w.Header().Add("Access-Control-Allow-Origin", strings.Join(AllowedCORSOrigins, "|")) - w.Header().Add("Access-Control-Allow-Credentials", "true") - // } + allowedOrigin := false + for i := range AllowedCORSOrigins { + if strings.EqualFold(r.Header.Get("Origin"), AllowedCORSOrigins[i]) { + allowedOrigin = true + break + } + } + if allowedOrigin { + w.Header().Add("Access-Control-Allow-Origin", r.Header.Get("Origin")) + w.Header().Add("Access-Control-Allow-Credentials", "true") + } else { + w.Header().Add("Access-Control-Allow-Origin", strings.Join(AllowedCORSOrigins, "|")) + w.Header().Add("Access-Control-Allow-Credentials", "true") + } } From ac9ab128e0182837ad13e1879f9f9e574e825aeb Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 12 Dec 2022 21:20:01 +0400 Subject: [PATCH 048/109] Reset password stores the OTP time --- d_api_reset_password.go | 2 +- forgot_password_handler.go | 34 ++------------------ otp.go | 19 +++++++++++ password_reset_handler.go | 2 +- user.go | 65 +++++++++++++++++++++++++++++++++++--- 5 files changed, 85 insertions(+), 37 deletions(-) diff --git a/d_api_reset_password.go b/d_api_reset_password.go index 9cc79ae6..567e7f02 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -128,7 +128,7 @@ func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session } // check OTP - if !user.VerifyOTP(otp) { + if !user.VerifyOTPAtPasswordReset(otp) { incrementInvalidLogins(r) w.WriteHeader(401) ReturnJSON(w, r, map[string]interface{}{ diff --git a/forgot_password_handler.go b/forgot_password_handler.go index 0d99b1ce..18c70f41 100644 --- a/forgot_password_handler.go +++ b/forgot_password_handler.go @@ -2,7 +2,6 @@ package uadmin import ( "fmt" - "net" "net/http" "strings" ) @@ -27,37 +26,10 @@ func forgotPasswordHandler(u *User, r *http.Request, link string, msg string) er ` } - // Check if the host name is in the allowed hosts list - allowed := false - var host string - var allowedHost string - var err error - if host, _, err = net.SplitHostPort(GetHostName(r)); err != nil { - host = r.Host + link, err := u.GeneratePasswordResetLink(r, link) + if err != nil { + return err } - for _, v := range strings.Split(AllowedHosts, ",") { - if allowedHost, _, err = net.SplitHostPort(v); err != nil { - allowedHost = v - } - if allowedHost == host { - allowed = true - break - } - } - host = GetHostName(r) - if !allowed { - Trail(CRITICAL, "Reset password request for host: (%s) which is not in AllowedHosts settings", host) - return nil - } - - schema := GetSchema(r) - if link == "" { - link = "{SCHEMA}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" - } - link = strings.ReplaceAll(link, "{SCHEMA}", schema) - link = strings.ReplaceAll(link, "{HOST}", host) - link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) - link = strings.ReplaceAll(link, "{OTP}", u.GetOTP()) msg = strings.ReplaceAll(msg, "{NAME}", u.String()) msg = strings.ReplaceAll(msg, "{WEBSITE}", SiteName) diff --git a/otp.go b/otp.go index b3b17d3c..e170f8a6 100644 --- a/otp.go +++ b/otp.go @@ -54,6 +54,25 @@ func verifyOTP(pass, seed string, digits int, algorithm string, skew uint, perio return valid } +func verifyOTPAt(pass, seed string, digits int, algorithm string, skew uint, period uint, t time.Time) bool { + algo := getOTPAlgorithm(strings.ToLower(algorithm)) + opts := totp.ValidateOpts{ + Algorithm: algo, + Digits: otp.Digits(digits), + Skew: skew, + Period: period, + } + + pass = fmt.Sprintf("%0"+fmt.Sprintf("%d.%d", digits, digits)+"s", pass) + + valid, err := totp.ValidateCustom(pass, seed, t.UTC(), opts) + if err != nil { + Trail(ERROR, "Unable to verify OTP. %s", err) + return false + } + return valid +} + func generateOTPSeed(digits int, algorithm string, skew uint, period uint, user *User) (secret string, imagePath string) { algo := getOTPAlgorithm(strings.ToLower(algorithm)) diff --git a/password_reset_handler.go b/password_reset_handler.go index b6f686d6..8910a81a 100644 --- a/password_reset_handler.go +++ b/password_reset_handler.go @@ -42,7 +42,7 @@ func passwordResetHandler(w http.ResponseWriter, r *http.Request) { return } otpCode := r.FormValue("key") - if !user.VerifyOTP(otpCode) { + if !user.VerifyOTPAtPasswordReset(otpCode) { go func() { log := &Log{} if r.Form.Get("password") != "" { diff --git a/user.go b/user.go index 342daffe..3cc59806 100644 --- a/user.go +++ b/user.go @@ -2,6 +2,8 @@ package uadmin import ( "fmt" + "net" + "net/http" "strings" "time" ) @@ -21,10 +23,11 @@ type User struct { UserGroupID uint Photo string `uadmin:"image"` //Language []Language `gorm:"many2many:user_languages" listExclude:"true"` - LastLogin *time.Time `uadmin:"read_only"` - ExpiresOn *time.Time - OTPRequired bool - OTPSeed string `uadmin:"list_exclude;hidden;read_only"` + LastLogin *time.Time `uadmin:"read_only"` + ExpiresOn *time.Time + OTPRequired bool + OTPSeed string `uadmin:"list_exclude;hidden;read_only"` + PasswordReset *time.Time } // String return string @@ -250,3 +253,57 @@ func (u *User) GetOTP() string { func (u *User) VerifyOTP(pass string) bool { return verifyOTP(pass, u.OTPSeed, OTPDigits, OTPAlgorithm, OTPSkew, OTPPeriod) } + +func (u *User) VerifyOTPAtPasswordReset(pass string) bool { + // Password reset link is valid for 24 hours + if u.PasswordReset == nil || u.PasswordReset.Before(time.Now().AddDate(0, 0, -1)) { + return false + } + return verifyOTPAt(pass, u.OTPSeed, OTPDigits, OTPAlgorithm, OTPSkew, OTPPeriod, *u.PasswordReset) +} + +func (u *User) GeneratePasswordResetLink(r *http.Request, link string) (string, error) { + // Check if the host name is in the allowed hosts list + allowed := false + var host string + var allowedHost string + var err error + if host, _, err = net.SplitHostPort(GetHostName(r)); err != nil { + host = r.Host + } + for _, v := range strings.Split(AllowedHosts, ",") { + if allowedHost, _, err = net.SplitHostPort(v); err != nil { + allowedHost = v + } + if allowedHost == host { + allowed = true + break + } + } + host = GetHostName(r) + if !allowed { + Trail(CRITICAL, "Reset password request for host: (%s) which is not in AllowedHosts settings", host) + return "", fmt.Errorf("Reset password request for host: (%s) which is not in AllowedHosts settings", host) + } + + schema := GetSchema(r) + if link == "" { + link = "{SCHEMA}://{HOST}" + RootURL + "resetpassword?u={USER_ID}&key={OTP}" + } + link = strings.ReplaceAll(link, "{SCHEMA}", schema) + link = strings.ReplaceAll(link, "{HOST}", host) + link = strings.ReplaceAll(link, "{USER_ID}", fmt.Sprint(u.ID)) + link = strings.ReplaceAll(link, "{EMAIL}", fmt.Sprint(u.Email)) + link = strings.ReplaceAll(link, "{OTP}", u.GeneratePasswordResetOTP()) + + return link, nil +} + +func (u *User) GeneratePasswordResetOTP() string { + // Set the date time for the password reset + now := time.Now() + u.PasswordReset = &now + Save(u) + + return u.GetOTP() +} From bc90095a861ce2812fa95a148be8d487098f8205 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 23 Dec 2022 09:25:23 +0400 Subject: [PATCH 049/109] Add custom handler for login response --- d_api_login.go | 8 ++++++-- global.go | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/d_api_login.go b/d_api_login.go index 551669bb..608b90f2 100644 --- a/d_api_login.go +++ b/d_api_login.go @@ -46,7 +46,7 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { Preload(&s.User) jwt := SetSessionCookie(w, r, s) - ReturnJSON(w, r, map[string]interface{}{ + res := map[string]interface{}{ "status": "ok", "session": s.Key, "jwt": jwt, @@ -57,5 +57,9 @@ func dAPILoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { "group_name": s.User.UserGroup.GroupName, "admin": s.User.Admin, }, - }) + } + if CustomDAPILoginHandler != nil { + res = CustomDAPILoginHandler(r, &s.User, res) + } + ReturnJSON(w, r, res) } diff --git a/global.go b/global.go index 03b9e127..9ef4a3d2 100644 --- a/global.go +++ b/global.go @@ -439,6 +439,10 @@ var AllowedCORSOrigins []string // CustomizeJSON is a function to allow customization of JSON returns var CustomizeJSON func(http.ResponseWriter, *http.Request, interface{}, []byte) []byte +// CustomDAPILoginHandler is a function that can provide extra information +// in the login return +var CustomDAPILoginHandler func(*http.Request, *User, map[string]interface{}) map[string]interface{} + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") From fb8533f6b2a692bdb22b1d742e53c6011304fe49 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 23 Dec 2022 10:07:54 +0400 Subject: [PATCH 050/109] Add description to write parameters --- openapi.go | 29 ++++++++++++++++++++--------- openapi/generate_schema.go | 2 +- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/openapi.go b/openapi.go index 2d969d08..857f60f5 100644 --- a/openapi.go +++ b/openapi.go @@ -350,40 +350,51 @@ func GenerateOpenAPISchema() { fallthrough case cPASSWORD: return &openapi.SchemaObject{ - Type: "string", + Type: "string", + Description: v.Fields[i].Help, } case cFILE: fallthrough case cIMAGE: return &openapi.SchemaObject{ - Type: "string", - Format: "binary", + Type: "string", + Format: "binary", + Description: v.Fields[i].Help, } case cFK: - fallthrough + return &openapi.SchemaObject{ + Type: "integer", + Description: "Foreign key to " + v.Fields[i].TypeName + ". " + v.Fields[i].Help, + } case cLIST: fallthrough case cMONEY: return &openapi.SchemaObject{ - Type: "integer", + Type: "integer", + Description: v.Fields[i].Help, } case cNUMBER: fallthrough case cPROGRESSBAR: return &openapi.SchemaObject{ - Type: "number", + Type: "number", + Description: v.Fields[i].Help, } case cBOOL: return &openapi.SchemaObject{ - Type: "boolean", + Type: "string", + Enum: []interface{}{"", "0", "1"}, + Description: v.Fields[i].Help, } case cDATE: return &openapi.SchemaObject{ - Type: "string", + Type: "string", + Description: v.Fields[i].Help, } default: return &openapi.SchemaObject{ - Type: "string", + Type: "string", + Description: v.Fields[i].Help, } } }() diff --git a/openapi/generate_schema.go b/openapi/generate_schema.go index 46225210..83a8d76e 100644 --- a/openapi/generate_schema.go +++ b/openapi/generate_schema.go @@ -344,7 +344,7 @@ func GenerateBaseSchema() *Schema { Enum: []interface{}{"", "true", "false"}, }, Examples: map[string]Example{ - "getDeleted": { + "getStats": { Summary: "An example of a query that measures the execution time", Value: "$stat=1", }, From 4c742e35fa67a8ae4172b4364910ed04964a7f65 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 17 Jan 2023 06:17:23 +0400 Subject: [PATCH 051/109] BUG FIX: fix dAPI add custom saver --- d_api_edit.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/d_api_edit.go b/d_api_edit.go index 33978034..7d5989e0 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -154,8 +154,8 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Execute business logic if _, ok := model.Addr().Interface().(saver); ok { - for i := 0; i < modelArray.Len(); i++ { - id := GetID(modelArray.Index(i)) + for i := 0; i < modelArray.Elem().Len(); i++ { + id := GetID(modelArray.Elem().Index(i)) model, _ = NewModel(modelName, false) Get(model.Addr().Interface(), "id = ?", id) model.Addr().Interface().(saver).Save() From c80c86b2df01ff278740849ac81b5b6c8867eff1 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 24 Jan 2023 13:36:31 +0400 Subject: [PATCH 052/109] BUG FIX: no preload for custom schema --- d_api_read.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/d_api_read.go b/d_api_read.go index 12cf94ce..cee8a71d 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -173,7 +173,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { } } // Preload - if params["$preload"] == "1" || params["$preload"] == "true" { + if !customSchema && (params["$preload"] == "1" || params["$preload"] == "true") { mList := reflect.ValueOf(m) for i := 0; i < mList.Elem().Len(); i++ { Preload(mList.Elem().Index(i).Addr().Interface()) From 86df2b12623fd4b53cd25f9fb4046224b9644c7a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 24 Jan 2023 13:51:06 +0400 Subject: [PATCH 053/109] add database error handeling for dAPI read --- d_api_read.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/d_api_read.go b/d_api_read.go index cee8a71d..4603078a 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -9,6 +9,7 @@ import ( func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { var rowsCount int64 + var err error urlParts := strings.Split(r.URL.Path, "/") modelName := r.Context().Value(CKey("modelName")).(string) @@ -129,10 +130,10 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { if Database.Type == "mysql" { db := GetDB() if !customSchema { - db.Raw(SQL, args...).Scan(m) + err = db.Raw(SQL, args...).Scan(m).Error } else { var rec []map[string]interface{} - db.Raw(SQL, args...).Scan(&rec) + err = db.Raw(SQL, args...).Scan(&rec).Error m = rec } if a, ok := m.([]map[string]interface{}); ok { @@ -144,10 +145,10 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { db := GetDB().Begin() db.Exec("PRAGMA case_sensitive_like=ON;") if !customSchema { - db.Raw(SQL, args...).Scan(m) + err = db.Raw(SQL, args...).Scan(m).Error } else { var rec []map[string]interface{} - db.Raw(SQL, args...).Scan(&rec) + err = db.Raw(SQL, args...).Scan(&rec).Error m = rec } db.Exec("PRAGMA case_sensitive_like=OFF;") @@ -160,10 +161,10 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { } else if Database.Type == "postgres" { db := GetDB() if !customSchema { - db.Raw(SQL, args...).Scan(m) + err = db.Raw(SQL, args...).Scan(m).Error } else { var rec []map[string]interface{} - db.Raw(SQL, args...).Scan(&rec) + err = db.Raw(SQL, args...).Scan(&rec).Error m = rec } if a, ok := m.([]map[string]interface{}); ok { @@ -172,6 +173,16 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { rowsCount = int64(reflect.ValueOf(m).Elem().Len()) } } + + // Check for errors + if err != nil { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Error in query. " + err.Error(), + }) + } + // Preload if !customSchema && (params["$preload"] == "1" || params["$preload"] == "true") { mList := reflect.ValueOf(m) From 60e8c5a2c1c807d45050e8416f2b091ec0dc9f04 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 24 Jan 2023 13:54:42 +0400 Subject: [PATCH 054/109] add database error handeling for add and edit --- d_api_add.go | 10 +++++++++- d_api_edit.go | 11 ++++++++++- d_api_read.go | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/d_api_add.go b/d_api_add.go index 619093fc..b5b682bc 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -105,7 +105,15 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { db = db.Raw("SELECT lastval() AS lastid") } db.Table(tableName).Pluck("lastid", &id) - db.Commit() + err := db.Commit().Error + if err != nil { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Error in update query. " + err.Error(), + }) + return + } if db.Error != nil { ReturnJSON(w, r, map[string]interface{}{ diff --git a/d_api_edit.go b/d_api_edit.go index 7d5989e0..0fc67a47 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -140,7 +140,16 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { } } } - db.Commit() + err = db.Commit().Error + + if err != nil { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Error in update query. " + err.Error(), + }) + return + } returnDAPIJSON(w, r, map[string]interface{}{ "status": "ok", diff --git a/d_api_read.go b/d_api_read.go index 4603078a..772b78ef 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -179,8 +179,9 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { w.WriteHeader(400) ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "Error in query. " + err.Error(), + "err_msg": "Error in read query. " + err.Error(), }) + return } // Preload From 6a3a41d88a504dee5bf31153404a14a7aef41231 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 25 Jan 2023 14:52:50 +0400 Subject: [PATCH 055/109] FullMediaURL --- d_api_read.go | 17 +++++++++++++++++ global.go | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/d_api_read.go b/d_api_read.go index 772b78ef..a275c91a 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -195,6 +195,23 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Process M2M getQueryM2M(params, m, customSchema, modelName) + // Process Full Media URL + if !customSchema && FullMediaURL { + for i := 0; i < reflect.ValueOf(m).Elem().Len(); i++ { + // Search for media fields + record := reflect.ValueOf(m).Elem().Index(i) + for j := range schema.Fields { + if schema.Fields[j].Type == cIMAGE || schema.Fields[j].Type == cFILE { + // Check if there is a file + if record.FieldByName(schema.Fields[j].Name).String() != "" && record.FieldByName(schema.Fields[j].Name).String()[0] == '/' { + record.FieldByName(schema.Fields[j].Name).SetString(GetSchema(r) + "://" + GetHostName(r) + record.FieldByName(schema.Fields[j].Name).String()) + } + + } + } + } + } + returnDAPIJSON(w, r, map[string]interface{}{ "status": "ok", "result": m, diff --git a/global.go b/global.go index 9ef4a3d2..607968e9 100644 --- a/global.go +++ b/global.go @@ -443,6 +443,10 @@ var CustomizeJSON func(http.ResponseWriter, *http.Request, interface{}, []byte) // in the login return var CustomDAPILoginHandler func(*http.Request, *User, map[string]interface{}) map[string]interface{} +// FullMediaURL allows uAdmin to send you full path URL instead on relative +// path for dAPI read requests +var FullMediaURL = false + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") From 9d58aeb07470fd2d819a93d2e8c73342d3f05c12 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 25 Jan 2023 17:56:06 +0400 Subject: [PATCH 056/109] Mask passwords in API --- d_api_read.go | 9 ++++++--- global.go | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/d_api_read.go b/d_api_read.go index a275c91a..1ba4bac6 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -196,17 +196,20 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { getQueryM2M(params, m, customSchema, modelName) // Process Full Media URL - if !customSchema && FullMediaURL { + // Mask passwords + if !customSchema { for i := 0; i < reflect.ValueOf(m).Elem().Len(); i++ { // Search for media fields record := reflect.ValueOf(m).Elem().Index(i) for j := range schema.Fields { - if schema.Fields[j].Type == cIMAGE || schema.Fields[j].Type == cFILE { + if FullMediaURL && (schema.Fields[j].Type == cIMAGE || schema.Fields[j].Type == cFILE) { // Check if there is a file if record.FieldByName(schema.Fields[j].Name).String() != "" && record.FieldByName(schema.Fields[j].Name).String()[0] == '/' { record.FieldByName(schema.Fields[j].Name).SetString(GetSchema(r) + "://" + GetHostName(r) + record.FieldByName(schema.Fields[j].Name).String()) } - + } + if MaskPasswordInAPI && schema.Fields[j].Type == cPASSWORD { + record.FieldByName(schema.Fields[j].Name).SetString("***") } } } diff --git a/global.go b/global.go index 607968e9..c1209cd5 100644 --- a/global.go +++ b/global.go @@ -447,6 +447,9 @@ var CustomDAPILoginHandler func(*http.Request, *User, map[string]interface{}) ma // path for dAPI read requests var FullMediaURL = false +// MaskPasswordInAPI will replace any password fields with an asterisk mask +var MaskPasswordInAPI = true + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") From cddda6e5f13b29dc8252b9ad8e8ea87c5be7f83b Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 25 Jan 2023 17:57:12 +0400 Subject: [PATCH 057/109] Make OTP a password field --- user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user.go b/user.go index 3cc59806..b4865f5b 100644 --- a/user.go +++ b/user.go @@ -26,7 +26,7 @@ type User struct { LastLogin *time.Time `uadmin:"read_only"` ExpiresOn *time.Time OTPRequired bool - OTPSeed string `uadmin:"list_exclude;hidden;read_only"` + OTPSeed string `uadmin:"list_exclude;hidden;read_only;password"` PasswordReset *time.Time } From 0f3b48624a1cd9002a101cddcb07a007f2be2d2f Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 26 Jan 2023 16:25:43 +0400 Subject: [PATCH 058/109] password masking and Full media URL for read one --- d_api_read.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/d_api_read.go b/d_api_read.go index 1ba4bac6..3392c075 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -241,6 +241,22 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { Preload(m.Interface()) } + // Process Full Media URL + // Mask passwords + // Search for media fields + record := m.Elem() + for j := range schema.Fields { + if FullMediaURL && (schema.Fields[j].Type == cIMAGE || schema.Fields[j].Type == cFILE) { + // Check if there is a file + if record.FieldByName(schema.Fields[j].Name).String() != "" && record.FieldByName(schema.Fields[j].Name).String()[0] == '/' { + record.FieldByName(schema.Fields[j].Name).SetString(GetSchema(r) + "://" + GetHostName(r) + record.FieldByName(schema.Fields[j].Name).String()) + } + } + if MaskPasswordInAPI && schema.Fields[j].Type == cPASSWORD { + record.FieldByName(schema.Fields[j].Name).SetString("***") + } + } + returnDAPIJSON(w, r, map[string]interface{}{ "status": "ok", "result": i, From 3e581a729ed916ff31a89f0a77552cb342ebd523 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 1 Feb 2023 20:03:45 +0800 Subject: [PATCH 059/109] send 400 in dAPI edit if there is a database error --- d_api_edit.go | 1 + 1 file changed, 1 insertion(+) diff --git a/d_api_edit.go b/d_api_edit.go index 0fc67a47..f4437377 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -106,6 +106,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { db.Model(model.Interface()).Where(q, args...).Scan(modelArray.Interface()) db = db.Model(model.Interface()).Where(q, args...).Updates(writeMap) if db.Error != nil { + w.WriteHeader(400) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Unable to update database. " + db.Error.Error(), From 94595fc3c3c648b436bcf1369e26c91629a14378 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 2 Feb 2023 01:11:11 +0800 Subject: [PATCH 060/109] BUG FIX: business logic on edit one read again before saving --- d_api_edit.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/d_api_edit.go b/d_api_edit.go index f4437377..05db97ff 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -216,8 +216,11 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { } // Execute business logic - if modelSaver, ok := m.Interface().(saver); ok { - modelSaver.Save() + if _, ok := m.Interface().(saver); ok { + db = GetDB() + m, _ = NewModel(modelName, true) + db.Model(model.Interface()).Where("id = ?", urlParts[0]).Scan(m.Interface()) + m.Interface().(saver).Save() } returnDAPIJSON(w, r, map[string]interface{}{ From b9f301e0679472b3b21cff1ce09fb85edeaf30f3 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 9 Feb 2023 19:57:29 +0800 Subject: [PATCH 061/109] Stop email sending if custom handler sends false fo4 proceed --- send_email.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/send_email.go b/send_email.go index 65131fa1..fb2f1bf4 100644 --- a/send_email.go +++ b/send_email.go @@ -23,9 +23,9 @@ func SendEmail(to, cc, bcc []string, subject, body string, attachments ...string proceed, err = CustomEmailHandler(&to, &cc, &bcc, &subject, &body, attachmentsPointers...) if err != nil { Trail(ERROR, "Error in CustomEmailHandler. %s", err) - if !proceed { - return - } + } + if !proceed { + return } } if EmailFrom == "" || EmailUsername == "" || EmailPassword == "" || EmailSMTPServer == "" || EmailSMTPServerPort == 0 { From c1aadf7e535068079271a4abaf7ca9538f0abe8e Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 13 Feb 2023 23:52:18 +0800 Subject: [PATCH 062/109] Add CORS handler for static and media --- register.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/register.go b/register.go index caea0246..ccff496f 100644 --- a/register.go +++ b/register.go @@ -363,8 +363,13 @@ func registerHandlers() { if !DisableAdminUI { // Handler for uAdmin, static and media http.HandleFunc(RootURL, Handler(mainHandler)) - http.HandleFunc("/static/", Handler(StaticHandler)) - http.HandleFunc("/media/", Handler(mediaHandler)) + if EnableDAPICORS { + http.HandleFunc("/media/", CORSHandler(StaticHandler)) + http.HandleFunc("/media/", CORSHandler(mediaHandler)) + } else { + http.HandleFunc("/static/", Handler(StaticHandler)) + http.HandleFunc("/media/", Handler(mediaHandler)) + } // api handler http.HandleFunc(RootURL+"revertHandler/", Handler(revertLogHandler)) From 2eb92c3b13271e049352b33deed7f5ceb3139163 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 14 Feb 2023 08:28:17 +0800 Subject: [PATCH 063/109] Add CORS handler for static and media --- register.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/register.go b/register.go index ccff496f..9bdb3f03 100644 --- a/register.go +++ b/register.go @@ -364,7 +364,7 @@ func registerHandlers() { // Handler for uAdmin, static and media http.HandleFunc(RootURL, Handler(mainHandler)) if EnableDAPICORS { - http.HandleFunc("/media/", CORSHandler(StaticHandler)) + http.HandleFunc("/static/", CORSHandler(StaticHandler)) http.HandleFunc("/media/", CORSHandler(mediaHandler)) } else { http.HandleFunc("/static/", Handler(StaticHandler)) From b67b2edbcdce006367b229464537b26326d67c70 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 20 Feb 2023 13:15:19 +0400 Subject: [PATCH 064/109] m2m filter --- d_api_helper.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ db.go | 3 +++ 2 files changed, 51 insertions(+) diff --git a/d_api_helper.go b/d_api_helper.go index 37f64420..0d956945 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -126,6 +126,11 @@ func getFilters(r *http.Request, params map[string]string, tableName string, sch qParts = append(qParts, "("+strings.Join(orQParts, " OR ")+")") args = append(args, orArgs...) } + } else if isM2MField(k, schema) { + // M2M filter + Trail(DEBUG, "M2M: %s", k) + qParts = append(qParts, getM2MQueryOperator(k, schema)) + args = append(args, getM2MQueryArg(k, v, schema)...) } else { qParts = append(qParts, getQueryOperator(r, k, tableName)) args = append(args, getQueryArg(k, v)...) @@ -147,6 +152,49 @@ func getFilters(r *http.Request, params map[string]string, tableName string, sch return query, args } +func isM2MField(v string, schema *ModelSchema) bool { + f := schema.FieldByColumnName(v) + if f == nil { + return false + } + + return f.Type == cM2M +} + +func getM2MQueryOperator(v string, schema *ModelSchema) string { + return "id IN (?)" +} + +func getM2MQueryArg(k, v string, schema *ModelSchema) []interface{} { + f := schema.FieldByColumnName(k) + t1 := schema.ModelName + t2 := strings.ToLower(f.TypeName) + SQL := sqlDialect[Database.Type]["selectM2MT2"] + SQL = strings.ReplaceAll(SQL, "{TABLE1}", t1) + SQL = strings.ReplaceAll(SQL, "{TABLE2}", t2) + + type M2MTable struct { + Table1ID uint `gorm:"column:table1_id"` + Table2ID uint `gorm:"column:table2_id"` + } + + values := []M2MTable{} + + err := GetDB().Raw(SQL, strings.Split(v, ",")).Scan(&values).Error + if err != nil { + Trail(ERROR, "Unable to get M2M args. %s", err) + return []interface{}{} + } + + returnArgs := make([]interface{}, len(values)) + + for i := range values { + returnArgs[i] = values[i].Table1ID + } + + return []interface{}{returnArgs} +} + func getQueryOperator(r *http.Request, v string, tableName string) string { // Determine if the query is negated n := len(v) > 0 && v[0] == '!' diff --git a/db.go b/db.go index 9dbbb254..8241c466 100644 --- a/db.go +++ b/db.go @@ -39,12 +39,14 @@ var sqlDialect = map[string]map[string]string{ "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id=?;", "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`=?;", "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES (?, ?);", + "selectM2MT2": "SELECT DISTINCT `table1_id` FROM `{TABLE1}_{TABLE2}` WHERE table2_id IN (?);", }, "postgres": { "createM2MTable": `CREATE TABLE "{TABLE1}_{TABLE2}" ("table1_id" BIGINT NOT NULL, "table2_id" BIGINT NOT NULL, PRIMARY KEY ("table1_id","table2_id"))`, "selectM2M": `SELECT "table2_id" FROM "{TABLE1}_{TABLE2}" WHERE table1_id=?;`, "deleteM2M": `DELETE FROM "{TABLE1}_{TABLE2}" WHERE "table1_id"=?;`, "insertM2M": `INSERT INTO "{TABLE1}_{TABLE2}" VALUES (?, ?);`, + "selectM2MT2": "SELECT DISTINCT `table1_id` FROM `{TABLE1}_{TABLE2}` WHERE table2_id IN (?);", }, "sqlite": { //"createM2MTable": "CREATE TABLE `{TABLE1}_{TABLE2}` (`{TABLE1}_id` INTEGER NOT NULL,`{TABLE2}_id` INTEGER NOT NULL, PRIMARY KEY(`{TABLE1}_id`,`{TABLE2}_id`));", @@ -52,6 +54,7 @@ var sqlDialect = map[string]map[string]string{ "selectM2M": "SELECT `table2_id` FROM `{TABLE1}_{TABLE2}` WHERE table1_id=?;", "deleteM2M": "DELETE FROM `{TABLE1}_{TABLE2}` WHERE `table1_id`=?;", "insertM2M": "INSERT INTO `{TABLE1}_{TABLE2}` VALUES (?, ?);", + "selectM2MT2": "SELECT DISTINCT `table1_id` FROM `{TABLE1}_{TABLE2}` WHERE table2_id IN (?);", }, } From 3189c19a3c64939e58b6f246855c6b06d956a680 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 20 Feb 2023 13:37:44 +0400 Subject: [PATCH 065/109] update excelize --- d_api_helper.go | 1 - export.go | 15 +++- go.mod | 33 +++---- go.sum | 230 +++++++++++------------------------------------- 4 files changed, 76 insertions(+), 203 deletions(-) diff --git a/d_api_helper.go b/d_api_helper.go index 0d956945..caf6005e 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -128,7 +128,6 @@ func getFilters(r *http.Request, params map[string]string, tableName string, sch } } else if isM2MField(k, schema) { // M2M filter - Trail(DEBUG, "M2M: %s", k) qParts = append(qParts, getM2MQueryOperator(k, schema)) args = append(args, getM2MQueryArg(k, v, schema)...) } else { diff --git a/export.go b/export.go index fdb0ea5a..767fce89 100644 --- a/export.go +++ b/export.go @@ -10,7 +10,7 @@ import ( "strings" "time" - // import upportd image formats to allow exporting + // import unsupported image formats to allow exporting // images to excel _ "image/gif" _ "image/jpeg" @@ -300,7 +300,18 @@ func exportHandler(w http.ResponseWriter, r *http.Request, session *Session) { } file.SetRowHeight(sheetName, i+2, 100) file.SetColWidth(sheetName, colName, colName, 25) - file.AddPicture(sheetName, cellName, a.Index(i).Field(c).String()[1:], `{"autofit": true, "print_obj": true, "lock_aspect_ratio": true, "locked": false, "positioning": "oneCell", "x_scale":5.0, "y_scale":5.0}`) + True := true + False := false + graphicsOptions := excelize.GraphicOptions{ + AutoFit: true, + PrintObject: &True, + LockAspectRatio: true, + Locked: &False, + Positioning: "oneCell", + ScaleX: 5.0, + ScaleY: 5.0, + } + file.AddPicture(sheetName, cellName, a.Index(i).Field(c).String()[1:], &graphicsOptions) file.SetCellStyle(sheetName, cellName, cellName, bodyStyle) } else if schema.FieldByName(t.Field(c).Name).Type == cCODE { file.SetCellValue(sheetName, cellName, a.Index(i).Field(c).Interface()) diff --git a/go.mod b/go.mod index 409f1962..4c2ac87f 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,25 @@ go 1.17 require ( github.com/jinzhu/inflection v1.0.0 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 - github.com/pquerna/otp v1.3.0 + github.com/pquerna/otp v1.4.0 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e github.com/uadmin/rrd v0.0.0-20200219090641-e438da1b7640 - github.com/xuri/excelize/v2 v2.6.1 - golang.org/x/crypto v0.2.0 - golang.org/x/mod v0.7.0 - golang.org/x/net v0.2.0 - gorm.io/driver/mysql v1.4.4 - gorm.io/driver/postgres v1.4.5 - gorm.io/driver/sqlite v1.4.3 - gorm.io/gorm v1.24.1 + github.com/xuri/excelize/v2 v2.7.0 + golang.org/x/crypto v0.6.0 + golang.org/x/mod v0.8.0 + golang.org/x/net v0.7.0 + gorm.io/driver/mysql v1.4.7 + gorm.io/driver/postgres v1.4.8 + gorm.io/driver/sqlite v1.4.4 + gorm.io/gorm v1.24.5 ) require ( github.com/boombuler/barcode v1.0.1 // indirect - github.com/go-sql-driver/mysql v1.6.0 // indirect - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgconn v1.13.0 // indirect - github.com/jackc/pgio v1.0.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgproto3/v2 v2.3.1 // indirect - github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect - github.com/jackc/pgtype v1.12.0 // indirect - github.com/jackc/pgx/v4 v4.17.2 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.3.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.16 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect @@ -36,6 +31,6 @@ require ( github.com/richardlehane/msoleps v1.0.3 // indirect github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 // indirect github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 // indirect - golang.org/x/sys v0.2.0 // indirect - golang.org/x/text v0.4.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 93ba2e38..9a1dc4dc 100644 --- a/go.sum +++ b/go.sum @@ -1,96 +1,30 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= -github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= -github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= -github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= -github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= -github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= -github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= -github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= -github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= -github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= -github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys= -github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= -github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= -github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= -github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= -github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= -github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= -github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= -github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= -github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y= -github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= -github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= -github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= -github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= -github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w= -github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= -github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= -github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= -github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= -github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= -github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E= -github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= -github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= -github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= +github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -98,160 +32,94 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs= -github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= +github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= -github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= -github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= -github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/uadmin/rrd v0.0.0-20200219090641-e438da1b7640 h1:A8ZPAW0kgZQ9MaqZRJyq9Zb39Zsf84G2Ypafszsttb8= github.com/uadmin/rrd v0.0.0-20200219090641-e438da1b7640/go.mod h1:Xo1H4x3+D6gR2/pDDHOLe3uvF7Y59Sro7ErzHnDuLfs= github.com/xuri/efp v0.0.0-20220603152613-6918739fd470 h1:6932x8ltq1w4utjmfMPVj09jdMlkY0aiA6+Skbtl3/c= github.com/xuri/efp v0.0.0-20220603152613-6918739fd470/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= -github.com/xuri/excelize/v2 v2.6.1 h1:ICBdtw803rmhLN3zfvyEGH3cwSmZv+kde7LhTDT659k= -github.com/xuri/excelize/v2 v2.6.1/go.mod h1:tL+0m6DNwSXj/sILHbQTYsLi9IF4TW59H2EF3Yrx1AU= +github.com/xuri/excelize/v2 v2.7.0 h1:Hri/czwyRCW6f6zrCDWXcXKshlq4xAZNpNOpdfnFhEw= +github.com/xuri/excelize/v2 v2.7.0/go.mod h1:ebKlRoS+rGyLMyUx3ErBECXs/HNYqyj+PbkkKRK5vSI= github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22 h1:OAmKAfT06//esDdpi/DZ8Qsdt4+M5+ltca05dA5bG2M= github.com/xuri/nfp v0.0.0-20220409054826-5e722a1d9e22/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= -go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= -go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= -go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= -go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= -golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= -golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY= +golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/mysql v1.4.4 h1:MX0K9Qvy0Na4o7qSC/YI7XxqUw5KDw01umqgID+svdQ= -gorm.io/driver/mysql v1.4.4/go.mod h1:BCg8cKI+R0j/rZRQxeKis/forqRwRSYOR8OM3Wo6hOM= -gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc= -gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg= -gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= -gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/driver/mysql v1.4.7 h1:rY46lkCspzGHn7+IYsNpSfEv9tA+SU4SkkB+GFX125Y= +gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8oc= +gorm.io/driver/postgres v1.4.8 h1:NDWizaclb7Q2aupT0jkwK8jx1HVCNzt+PQ8v/VnxviA= +gorm.io/driver/postgres v1.4.8/go.mod h1:O9MruWGNLUBUWVYfWuBClpf3HeGjOoybY0SNmCs3wsw= +gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc= +gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gorm.io/gorm v1.24.1-0.20221019064659-5dd2bb482755/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gorm.io/gorm v1.24.1 h1:CgvzRniUdG67hBAzsxDGOAuq4Te1osVMYsa1eQbd4fs= -gorm.io/gorm v1.24.1/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.24.5 h1:g6OPREKqqlWq4kh/3MCQbZKImeB9e6Xgc4zD+JgNZGE= +gorm.io/gorm v1.24.5/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= From 5f8e585635abfc0fc560d86de2cc7e89fc7ccac9 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 22 Feb 2023 09:47:52 +0400 Subject: [PATCH 066/109] limit bcrypt to 72 bytes --- auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 9c093efb..681fab37 100644 --- a/auth.go +++ b/auth.go @@ -84,7 +84,7 @@ func GenerateBase32(length int) string { // hashPass Generates a hash from a password and salt func hashPass(pass string) string { password := []byte(pass + Salt) - hash, err := bcrypt.GenerateFromPassword(password, bcryptDiff) + hash, err := bcrypt.GenerateFromPassword(password[:72], bcryptDiff) if err != nil { Trail(ERROR, "uadmin.auth.hashPass.GenerateFromPassword: %s", err) return "" From a87d30194b4316bd52b72e12ad813cea598fef1a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 22 Feb 2023 09:48:54 +0400 Subject: [PATCH 067/109] limit bcrypt to 72 bytes --- auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 681fab37..bf5602b0 100644 --- a/auth.go +++ b/auth.go @@ -772,7 +772,7 @@ func GetSchema(r *http.Request) string { func verifyPassword(hash string, plain string) error { password := []byte(plain + Salt) hashedPassword := []byte(hash) - return bcrypt.CompareHashAndPassword(hashedPassword, password) + return bcrypt.CompareHashAndPassword(hashedPassword[:72], password[:72]) } // sanitizeFileName is a function to sanitize file names to pretect From 111558e161f558e3f9e32272c53259835ae2badf Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 22 Feb 2023 09:55:37 +0400 Subject: [PATCH 068/109] limit bcrypt to 72 bytes --- auth.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/auth.go b/auth.go index bf5602b0..870fa936 100644 --- a/auth.go +++ b/auth.go @@ -84,6 +84,9 @@ func GenerateBase32(length int) string { // hashPass Generates a hash from a password and salt func hashPass(pass string) string { password := []byte(pass + Salt) + if len(password) > 72 { + password = password[:72] + } hash, err := bcrypt.GenerateFromPassword(password[:72], bcryptDiff) if err != nil { Trail(ERROR, "uadmin.auth.hashPass.GenerateFromPassword: %s", err) @@ -772,6 +775,12 @@ func GetSchema(r *http.Request) string { func verifyPassword(hash string, plain string) error { password := []byte(plain + Salt) hashedPassword := []byte(hash) + if len(hashedPassword) > 72 { + hashedPassword = hashedPassword[:72] + } + if len(password) > 72 { + password = password[:72] + } return bcrypt.CompareHashAndPassword(hashedPassword[:72], password[:72]) } From 86bead52362dc8d3a8f9f89b901b4aef4f33a41d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 22 Feb 2023 09:57:45 +0400 Subject: [PATCH 069/109] limit bcrypt to 72 bytes --- auth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth.go b/auth.go index 870fa936..2740eb0b 100644 --- a/auth.go +++ b/auth.go @@ -87,7 +87,7 @@ func hashPass(pass string) string { if len(password) > 72 { password = password[:72] } - hash, err := bcrypt.GenerateFromPassword(password[:72], bcryptDiff) + hash, err := bcrypt.GenerateFromPassword(password, bcryptDiff) if err != nil { Trail(ERROR, "uadmin.auth.hashPass.GenerateFromPassword: %s", err) return "" @@ -781,7 +781,7 @@ func verifyPassword(hash string, plain string) error { if len(password) > 72 { password = password[:72] } - return bcrypt.CompareHashAndPassword(hashedPassword[:72], password[:72]) + return bcrypt.CompareHashAndPassword(hashedPassword, password) } // sanitizeFileName is a function to sanitize file names to pretect From 000e5e8bff82a38fc3d8d8c02071de46d8756c13 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 27 Feb 2023 16:07:15 +0400 Subject: [PATCH 070/109] make trail the first registered model --- register.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/register.go b/register.go index 9bdb3f03..fa7586c1 100644 --- a/register.go +++ b/register.go @@ -80,6 +80,17 @@ func Register(m ...interface{}) { // Setup languages initializeLanguage() + // check if trail dashboard menu item is added + if Count([]DashboardMenu{}, "menu_name = ?", "Trail") == 0 { + dashboard := DashboardMenu{ + MenuName: "Trail", + URL: "trail", + Hidden: false, + Cat: "System", + } + Save(&dashboard) + } + // Store models in Model global variable // and initialize the dashboard dashboardMenus := []DashboardMenu{} @@ -142,17 +153,6 @@ func Register(m ...interface{}) { } } - // check if trail dashboard menu item is added - if Count([]DashboardMenu{}, "menu_name = ?", "Trail") == 0 { - dashboard := DashboardMenu{ - MenuName: "Trail", - URL: "trail", - Hidden: false, - Cat: "System", - } - Save(&dashboard) - } - // Check if encrypt key is there or generate it if _, err := os.Stat(".key"); os.IsNotExist(err) && os.Getenv("UADMIN_KEY") == "" { EncryptKey = generateByteArray(32) From f6cd6f8fcf472c9a41d5d69a9f22e8a1f466de59 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 28 Feb 2023 00:27:57 +0400 Subject: [PATCH 071/109] BUGFIX: get table columns in dAPI join --- d_api_read.go | 2 +- global.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/d_api_read.go b/d_api_read.go index 3392c075..9b863901 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -69,7 +69,7 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { if f != "" { SQL = strings.Replace(SQL, "{FIELDS}", f, -1) } else { - SQL = strings.Replace(SQL, "{FIELDS}", "*", -1) + SQL = strings.Replace(SQL, "{FIELDS}", tableName+".*", -1) } join := getQueryJoin(r, params, tableName) diff --git a/global.go b/global.go index c1209cd5..a69642a9 100644 --- a/global.go +++ b/global.go @@ -81,7 +81,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.2" +const Version = "0.9.5" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. From 8749560b0fa6be5a0f7c91af7edd0bc52dcb39bf Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 28 Feb 2023 19:24:20 +0400 Subject: [PATCH 072/109] Load data reads before writing --- load_initial_data.go | 1 + 1 file changed, 1 insertion(+) diff --git a/load_initial_data.go b/load_initial_data.go index d8deb0a0..f67a5c4c 100644 --- a/load_initial_data.go +++ b/load_initial_data.go @@ -103,6 +103,7 @@ func loadInitialData() error { // Save records for i := 0; i < modelArray.Elem().Len(); i++ { + Get(modelArray.Elem().Index(i).Addr().Interface(), "id = ?", GetID(modelArray.Elem().Index(i))) err = Save(modelArray.Elem().Index(i).Addr().Interface()) if err != nil { return fmt.Errorf("loadInitialData.Save: Error in %s[%d]. %s", table, i, err) From aa0574e28a34376f692db6fe0e92b131095c17b8 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 28 Feb 2023 22:29:15 +0400 Subject: [PATCH 073/109] Load data reads before writing --- load_initial_data.go | 1 + 1 file changed, 1 insertion(+) diff --git a/load_initial_data.go b/load_initial_data.go index f67a5c4c..f81e7dc5 100644 --- a/load_initial_data.go +++ b/load_initial_data.go @@ -104,6 +104,7 @@ func loadInitialData() error { // Save records for i := 0; i < modelArray.Elem().Len(); i++ { Get(modelArray.Elem().Index(i).Addr().Interface(), "id = ?", GetID(modelArray.Elem().Index(i))) + json.Unmarshal(buf, modelArray.Interface()) err = Save(modelArray.Elem().Index(i).Addr().Interface()) if err != nil { return fmt.Errorf("loadInitialData.Save: Error in %s[%d]. %s", table, i, err) From 497f449cb0b69a26e29da0c99930f99b75624acb Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 2 Mar 2023 10:07:36 +0400 Subject: [PATCH 074/109] update error message no unknown DB in mysql --- db.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db.go b/db.go index 8241c466..c6f99601 100644 --- a/db.go +++ b/db.go @@ -238,7 +238,7 @@ func GetDB() *gorm.DB { }) // Check if the error is DB doesn't exist and create it - if err != nil && err.Error() == "Error 1049: Unknown database '"+Database.Name+"'" { + if err != nil && strings.Contains(err.Error(), "Unknown database '"+Database.Name+"'") { err = createDB() if err == nil { From 151ed8c6c199080aadafc7fa98c14ff9abb8da94 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 2 Mar 2023 10:41:27 +0400 Subject: [PATCH 075/109] set dates to now if not set in mysql --- db.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/db.go b/db.go index c6f99601..876010f6 100644 --- a/db.go +++ b/db.go @@ -440,6 +440,9 @@ func All(a interface{}) (err error) { // Save saves the object in the database func Save(a interface{}) (err error) { encryptRecord(a) + if Database.Type == "mysql" { + a = fixDates(a) + } TimeMetric("uadmin/db/duration", 1000, func() { err = db.Save(a).Error for fmt.Sprint(err) == "database is locked" { @@ -459,6 +462,20 @@ func Save(a interface{}) (err error) { return nil } +func fixDates(a interface{}) interface{} { + value := reflect.ValueOf(a).Elem() + timeType := reflect.TypeOf(time.Now()) + timeValue := reflect.ValueOf(time.Now()) + for i := 0; i < value.NumField(); i++ { + if value.Field(i).Type() == timeType { + if value.Interface().(time.Time).IsZero() { + value.Field(i).Set(timeValue) + } + } + } + return value.Addr().Interface() +} + func customSave(m interface{}) (err error) { a := m t := reflect.TypeOf(a) From 59f4ba047ff0d3c8dc830c6ae883e8b6ac8edbc3 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 2 Mar 2023 11:39:40 +0400 Subject: [PATCH 076/109] set dates to now if not set in mysql --- db.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db.go b/db.go index 876010f6..8be2b5c5 100644 --- a/db.go +++ b/db.go @@ -468,7 +468,7 @@ func fixDates(a interface{}) interface{} { timeValue := reflect.ValueOf(time.Now()) for i := 0; i < value.NumField(); i++ { if value.Field(i).Type() == timeType { - if value.Interface().(time.Time).IsZero() { + if value.Field(i).Interface().(time.Time).IsZero() { value.Field(i).Set(timeValue) } } From 7dd4f2b4f9ebe19094f8074a702809cb9c72f13a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 4 Mar 2023 20:23:04 +0400 Subject: [PATCH 077/109] add global pre and post handler for dAPI --- d_api.go | 13 +++++++++++++ d_api_helper.go | 14 +++++++++++++- d_api_reset_password.go | 9 ++++++++- global.go | 24 ++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/d_api.go b/d_api.go index be8ba959..537e5d04 100644 --- a/d_api.go +++ b/d_api.go @@ -238,6 +238,9 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Route the request to the correct handler based on the command if command == "read" { // check if there is a prequery + if APIPreQueryReadHandler != nil && !APIPreQueryReadHandler(w, r) { + return + } if preQuery, ok := model.(APIPreQueryReader); ok && !preQuery.APIPreQueryRead(w, r) { } else { dAPIReadHandler(w, r, s) @@ -245,6 +248,10 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { return } if command == "add" { + // check if there is a prequery + if APIPreQueryAddHandler != nil && !APIPreQueryAddHandler(w, r) { + return + } if preQuery, ok := model.(APIPreQueryAdder); ok && !preQuery.APIPreQueryAdd(w, r) { } else { dAPIAddHandler(w, r, s) @@ -253,6 +260,9 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if command == "edit" { // check if there is a prequery + if APIPreQueryEditHandler != nil && !APIPreQueryEditHandler(w, r) { + return + } if preQuery, ok := model.(APIPreQueryEditor); ok && !preQuery.APIPreQueryEdit(w, r) { } else { dAPIEditHandler(w, r, s) @@ -261,6 +271,9 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if command == "delete" { // check if there is a prequery + if APIPreQueryDeleteHandler != nil && !APIPreQueryDeleteHandler(w, r) { + return + } if preQuery, ok := model.(APIPreQueryDeleter); ok && !preQuery.APIPreQueryDelete(w, r) { } else { dAPIDeleteHandler(w, r, s) diff --git a/d_api_helper.go b/d_api_helper.go index caf6005e..9bcb7b59 100644 --- a/d_api_helper.go +++ b/d_api_helper.go @@ -650,6 +650,9 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa if model != nil { if command == "read" { + if APIPostQueryReadHandler != nil && !APIPostQueryReadHandler(w, r, a) { + return nil + } if postQuery, ok := model.(APIPostQueryReader); ok { if !postQuery.APIPostQueryRead(w, r, a) { return nil @@ -657,6 +660,9 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa } } if command == "add" { + if APIPostQueryAddHandler != nil && !APIPostQueryAddHandler(w, r, a) { + return nil + } if postQuery, ok := model.(APIPostQueryAdder); ok { if !postQuery.APIPostQueryAdd(w, r, a) { return nil @@ -664,6 +670,9 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa } } if command == "edit" { + if APIPostQueryEditHandler != nil && !APIPostQueryEditHandler(w, r, a) { + return nil + } if postQuery, ok := model.(APIPostQueryEditor); ok { if !postQuery.APIPostQueryEdit(w, r, a) { return nil @@ -671,6 +680,9 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa } } if command == "delete" { + if APIPostQueryDeleteHandler != nil && !APIPostQueryDeleteHandler(w, r, a) { + return nil + } if postQuery, ok := model.(APIPostQueryDeleter); ok { if !postQuery.APIPostQueryDelete(w, r, a) { return nil @@ -684,7 +696,7 @@ func returnDAPIJSON(w http.ResponseWriter, r *http.Request, a map[string]interfa } } } - // if command == "schema" { + // if command == "method" { /* TODO: Add post query for methods if postQuery, ok := model.(APIPostQueryMethoder); ok { diff --git a/d_api_reset_password.go b/d_api_reset_password.go index 567e7f02..6d1c6dd9 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -1,6 +1,7 @@ package uadmin import ( + "fmt" "net/http" "time" ) @@ -52,9 +53,15 @@ func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session // check if the user exists and active if user.ID == 0 || (user.ExpiresOn != nil && user.ExpiresOn.After(time.Now())) { w.WriteHeader(404) + identifier := "email" + identifierVal := email + if uid != "" { + identifier = "uid" + identifierVal = uid + } ReturnJSON(w, r, map[string]interface{}{ "status": "error", - "err_msg": "email or uid do not match any active user", + "err_msg": fmt.Sprintf("%s: '%s' do not match any active user", identifier, identifierVal), }) // log the request go func() { diff --git a/global.go b/global.go index a69642a9..58ddd803 100644 --- a/global.go +++ b/global.go @@ -450,6 +450,30 @@ var FullMediaURL = false // MaskPasswordInAPI will replace any password fields with an asterisk mask var MaskPasswordInAPI = true +// APIPreQueryReadHandler is a function that runs before all dAPI read requests +var APIPreQueryReadHandler func(http.ResponseWriter, *http.Request) bool + +// APIPostQueryReadHandler is a function that runs after all dAPI read requests +var APIPostQueryReadHandler func(http.ResponseWriter, *http.Request, map[string]interface{}) bool + +// APIPreQueryAddHandler is a function that runs before all dAPI add requests +var APIPreQueryAddHandler func(http.ResponseWriter, *http.Request) bool + +// APIPostQueryAddHandler is a function that runs after all dAPI add requests +var APIPostQueryAddHandler func(http.ResponseWriter, *http.Request, map[string]interface{}) bool + +// APIPreQueryEditHandler is a function that runs before all dAPI edit requests +var APIPreQueryEditHandler func(http.ResponseWriter, *http.Request) bool + +// APIPostQueryEditHandler is a function that runs after all dAPI edit requests +var APIPostQueryEditHandler func(http.ResponseWriter, *http.Request, map[string]interface{}) bool + +// APIPreQueryDeleteHandler is a function that runs before all dAPI delete requests +var APIPreQueryDeleteHandler func(http.ResponseWriter, *http.Request) bool + +// APIPostQueryDeleteHandler is a function that runs after all dAPI delete requests +var APIPostQueryDeleteHandler func(http.ResponseWriter, *http.Request, map[string]interface{}) bool + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") From 4919943d00defce68a0e66c0869f2ab4804d263f Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 4 Mar 2023 21:01:56 +0400 Subject: [PATCH 078/109] apply global pre read for dAPI --- d_api_read.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/d_api_read.go b/d_api_read.go index 9b863901..cb357a64 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -94,6 +94,12 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { args = append(args, lmArgs...) } } + if r.Context().Value(CKey("WHERE")) != nil { + if q != "" { + q += " AND " + } + q += r.Context().Value(CKey("WHERE")).(string) + } if q != "" { SQL += " WHERE " + q } @@ -228,7 +234,11 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { } else if len(urlParts) == 1 { // Read One m, _ := NewModel(modelName, true) - Get(m.Interface(), "id = ?", urlParts[0]) + q := "id = ?" + if r.Context().Value(CKey("WHERE")) != nil { + q += " AND " + r.Context().Value(CKey("WHERE")).(string) + } + Get(m.Interface(), q, urlParts[0]) rowsCount = 0 var i interface{} From 662fa05f416b02421e1f267589c10568dc84b2f2 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 6 Mar 2023 12:54:08 +0400 Subject: [PATCH 079/109] fix mysql date pointer for zero value --- db.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/db.go b/db.go index 8be2b5c5..06c1c673 100644 --- a/db.go +++ b/db.go @@ -464,14 +464,22 @@ func Save(a interface{}) (err error) { func fixDates(a interface{}) interface{} { value := reflect.ValueOf(a).Elem() - timeType := reflect.TypeOf(time.Now()) - timeValue := reflect.ValueOf(time.Now()) + now := time.Now() + timeType := reflect.TypeOf(now) + timePointerType := reflect.TypeOf(&now) + timeValue := reflect.ValueOf(now) + timePointerValue := reflect.ValueOf(now) for i := 0; i < value.NumField(); i++ { if value.Field(i).Type() == timeType { if value.Field(i).Interface().(time.Time).IsZero() { value.Field(i).Set(timeValue) } + } else if value.Field(i).Type() == timePointerType { + if value.Field(i).Interface() != nil && value.Field(i).Interface().(*time.Time).IsZero() { + value.Field(i).Set(timePointerValue) + } } + } return value.Addr().Interface() } From 49f019dccaf4ba21f7a23d29c12a963eb35c80cb Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 6 Mar 2023 13:01:27 +0400 Subject: [PATCH 080/109] office space lease migration --- db.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db.go b/db.go index 06c1c673..70b8b796 100644 --- a/db.go +++ b/db.go @@ -468,7 +468,7 @@ func fixDates(a interface{}) interface{} { timeType := reflect.TypeOf(now) timePointerType := reflect.TypeOf(&now) timeValue := reflect.ValueOf(now) - timePointerValue := reflect.ValueOf(now) + timePointerValue := reflect.ValueOf(&now) for i := 0; i < value.NumField(); i++ { if value.Field(i).Type() == timeType { if value.Field(i).Interface().(time.Time).IsZero() { From 2beb5fa97338a7e070bc222bfb6297b8ad93664c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 6 Mar 2023 13:24:33 +0400 Subject: [PATCH 081/109] fix mysql date pointer for zero value --- db.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/db.go b/db.go index 70b8b796..f0f1b06c 100644 --- a/db.go +++ b/db.go @@ -475,8 +475,10 @@ func fixDates(a interface{}) interface{} { value.Field(i).Set(timeValue) } } else if value.Field(i).Type() == timePointerType { - if value.Field(i).Interface() != nil && value.Field(i).Interface().(*time.Time).IsZero() { - value.Field(i).Set(timePointerValue) + if !value.Field(i).IsNil() { + if value.Field(i).Interface().(*time.Time).IsZero() { + value.Field(i).Set(timePointerValue) + } } } From ae051797d2dc500e45812f6f1a17d96e8aba38e0 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 7 Mar 2023 07:52:53 +0400 Subject: [PATCH 082/109] BUG FIX: get session from POST-like methods --- auth.go | 2 +- check_csrf.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 2740eb0b..850acdf7 100644 --- a/auth.go +++ b/auth.go @@ -580,7 +580,7 @@ func getSession(r *http.Request) string { if r.Method == "GET" && r.FormValue("session") != "" { return r.FormValue("session") } - if r.Method == "POST" { + if r.Method != "POST" { err := r.ParseMultipartForm(2 << 10) if err != nil { r.ParseForm() diff --git a/check_csrf.go b/check_csrf.go index 64f9c57c..99660e94 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -48,6 +48,7 @@ Where you replace `MY_SESSION_KEY` with the session key. */ func CheckCSRF(r *http.Request) bool { token := getCSRFToken(r) + Trail(DEBUG, "token: %s", token) if token != "" && token == getSession(r) { return false } From cdc9764f2e9e60f54b9ab600873e343eb50fb149 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 7 Mar 2023 07:54:16 +0400 Subject: [PATCH 083/109] BUG FIX: get session from POST-like methods --- auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth.go b/auth.go index 850acdf7..8de9190a 100644 --- a/auth.go +++ b/auth.go @@ -580,7 +580,7 @@ func getSession(r *http.Request) string { if r.Method == "GET" && r.FormValue("session") != "" { return r.FormValue("session") } - if r.Method != "POST" { + if r.Method != "GET" { err := r.ParseMultipartForm(2 << 10) if err != nil { r.ParseForm() From 2879f2d84959368c7fd854de985692150862c3f4 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 7 Mar 2023 07:55:51 +0400 Subject: [PATCH 084/109] BUG FIX: get session from POST-like methods --- check_csrf.go | 1 - 1 file changed, 1 deletion(-) diff --git a/check_csrf.go b/check_csrf.go index 99660e94..64f9c57c 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -48,7 +48,6 @@ Where you replace `MY_SESSION_KEY` with the session key. */ func CheckCSRF(r *http.Request) bool { token := getCSRFToken(r) - Trail(DEBUG, "token: %s", token) if token != "" && token == getSession(r) { return false } From 9982762575f5de6a5cbe7ee6a1f62de07866f565 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 7 Mar 2023 17:05:49 +0400 Subject: [PATCH 085/109] prelogin handler --- auth.go | 3 +++ global.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/auth.go b/auth.go index 8de9190a..7258ac2e 100644 --- a/auth.go +++ b/auth.go @@ -239,6 +239,9 @@ func getSessionFromRequest(r *http.Request) *Session { // Login return *User and a bool for Is OTP Required func Login(r *http.Request, username string, password string) (*Session, bool) { + if PreLoginHandler != nil { + PreLoginHandler(r, username, password) + } // Get the user from DB user := User{} Get(&user, "username = ?", username) diff --git a/global.go b/global.go index 58ddd803..441ac6eb 100644 --- a/global.go +++ b/global.go @@ -474,6 +474,9 @@ var APIPreQueryDeleteHandler func(http.ResponseWriter, *http.Request) bool // APIPostQueryDeleteHandler is a function that runs after all dAPI delete requests var APIPostQueryDeleteHandler func(http.ResponseWriter, *http.Request, map[string]interface{}) bool +// PreLoginHandler is a function that runs after all dAPI delete requests +var PreLoginHandler func(r *http.Request, username string, password string) + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") From d03674577002c8be9e6d84a6c5e624396d5aa5de Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 3 Apr 2023 11:28:45 +0400 Subject: [PATCH 086/109] Adjust folder permissions to 0755 --- cmd/uadmin/copy.go | 6 +++--- cmd/uadmin/main.go | 2 +- crop_image_handler_test.go | 2 +- generate_translation.go | 4 ++-- otp.go | 2 +- process_upload.go | 4 ++-- server_test.go | 2 +- upload_image_handler.go | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/uadmin/copy.go b/cmd/uadmin/copy.go index a86087b7..dabf2c10 100644 --- a/cmd/uadmin/copy.go +++ b/cmd/uadmin/copy.go @@ -1,7 +1,7 @@ /* The MIT License (MIT) -Copyright (c) 2018 otiai10 +# Copyright (c) 2018 otiai10 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -57,7 +57,7 @@ func copy(src, dest string, info os.FileInfo) error { // and file permission. func fcopy(src, dest string) error { - if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil { + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return err } @@ -86,7 +86,7 @@ func fcopy(src, dest string) error { // and pass everything to "copy" recursively. func dcopy(srcdir, destdir string) error { - if err := os.MkdirAll(destdir, os.FileMode(0744)); err != nil { + if err := os.MkdirAll(destdir, 0755); err != nil { return err } diff --git a/cmd/uadmin/main.go b/cmd/uadmin/main.go index 7be1c3f7..9b75b726 100644 --- a/cmd/uadmin/main.go +++ b/cmd/uadmin/main.go @@ -88,7 +88,7 @@ func main() { for _, v := range folderList { dst = filepath.Join(ex, v) if _, err = os.Stat(dst); os.IsNotExist(err) { - err = os.MkdirAll(dst, os.FileMode(0744)) + err = os.MkdirAll(dst, 0755) if err != nil { uadmin.Trail(uadmin.WARNING, "Unable to create \"%s\" folder: %s", v, err) } else { diff --git a/crop_image_handler_test.go b/crop_image_handler_test.go index eea1541f..5ccfe0a8 100644 --- a/crop_image_handler_test.go +++ b/crop_image_handler_test.go @@ -31,7 +31,7 @@ func (t *UAdminTests) TestCropImageHandler() { } } - os.MkdirAll("./media/user", 0744) + os.MkdirAll("./media/user", 0755) // Save to iamge.png f1, _ := os.OpenFile("./media/user/image_raw.png", os.O_WRONLY|os.O_CREATE, 0600) diff --git a/generate_translation.go b/generate_translation.go index 7aa860a8..cd08abd2 100644 --- a/generate_translation.go +++ b/generate_translation.go @@ -65,7 +65,7 @@ func syncCustomTranslation(path string) map[string]int { group := pathParts[0] name := pathParts[1] - os.MkdirAll("./static/i18n/"+group+"/", 0744) + os.MkdirAll("./static/i18n/"+group+"/", 0755) fileName := "./static/i18n/" + group + "/" + name + ".en.json" langMap := map[string]string{} if _, err = os.Stat(fileName); os.IsNotExist(err) { @@ -159,7 +159,7 @@ func syncModelTranslation(m ModelSchema) map[string]int { pkgName = strings.Split(pkgName, ".")[0] // Get the model's original language file - err = os.MkdirAll("./static/i18n/"+pkgName+"/", 0744) + err = os.MkdirAll("./static/i18n/"+pkgName+"/", 0755) if err != nil { Trail(ERROR, "generateTranslation error creating folder (./static/i18n/"+pkgName+"/). %v", err) diff --git a/otp.go b/otp.go index e170f8a6..13946b72 100644 --- a/otp.go +++ b/otp.go @@ -88,7 +88,7 @@ func generateOTPSeed(digits int, algorithm string, skew uint, period uint, user key, _ := totp.Generate(opts) img, _ := key.Image(250, 250) - os.MkdirAll("./media/otp/", 0744) + os.MkdirAll("./media/otp/", 0755) fName := "./media/otp/" + key.Secret() + ".png" for _, err := os.Stat(fName); os.IsExist(err); { diff --git a/process_upload.go b/process_upload.go index 73484757..94b379d0 100644 --- a/process_upload.go +++ b/process_upload.go @@ -65,7 +65,7 @@ func processUpload(r *http.Request, f *F, modelName string, session *Session, s uploadTo = f.UploadTo } if _, err = os.Stat("." + uploadTo); os.IsNotExist(err) { - err = os.MkdirAll("."+uploadTo, os.ModePerm) + err = os.MkdirAll("."+uploadTo, 0755) if err != nil { Trail(ERROR, "processForm.MkdirAll. %s", err) return "" @@ -103,7 +103,7 @@ func processUpload(r *http.Request, f *F, modelName string, session *Session, s // Sanitize the file name fName = pathName + path.Clean(fName) - err = os.MkdirAll(pathName, os.ModePerm) + err = os.MkdirAll(pathName, 0755) if err != nil { Trail(ERROR, "processForm.MkdirAll. unable to create folder for uploaded file. %s", err) return "" diff --git a/server_test.go b/server_test.go index e1de492d..e137c1bd 100644 --- a/server_test.go +++ b/server_test.go @@ -174,7 +174,7 @@ func TestRunner(t *testing.T) { t.Run(dbSetup.Name+"=GroupPermissions", func(t *testing.T) { uTest.TestGroupPermission() }) - t.Run(dbSetup.Name+"=HomeHamdler", func(t *testing.T) { + t.Run(dbSetup.Name+"=HomeHandler", func(t *testing.T) { uTest.TestHomeHandler() }) t.Run(dbSetup.Name+"=Language", func(t *testing.T) { diff --git a/upload_image_handler.go b/upload_image_handler.go index 2f103c55..9d05c486 100644 --- a/upload_image_handler.go +++ b/upload_image_handler.go @@ -20,7 +20,7 @@ func UploadImageHandler(w http.ResponseWriter, r *http.Request, session *Session } folderPath = "./media/htmlimages/" + GenerateBase64(24) + "/" } - os.MkdirAll(folderPath, 0744) + os.MkdirAll(folderPath, 0755) fileName := strings.Replace(f.Filename, "/", " ", -1) From a681eab76e16a85ab2bb5f350d43ac906aa2d1e1 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 3 Apr 2023 11:32:17 +0400 Subject: [PATCH 087/109] make version 0.9.6 --- global.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.go b/global.go index 441ac6eb..3577ed5c 100644 --- a/global.go +++ b/global.go @@ -81,7 +81,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.5" +const Version = "0.9.6" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. From 365febbf42989279f7d074e98de29df84eba20f9 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 3 Apr 2023 12:42:46 +0400 Subject: [PATCH 088/109] Add AutoMigrater Interface --- db.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/db.go b/db.go index f0f1b06c..e0dae6d7 100644 --- a/db.go +++ b/db.go @@ -73,6 +73,10 @@ type DBSettings struct { Timezone string `json:"timezone"` } +type AutoMigrater interface { + AutoMigrate() bool +} + // initializeDB opens the connection the DB func initializeDB(a ...interface{}) { // Open the connection the the DB @@ -80,6 +84,11 @@ func initializeDB(a ...interface{}) { // Migrate schema for i, model := range a { + if autoMigrate, ok := model.(AutoMigrater); ok { + if !autoMigrate.AutoMigrate() { + continue + } + } Trail(INFO, "Initializing DB: [%s%d/%d%s]", colors.FGGreenB, i+1, len(a), colors.FGNormal) err := db.AutoMigrate(model) if err != nil { From 842655583bdba104154b99c71ca9ac3ed8b8fd6f Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Mon, 24 Apr 2023 20:59:28 +0800 Subject: [PATCH 089/109] Add Post File Upload Handler --- global.go | 3 +++ process_upload.go | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/global.go b/global.go index 441ac6eb..555d8f36 100644 --- a/global.go +++ b/global.go @@ -477,6 +477,9 @@ var APIPostQueryDeleteHandler func(http.ResponseWriter, *http.Request, map[strin // PreLoginHandler is a function that runs after all dAPI delete requests var PreLoginHandler func(r *http.Request, username string, password string) +// PreLoginHandler is a function that runs after all dAPI delete requests +var PostUploadHandler func(filePath string) string + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/process_upload.go b/process_upload.go index 73484757..39ce2268 100644 --- a/process_upload.go +++ b/process_upload.go @@ -255,5 +255,9 @@ func processUpload(r *http.Request, f *F, modelName string, session *Session, s os.RemoveAll(strings.Join(oldFileParts[0:len(oldFileParts)-1], "/")) } + if PostUploadHandler != nil { + val = PostUploadHandler(val) + } + return val } From 53a0e9685c33b489e4879b586edd9f3e7c06664c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 26 Apr 2023 01:36:41 +0800 Subject: [PATCH 090/109] Change Post upload handler signature --- global.go | 2 +- process_upload.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/global.go b/global.go index f96784b8..2eb87adb 100644 --- a/global.go +++ b/global.go @@ -478,7 +478,7 @@ var APIPostQueryDeleteHandler func(http.ResponseWriter, *http.Request, map[strin var PreLoginHandler func(r *http.Request, username string, password string) // PreLoginHandler is a function that runs after all dAPI delete requests -var PostUploadHandler func(filePath string) string +var PostUploadHandler func(filePath string, modelName string, f *F) string // Private Global Variables // Regex diff --git a/process_upload.go b/process_upload.go index d5cae606..53bb1a99 100644 --- a/process_upload.go +++ b/process_upload.go @@ -256,7 +256,7 @@ func processUpload(r *http.Request, f *F, modelName string, session *Session, s } if PostUploadHandler != nil { - val = PostUploadHandler(val) + val = PostUploadHandler(val, modelName, f) } return val From 0d15f549217ded78cf89c45213a21e7512170164 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 29 Apr 2023 22:26:30 +0800 Subject: [PATCH 091/109] return 403 for failed CSRF --- d_api_add.go | 1 + d_api_delete.go | 1 + d_api_edit.go | 1 + d_api_logout.go | 2 +- d_api_method.go | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/d_api_add.go b/d_api_add.go index b5b682bc..e8ef728a 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -20,6 +20,7 @@ func dAPIAddHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Check CSRF if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", diff --git a/d_api_delete.go b/d_api_delete.go index a69676c4..da766790 100644 --- a/d_api_delete.go +++ b/d_api_delete.go @@ -18,6 +18,7 @@ func dAPIDeleteHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Check CSRF if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", diff --git a/d_api_edit.go b/d_api_edit.go index 05db97ff..1cfe012e 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -16,6 +16,7 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Check CSRF if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", diff --git a/d_api_logout.go b/d_api_logout.go index 30eb7506..1dd276c0 100644 --- a/d_api_logout.go +++ b/d_api_logout.go @@ -13,7 +13,7 @@ func dAPILogoutHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if CheckCSRF(r) { - w.WriteHeader(http.StatusUnauthorized) + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Missing CSRF token", diff --git a/d_api_method.go b/d_api_method.go index 7f60a544..48d9b18e 100644 --- a/d_api_method.go +++ b/d_api_method.go @@ -24,6 +24,7 @@ func dAPIMethodHandler(w http.ResponseWriter, r *http.Request, s *Session) { } if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", From 5d33a30c2c3a58dcdb708272f44b26f2a0bd041f Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Wed, 3 May 2023 00:13:32 +0800 Subject: [PATCH 092/109] change settings uploads to media by default --- params_to_instance.go | 60 +++++++++++++++++++++++++++++++++++++++++++ setting_handler.go | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 params_to_instance.go diff --git a/params_to_instance.go b/params_to_instance.go new file mode 100644 index 00000000..dfeaee8c --- /dev/null +++ b/params_to_instance.go @@ -0,0 +1,60 @@ +package uadmin + +// func setParams(params map[string]string, m reflect.Value, schema ModelSchema) (reflect.Value, error) { +// paramMap := map[string]interface{}{} +// for k, v := range params { +// key := k +// if key == "" { +// continue +// } +// if key[0] == '_' { +// key = key[1:] +// } +// f := schema.FieldByColumnName(key) +// if f != nil { +// key = f.Name +// } +// paramMap[key] = v + +// // fix value for numbers +// if f.Type == cNUMBER { +// if strings.HasPrefix(f.TypeName, "float") { +// paramMap[key], _ = strconv.ParseFloat(v, 64) +// } else if strings.HasPrefix(f.TypeName, "uint") { +// paramMap[key], _ = strconv.ParseUint(v, 10, 64) +// } else if strings.HasPrefix(f.TypeName, "int") { +// paramMap[key], _ = strconv.ParseInt(v, 10, 64) +// } +// } else if f.Type == cBOOL { +// if paramMap[key] == "true" || paramMap[key] == "1" { +// paramMap[key] = true +// } else { +// paramMap[key] = false +// } +// } else if f.Type == cLIST { +// paramMap[key], _ = strconv.ParseInt(v, 10, 64) +// } else if f.Type == cDATE { + +// } +// } + +// buf, _ := json.Marshal(params) +// var err error +// if m.Kind() == reflect.Pointer { +// err = json.Unmarshal(buf, m.Interface()) +// } else if m.Kind() == reflect.Struct { +// err = json.Unmarshal(buf, m.Addr().Interface()) +// } + +// return m, err +// } + +// func parseDate(v string) interface{} { +// if v == "" || v == "null" { +// return nil +// } +// dt, err := time.Parse("2006-05-04T15:02:01Z", v) +// if err != nil { +// return dt +// } +// } diff --git a/setting_handler.go b/setting_handler.go index 20b0a108..e6721849 100644 --- a/setting_handler.go +++ b/setting_handler.go @@ -63,7 +63,7 @@ func settingsHandler(w http.ResponseWriter, r *http.Request, session *Session) { schema, _ := getSchema(s) schema.FieldByName(sParts[1]) - f := F{Name: s.Code, Type: tMap[s.DataType], UploadTo: "/static/settings/"} + f := F{Name: s.Code, Type: tMap[s.DataType], UploadTo: "/media/settings/"} val := processUpload(r, &f, "setting", session, &schema) if val == "" { From 21d3b5134ad7b29b71bafc80440449c97f4a24ae Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 11 May 2023 12:47:36 +0800 Subject: [PATCH 093/109] cache for media and increase cache age for static --- media_handler.go | 37 +++++++++++++++++++++++++++++-------- static_handler.go | 2 +- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/media_handler.go b/media_handler.go index 719e1c38..b2078e9a 100644 --- a/media_handler.go +++ b/media_handler.go @@ -1,7 +1,6 @@ package uadmin import ( - "io" "net/http" "os" "path" @@ -15,18 +14,40 @@ func mediaHandler(w http.ResponseWriter, r *http.Request) { return } - r.URL.Path = strings.TrimPrefix(r.URL.Path, "/media/") - file, err := os.Open("./media/" + path.Clean(r.URL.Path)) + // r.URL.Path = strings.TrimPrefix(r.URL.Path, "/media/") + // file, err := os.Open("./media/" + path.Clean(r.URL.Path)) + // if err != nil { + // pageErrorHandler(w, r, session) + // return + // } + // io.Copy(w, file) + // file.Close() + + fName := path.Clean(r.URL.Path) + + f, err := os.Open("." + fName) if err != nil { - pageErrorHandler(w, r, session) + w.WriteHeader(404) + return + } + defer f.Close() + stat, err := os.Stat("." + fName) + if err != nil || stat.IsDir() { + w.WriteHeader(404) return } - io.Copy(w, file) - file.Close() + modTime := stat.ModTime() + if RetainMediaVersions { + w.Header().Add("Cache-Control", "private, max-age=604800") + } else { + w.Header().Add("Cache-Control", "private, max-age=3600") + } + + http.ServeContent(w, r, "."+fName, modTime, f) // Delete the file if exported to excel - if strings.HasPrefix(r.URL.Path, "export/") { - filePart := strings.TrimPrefix(r.URL.Path, "export/") + if strings.HasPrefix(fName, "/media/export/") { + filePart := strings.TrimPrefix(fName, "/media/export/") filePart = path.Clean(filePart) if filePart != "" && !strings.HasSuffix(filePart, "index.html") { os.Remove("./media/export/" + filePart) diff --git a/static_handler.go b/static_handler.go index 02260a9a..1b1ac833 100644 --- a/static_handler.go +++ b/static_handler.go @@ -71,7 +71,7 @@ func StaticHandler(w http.ResponseWriter, r *http.Request) { return } modTime = stat.ModTime() - w.Header().Add("Cache-Control", "private, max-age=3600") + w.Header().Add("Cache-Control", "private, max-age=604800") } else { modTime = time.Now() } From 8e46093f5ee64165f68d335f6a7dca5d54f3c5d8 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 11 May 2023 13:48:52 +0800 Subject: [PATCH 094/109] upgrade version to 0.10.0 --- global.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/global.go b/global.go index 2eb87adb..e2a9b377 100644 --- a/global.go +++ b/global.go @@ -81,7 +81,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.9.6" +const Version = "0.10.0" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. @@ -90,7 +90,8 @@ const Version = "0.9.6" // 0.7.0 Catterpiller // 0.8.0 Dragonfly // 0.9.0 Gnat -const VersionCodeName = "Gnat" +// 0.10.0 Gnat +const VersionCodeName = "Housefly" // Public Global Variables From ac0a5970c6520eab10864b58e58e3f5726fef2d8 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 06:21:02 +0400 Subject: [PATCH 095/109] add compress JSON setting --- admin.go | 13 ++++++++++--- check_csrf.go | 2 +- generate_translation.go | 2 +- global.go | 3 +++ setting.go | 15 +++++++++++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/admin.go b/admin.go index 1f11fb44..bb6904d9 100644 --- a/admin.go +++ b/admin.go @@ -154,7 +154,7 @@ func toSnakeCase(str string) string { // JSONMarshal Generates JSON format from an object func JSONMarshal(v interface{}, safeEncoding bool) ([]byte, error) { // b, err := json.Marshal(v) - b, err := json.MarshalIndent(v, "", " ") + b, err := jsonMarash(v) if safeEncoding { b = bytes.Replace(b, []byte("\\u003c"), []byte("<"), -1) @@ -164,19 +164,26 @@ func JSONMarshal(v interface{}, safeEncoding bool) ([]byte, error) { return b, err } +func jsonMarash(v interface{}) ([]byte, error) { + if CompressJSON { + return json.Marshal(v) + } + return json.MarshalIndent(v, "", " ") +} + // ReturnJSON returns json to the client func ReturnJSON(w http.ResponseWriter, r *http.Request, v interface{}) { // Set content type in header w.Header().Set("Content-Type", "application/json") // Marshal content - b, err := json.MarshalIndent(v, "", " ") + b, err := jsonMarash(v) if err != nil { response := map[string]interface{}{ "status": "error", "error_msg": fmt.Sprintf("unable to encode JSON. %s", err), } - b, _ = json.MarshalIndent(response, "", " ") + b, _ = jsonMarash(response) w.Write(b) return } diff --git a/check_csrf.go b/check_csrf.go index 64f9c57c..fcb45f45 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -40,7 +40,7 @@ If you you call this API: It will return an error message and the system will create a CRITICAL level log with details about the possible attack. To make the request -work, `x-csrf-token` paramtere should be added. +work, `x-csrf-token` parameter should be added. http://0.0.0.0:8080/myapi/?x-csrf-token=MY_SESSION_KEY diff --git a/generate_translation.go b/generate_translation.go index cd08abd2..33c4d6c8 100644 --- a/generate_translation.go +++ b/generate_translation.go @@ -168,7 +168,7 @@ func syncModelTranslation(m ModelSchema) map[string]int { fileName := "./static/i18n/" + pkgName + "/" + m.ModelName + ".en.json" - // Check if the fist doesn't exist and create it + // Check if the first doesn't exist and create it if _, err = os.Stat(fileName); os.IsNotExist(err) { buf, _ = json.MarshalIndent(structLang, "", " ") err = ioutil.WriteFile(fileName, buf, 0644) diff --git a/global.go b/global.go index 3577ed5c..9a068a2b 100644 --- a/global.go +++ b/global.go @@ -477,6 +477,9 @@ var APIPostQueryDeleteHandler func(http.ResponseWriter, *http.Request, map[strin // PreLoginHandler is a function that runs after all dAPI delete requests var PreLoginHandler func(r *http.Request, username string, password string) +// CompressJSON is a variable that allows the user to reduce the size of JSON responses +var CompressJSON = false + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/setting.go b/setting.go index 0b86a1af..e42e1ba6 100644 --- a/setting.go +++ b/setting.go @@ -287,6 +287,8 @@ func (s *Setting) ApplyValue() { Logo = v.(string) case "uAdmin.FavIcon": FavIcon = v.(string) + case "uAdmin.CompressJSON": + CompressJSON = v.(bool) } } @@ -821,6 +823,19 @@ func syncSystemSettings() { DataType: t.File(), Help: "the fav icon that shows on uAdmin UI", }, + { + Name: "Compress JSON", + Value: func(v bool) string { + n := 0 + if v { + n = 1 + } + return fmt.Sprint(n) + }(CompressJSON), + DefaultValue: "0", + DataType: t.Boolean(), + Help: "Compress JSON allows the system to reduce the size of json responses", + }, } // Prepare uAdmin Settings From 40ceef039024e2b05abb308126f869766354bf0c Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 06:40:19 +0400 Subject: [PATCH 096/109] omit deletedAt in json response --- model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model.go b/model.go index 7f886063..c178e5ff 100644 --- a/model.go +++ b/model.go @@ -8,5 +8,5 @@ import ( // in any other struct to make it a model for uadmin type Model struct { ID uint `gorm:"primary_key"` - DeletedAt gorm.DeletedAt `sql:"index"` + DeletedAt gorm.DeletedAt `sql:"index" json:",omitempty"` } From b3cd4d24c8f8de1c67f7587a87bb7e0a401d97a3 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 06:42:27 +0400 Subject: [PATCH 097/109] omit deletedAt in json response --- model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model.go b/model.go index c178e5ff..acae1e8b 100644 --- a/model.go +++ b/model.go @@ -8,5 +8,5 @@ import ( // in any other struct to make it a model for uadmin type Model struct { ID uint `gorm:"primary_key"` - DeletedAt gorm.DeletedAt `sql:"index" json:",omitempty"` + DeletedAt gorm.DeletedAt `sql:"index" json:"-"` } From 736e7904158f07fd3d3ff8a315be4e34d4925362 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 07:27:26 +0400 Subject: [PATCH 098/109] omit all zero value structs in JSON --- admin.go | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/admin.go b/admin.go index c6ed85ae..20df79b1 100644 --- a/admin.go +++ b/admin.go @@ -164,11 +164,53 @@ func JSONMarshal(v interface{}, safeEncoding bool) ([]byte, error) { return b, err } +func nullZeroValueStructs(record map[string]interface{}) map[string]interface{} { + for k := range record { + switch v := record[k].(type) { + case map[string]interface{}: + if id, ok := v["ID"].(float64); ok && id == 0 { + record[k] = nil + } else if id, ok := v["id"].(float64); ok && id == 0 { + record[k] = nil + } else { + record[k] = nullZeroValueStructs(v) + } + } + } + return record +} + +func removeZeroValueStructs(buf []byte) []byte { + response := map[string]interface{}{} + json.Unmarshal(buf, &response) + if _, ok := response["result"].([]interface{}); !ok { + return buf + } + val := response["result"].([]interface{}) + var record map[string]interface{} + for i := range val { + record = val[i].(map[string]interface{}) + record = nullZeroValueStructs(record) + val[i] = record + } + response["result"] = val + buf, _ = json.Marshal(response) + return buf +} + func jsonMarshal(v interface{}) ([]byte, error) { + var buf []byte + var err error if CompressJSON { - return json.Marshal(v) + buf, err = json.Marshal(v) + if err == nil { + buf = removeZeroValueStructs(buf) + } + } else { + buf, err = json.MarshalIndent(v, "", " ") } - return json.MarshalIndent(v, "", " ") + + return buf, err } // ReturnJSON returns json to the client From 58751ade1ed35a1aa45343140f3982fccf82e3da Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 08:59:39 +0400 Subject: [PATCH 099/109] omit all zero value structs in JSON for read one --- admin.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/admin.go b/admin.go index 20df79b1..f7b6f863 100644 --- a/admin.go +++ b/admin.go @@ -183,6 +183,11 @@ func nullZeroValueStructs(record map[string]interface{}) map[string]interface{} func removeZeroValueStructs(buf []byte) []byte { response := map[string]interface{}{} json.Unmarshal(buf, &response) + if val, ok := response["result"].(map[string]interface{}); ok { + val = nullZeroValueStructs(val) + buf, _ = json.Marshal(val) + return buf + } if _, ok := response["result"].([]interface{}); !ok { return buf } From 3b2c5338c0343cfc99ccdcf5011d853f3dce4d8a Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Thu, 25 May 2023 18:15:24 +0400 Subject: [PATCH 100/109] omit all zero value structs in JSON for read one --- admin.go | 2 +- global.go | 3 +++ setting.go | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/admin.go b/admin.go index f7b6f863..ed2a5880 100644 --- a/admin.go +++ b/admin.go @@ -208,7 +208,7 @@ func jsonMarshal(v interface{}) ([]byte, error) { var err error if CompressJSON { buf, err = json.Marshal(v) - if err == nil { + if err == nil && RemoveZeroValueJSON { buf = removeZeroValueStructs(buf) } } else { diff --git a/global.go b/global.go index 5ebef507..b55800bd 100644 --- a/global.go +++ b/global.go @@ -484,6 +484,9 @@ var PostUploadHandler func(filePath string, modelName string, f *F) string // CompressJSON is a variable that allows the user to reduce the size of JSON responses var CompressJSON = false +// CompressJSON is a variable that allows the user to reduce the size of JSON responses +var RemoveZeroValueJSON = false + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/setting.go b/setting.go index e42e1ba6..bf5fc771 100644 --- a/setting.go +++ b/setting.go @@ -289,6 +289,8 @@ func (s *Setting) ApplyValue() { FavIcon = v.(string) case "uAdmin.CompressJSON": CompressJSON = v.(bool) + case "uAdmin.RemoveZeroValueJSON": + RemoveZeroValueJSON = v.(bool) } } @@ -836,6 +838,19 @@ func syncSystemSettings() { DataType: t.Boolean(), Help: "Compress JSON allows the system to reduce the size of json responses", }, + { + Name: "Remove Zero Value JSON", + Value: func(v bool) string { + n := 0 + if v { + n = 1 + } + return fmt.Sprint(n) + }(RemoveZeroValueJSON), + DefaultValue: "0", + DataType: t.Boolean(), + Help: "Compress JSON allows the system to reduce the size of json responses", + }, } // Prepare uAdmin Settings From dea1d9c3f0dd2015f821f093a34f74bc396c8f22 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 2 Jun 2023 10:13:42 +0400 Subject: [PATCH 101/109] BUG FIX: return empty string if the object is nil --- representation.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/representation.go b/representation.go index 8373bafe..6bf25512 100644 --- a/representation.go +++ b/representation.go @@ -19,7 +19,10 @@ func GetID(m reflect.Value) uint { func GetString(a interface{}) string { str, ok := a.(fmt.Stringer) if ok { - return str.String() + if a != nil { + return str.String() + } + return "" } t := reflect.TypeOf(a) v := reflect.ValueOf(a) From 3e0abee2c080b7260ff83f640a2ced8ab6249615 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 27 Jun 2023 14:02:33 +0400 Subject: [PATCH 102/109] return 404 when dAPI read one doesn't find a record --- d_api_read.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/d_api_read.go b/d_api_read.go index cb357a64..8ab946e5 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -245,6 +245,8 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { if int(GetID(m)) != 0 { i = m.Interface() rowsCount = 1 + } else { + w.WriteHeader(404) } if params["$preload"] == "1" || params["$preload"] == "true" { From 1c93616f4855e45dc7339940ab1b68f1fa1551ce Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Tue, 27 Jun 2023 14:04:50 +0400 Subject: [PATCH 103/109] change the version to v0.10.1 --- global.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.go b/global.go index b55800bd..16497d76 100644 --- a/global.go +++ b/global.go @@ -81,7 +81,7 @@ const cEMAIL = "email" const cM2M = "m2m" // Version number as per Semantic Versioning 2.0.0 (semver.org) -const Version = "0.10.0" +const Version = "0.10.1" // VersionCodeName is the cool name we give to versions with significant changes. // This name should always be a bug's name starting from A-Z them revolving back. From 97dc027bb6d3bce7392679c4a3af60955d6fb348 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 7 Jul 2023 10:01:51 +0400 Subject: [PATCH 104/109] JWT does not need session for CSRF --- auth.go | 240 ++++++++++++++++++++++++++------------------------ check_csrf.go | 3 + 2 files changed, 130 insertions(+), 113 deletions(-) diff --git a/auth.go b/auth.go index 7258ac2e..eae748cc 100644 --- a/auth.go +++ b/auth.go @@ -575,140 +575,154 @@ func getSessionByKey(key string) *Session { return &s } -func getSession(r *http.Request) string { - key, err := r.Cookie("session") - if err == nil && key != nil { - return key.Value +func getJWT(r *http.Request) string { + // JWT + if r.Header.Get("Authorization") == "" { + return "" } - if r.Method == "GET" && r.FormValue("session") != "" { - return r.FormValue("session") + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer") { + return "" } - if r.Method != "GET" { - err := r.ParseMultipartForm(2 << 10) - if err != nil { - r.ParseForm() - } - if r.FormValue("session") != "" { - return r.FormValue("session") - } + + jwt := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + jwtParts := strings.Split(jwt, ".") + + if len(jwtParts) != 3 { + return "" } - // JWT - if r.Header.Get("Authorization") != "" { - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer") { - jwt := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - jwtParts := strings.Split(jwt, ".") - if len(jwtParts) != 3 { - return "" - } + jHeader, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[0]) + if err != nil { + return "" + } + jPayload, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[1]) + if err != nil { + return "" + } - jHeader, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[0]) - if err != nil { - return "" + header := map[string]interface{}{} + err = json.Unmarshal(jHeader, &header) + if err != nil { + return "" + } + + // Get data from payload + payload := map[string]interface{}{} + err = json.Unmarshal(jPayload, &payload) + if err != nil { + return "" + } + + // Verify issuer + if iss, ok := payload["iss"].(string); ok { + if iss != JWTIssuer { + accepted := false + for _, fiss := range AcceptedJWTIssuers { + if fiss == iss { + accepted = true + break + } } - jPayload, err := base64.RawURLEncoding.WithPadding(base64.NoPadding).DecodeString(jwtParts[1]) - if err != nil { + if !accepted { return "" } + } + } else { + return "" + } - header := map[string]interface{}{} - err = json.Unmarshal(jHeader, &header) - if err != nil { - return "" + // verify audience + if aud, ok := payload["aud"].(string); ok { + if aud != JWTIssuer { + return "" + } + } else if aud, ok := payload["aud"].([]string); ok { + accepted := false + for _, audItem := range aud { + if audItem == JWTIssuer { + accepted = true + break } + } + if !accepted { + return "" + } + } else { + return "" + } - // Get data from payload - payload := map[string]interface{}{} - err = json.Unmarshal(jPayload, &payload) - if err != nil { - return "" - } + // if there is no subject, return empty session + if _, ok := payload["sub"].(string); !ok { + return "" + } - // Verify issuer - if iss, ok := payload["iss"].(string); ok { - if iss != JWTIssuer { - accepted := false - for _, fiss := range AcceptedJWTIssuers { - if fiss == iss { - accepted = true - break - } - } - if !accepted { - return "" - } - } - } else { - return "" - } + sub := payload["sub"].(string) + user := User{} + Get(&user, "username = ?", sub) - // verify audience - if aud, ok := payload["aud"].(string); ok { - if aud != JWTIssuer { - return "" - } - } else if aud, ok := payload["aud"].([]string); ok { - accepted := false - for _, audItem := range aud { - if audItem == JWTIssuer { - accepted = true - break - } - } - if !accepted { - return "" - } - } else { - return "" - } + if user.ID == 0 { + return "" + } - // if there is no subject, return empty session - if _, ok := payload["sub"].(string); !ok { - return "" - } + session := user.GetActiveSession() + if session == nil { + return "" + } - sub := payload["sub"].(string) - user := User{} - Get(&user, "username = ?", sub) + // TODO: verify exp - if user.ID == 0 { - return "" - } + // Verify the signature + alg := "HS256" + if v, ok := header["alg"].(string); ok { + alg = v + } + if _, ok := header["typ"]; ok { + if v, ok := header["typ"].(string); !ok || v != "JWT" { + return "" + } + } + switch alg { + case "HS256": + // TODO: allow third party JWT signature authentication + hash := hmac.New(sha256.New, []byte(JWT+session.Key)) + hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) + token := hash.Sum(nil) + b64Token := base64.RawURLEncoding.EncodeToString(token) + if b64Token != jwtParts[2] { + return "" + } + default: + // For now, only support HMAC-SHA256 + return "" + } + return session.Key - session := user.GetActiveSession() - if session == nil { - return "" - } +} - // TODO: verify exp +func getSession(r *http.Request) string { + // First, try JWT + if val := getJWT(r); val != "" { + return val + } - // Verify the signature - alg := "HS256" - if v, ok := header["alg"].(string); ok { - alg = v - } - if _, ok := header["typ"]; ok { - if v, ok := header["typ"].(string); !ok || v != "JWT" { - return "" - } - } - switch alg { - case "HS256": - // TODO: allow third party JWT signature authentication - hash := hmac.New(sha256.New, []byte(JWT+session.Key)) - hash.Write([]byte(jwtParts[0] + "." + jwtParts[1])) - token := hash.Sum(nil) - b64Token := base64.RawURLEncoding.EncodeToString(token) - if b64Token != jwtParts[2] { - return "" - } - default: - // For now, only support HMAC-SHA256 - return "" - } - return session.Key + // Then try session + key, err := r.Cookie("session") + if err == nil && key != nil { + return key.Value + } + if r.Method == "GET" && r.FormValue("session") != "" { + return r.FormValue("session") + } + if r.Method != "GET" { + err := r.ParseMultipartForm(2 << 10) + if err != nil { + r.ParseForm() + } + if r.FormValue("session") != "" { + return r.FormValue("session") } } + return "" } diff --git a/check_csrf.go b/check_csrf.go index fcb45f45..5036244f 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -47,6 +47,9 @@ work, `x-csrf-token` parameter should be added. Where you replace `MY_SESSION_KEY` with the session key. */ func CheckCSRF(r *http.Request) bool { + if getJWT(r) != "" { + return false + } token := getCSRFToken(r) if token != "" && token == getSession(r) { return false From 71ff8369060798d3e91d0fe9608351ca50114dc8 Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Sat, 8 Jul 2023 21:17:38 +0400 Subject: [PATCH 105/109] OpenID Connect for SSO --- auth.go | 249 +++++++++++++++++-- d_api_auth.go | 4 + d_api_openid_cert_handler.go | 45 ++++ d_api_openid_login.go | 71 ++++++ global.go | 3 + go.mod | 1 + go.sum | 2 + login_handler.go | 7 + main_handler.go | 5 + openid_config_handler.go | 50 ++++ register.go | 4 + templates/uadmin/default/login.html | 11 + templates/uadmin/default/openid_concent.html | 79 ++++++ 13 files changed, 516 insertions(+), 15 deletions(-) create mode 100644 d_api_openid_cert_handler.go create mode 100644 d_api_openid_login.go create mode 100644 openid_config_handler.go create mode 100644 templates/uadmin/default/openid_concent.html diff --git a/auth.go b/auth.go index eae748cc..8a57fa52 100644 --- a/auth.go +++ b/auth.go @@ -4,12 +4,16 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" + "io" "math/big" "net" + "os" "path" "crypto/hmac" "crypto/rand" + "crypto/rsa" "crypto/sha256" "math" "net/http" @@ -17,6 +21,7 @@ import ( "strings" "time" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) @@ -36,6 +41,8 @@ var JWT = "" // used to identify the as JWT audience. var JWTIssuer = "" +var JWTAlgo = "HS256" //"RS256" + // AcceptedJWTIssuers is a list of accepted JWT issuers. By default the // local JWTIssuer is accepted. To accept other issuers, add them to // this list @@ -157,15 +164,20 @@ func createJWT(r *http.Request, s *Session) string { if !isValidSession(r, s) { return "" } + alg := JWTAlgo + aud := JWTIssuer + if r.Context().Value(CKey("aud")) != nil { + aud = r.Context().Value(CKey("aud")).(string) + } header := map[string]interface{}{ - "alg": "HS256", + "alg": alg, "typ": "JWT", } payload := map[string]interface{}{ "sub": s.User.Username, "iat": s.LastLogin.Unix(), "iss": JWTIssuer, - "aud": JWTIssuer, + "aud": aud, } if s.ExpiresOn != nil { payload["exp"] = s.ExpiresOn.Unix() @@ -176,16 +188,44 @@ func createJWT(r *http.Request, s *Session) string { payload = CustomJWT(r, s, payload) } - jHeader, _ := json.Marshal(header) - jPayload, _ := json.Marshal(payload) - b64Header := base64.RawURLEncoding.EncodeToString(jHeader) - b64Payload := base64.RawURLEncoding.EncodeToString(jPayload) + if alg == "HS256" { + jHeader, _ := json.Marshal(header) + jPayload, _ := json.Marshal(payload) + b64Header := base64.RawURLEncoding.EncodeToString(jHeader) + b64Payload := base64.RawURLEncoding.EncodeToString(jPayload) + + hash := hmac.New(sha256.New, []byte(JWT+s.Key)) + hash.Write([]byte(b64Header + "." + b64Payload)) + signature := hash.Sum(nil) + b64Signature := base64.RawURLEncoding.EncodeToString(signature) + return b64Header + "." + b64Payload + "." + b64Signature + } else if alg == "RS256" { + buf, err := os.ReadFile(".jwt-rsa-private.pem") + if err != nil { + return "" + } + key, err := jwt.ParseRSAPrivateKeyFromPEM(buf) + if err != nil { + return "" + } + header["kid"] = "1" + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(payload)) + + for k, v := range header { + token.Header[k] = v + } + + tokenRaw, err := token.SignedString(key) + + if err != nil { + return "" + } + return tokenRaw + } else { + Trail(ERROR, "Unknown algorithm for JWT (%s)", alg) + return "" + } - hash := hmac.New(sha256.New, []byte(JWT+s.Key)) - hash.Write([]byte(b64Header + "." + b64Payload)) - signature := hash.Sum(nil) - b64Signature := base64.RawURLEncoding.EncodeToString(signature) - return b64Header + "." + b64Payload + "." + b64Signature } func isValidSession(r *http.Request, s *Session) bool { @@ -584,8 +624,8 @@ func getJWT(r *http.Request) string { return "" } - jwt := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - jwtParts := strings.Split(jwt, ".") + jwtToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + jwtParts := strings.Split(jwtToken, ".") if len(jwtParts) != 3 { return "" @@ -614,10 +654,12 @@ func getJWT(r *http.Request) string { } // Verify issuer + SSOLogin := false if iss, ok := payload["iss"].(string); ok { if iss != JWTIssuer { accepted := false for _, fiss := range AcceptedJWTIssuers { + Trail(DEBUG, "fiss:%s, iss:%s", fiss, iss) if fiss == iss { accepted = true break @@ -626,6 +668,7 @@ func getJWT(r *http.Request) string { if !accepted { return "" } + SSOLogin = true } } else { return "" @@ -660,12 +703,31 @@ func getJWT(r *http.Request) string { user := User{} Get(&user, "username = ?", sub) - if user.ID == 0 { + if user.ID == 0 && SSOLogin { + user := User{ + Username: sub, + FirstName: sub, + Active: true, + Admin: true, + RemoteAccess: true, + Password: GenerateBase64(64), + } + user.Save() + } else if user.ID == 0 { return "" } session := user.GetActiveSession() - if session == nil { + if session == nil && SSOLogin { + session = &Session{ + UserID: user.ID, + Active: true, + LoginTime: time.Now(), + IP: GetRemoteIP(r), + } + session.GenerateKey() + session.Save() + } else if session == nil { return "" } @@ -681,6 +743,7 @@ func getJWT(r *http.Request) string { return "" } } + // verify signature switch alg { case "HS256": // TODO: allow third party JWT signature authentication @@ -691,20 +754,176 @@ func getJWT(r *http.Request) string { if b64Token != jwtParts[2] { return "" } + case "RS256": + if !verifyRSA(jwtToken, SSOLogin) { + return "" + } default: // For now, only support HMAC-SHA256 return "" } + return session.Key } +var jwtIssuerCerts = map[[2]string][]byte{} + +func getJWTRSAPublicKeySSO(jwtToken *jwt.Token) *rsa.PublicKey { + iss, err := jwtToken.Claims.GetIssuer() + if err != nil { + return nil + } + + kid, _ := jwtToken.Header["kid"].(string) + if kid == "" { + return nil + } + + if val, ok := jwtIssuerCerts[[2]string{iss, kid}]; ok { + cert, _ := jwt.ParseRSAPublicKeyFromPEM(val) + return cert + } + + res, err := http.Get(iss + "/.well-known/openid-configuration") + if err != nil { + return nil + } + + if res.StatusCode != 200 { + return nil + } + + buf, err := io.ReadAll(res.Body) + if err != nil { + return nil + } + + obj := map[string]interface{}{} + err = json.Unmarshal(buf, &obj) + if err != nil { + return nil + } + + crtURL := "" + if val, ok := obj["jwks_uri"].(string); !ok || val == "" { + return nil + } else { + crtURL = val + } + + res, err = http.Get(crtURL) + if err != nil { + return nil + } + + if res.StatusCode != 200 { + return nil + } + + buf, err = io.ReadAll(res.Body) + if err != nil { + return nil + } + + certObj := map[string][]map[string]string{} + err = json.Unmarshal(buf, &certObj) + if err != nil { + return nil + } + + if val, ok := certObj["keys"]; !ok || len(val) == 0 { + return nil + } + + var cert map[string]string + for i := range certObj["keys"] { + if certObj["keys"][i]["kid"] == kid { + cert = certObj["keys"][i] + break + } + } + + if cert == nil { + return nil + } + + N := new(big.Int) + buf, _ = base64.RawURLEncoding.DecodeString(cert["n"]) + N = N.SetBytes(buf) + + E := new(big.Int) + buf, _ = base64.RawURLEncoding.DecodeString(cert["e"]) + E = E.SetBytes(buf) + publicCert := rsa.PublicKey{ + N: N, + E: int(E.Int64()), + } + + Trail(DEBUG, publicCert) + + return &publicCert +} + +func getJWTRSAPublicKeyLocal(jwtToken *jwt.Token) *rsa.PublicKey { + pubKeyPEM, err := os.ReadFile(".jwt-rsa-public.pem") + if err != nil { + return nil + } + + pubKey, err := jwt.ParseRSAPublicKeyFromPEM(pubKeyPEM) + if err != nil { + return nil + } + + return pubKey +} + +func verifyRSA(token string, SSOLogin bool) bool { + tok, err := jwt.Parse(token, func(jwtToken *jwt.Token) (interface{}, error) { + if _, ok := jwtToken.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected method: %s", jwtToken.Header["alg"]) + } + + var pubKey *rsa.PublicKey + + if SSOLogin { + pubKey = getJWTRSAPublicKeySSO(jwtToken) + } else { + pubKey = getJWTRSAPublicKeyLocal(jwtToken) + } + + if pubKey == nil { + return nil, fmt.Errorf("Unable to load local public key") + } + + return pubKey, nil + }) + if err != nil { + return false + } + + _, ok := tok.Claims.(jwt.MapClaims) + if !ok || !tok.Valid { + return false + } + + return true +} + func getSession(r *http.Request) string { // First, try JWT if val := getJWT(r); val != "" { return val } + if r.URL.Query().Get("access-token") != "" { + r.Header.Add("Authorization", "Bearer "+r.URL.Query().Get("access-token")) + if val := getJWT(r); val != "" { + return val + } + } + // Then try session key, err := r.Cookie("session") if err == nil && key != nil { diff --git a/d_api_auth.go b/d_api_auth.go index 6481486c..02c2af2d 100644 --- a/d_api_auth.go +++ b/d_api_auth.go @@ -31,6 +31,10 @@ func dAPIAuthHandler(w http.ResponseWriter, r *http.Request, s *Session) { dAPIResetPasswordHandler(w, r, s) case "changepassword": dAPIChangePasswordHandler(w, r, s) + case "openidlogin": + dAPIOpenIDLoginHandler(w, r, s) + case "certs": + dAPIOpenIDCertHandler(w, r) default: w.WriteHeader(http.StatusNotFound) ReturnJSON(w, r, map[string]interface{}{ diff --git a/d_api_openid_cert_handler.go b/d_api_openid_cert_handler.go new file mode 100644 index 00000000..9b82e788 --- /dev/null +++ b/d_api_openid_cert_handler.go @@ -0,0 +1,45 @@ +package uadmin + +import ( + "encoding/base64" + "math/big" + "net/http" + "os" + + "github.com/golang-jwt/jwt/v5" +) + +func dAPIOpenIDCertHandler(w http.ResponseWriter, r *http.Request) { + buf, err := os.ReadFile(".jwt-rsa-public.pem") + if err != nil { + w.WriteHeader(404) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Unable to load public certificate", + }) + return + } + cert, err := jwt.ParseRSAPublicKeyFromPEM(buf) + if err != nil { + w.WriteHeader(404) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Unable to parse public certificate", + }) + return + } + obj := map[string][]map[string]string{ + "keys": { + { + "kid": "1", + "use": "sig", + "kty": "RSA", + "alg": "RS256", + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(cert.E)).Bytes()), + "n": base64.RawURLEncoding.EncodeToString(cert.N.Bytes()), + }, + }, + } + + ReturnJSON(w, r, obj) +} diff --git a/d_api_openid_login.go b/d_api_openid_login.go new file mode 100644 index 00000000..50bbc067 --- /dev/null +++ b/d_api_openid_login.go @@ -0,0 +1,71 @@ +package uadmin + +import ( + "context" + "net/http" + "strings" +) + +func dAPIOpenIDLoginHandler(w http.ResponseWriter, r *http.Request, s *Session) { + _ = s + redirectURI := r.FormValue("redirect_uri") + + if r.Method == "GET" { + if session := IsAuthenticated(r); session != nil { + Preload(session, "User") + c := map[string]interface{}{ + "SiteName": SiteName, + "Language": getLanguage(r), + "RootURL": RootURL, + "Logo": Logo, + "user": s.User, + "OpenIDWebsiteURL": redirectURI, + } + RenderHTML(w, r, "./templates/uadmin/"+Theme+"/openid_concent.html", c) + return + } + + http.Redirect(w, r, RootURL+"login/?next="+RootURL+"api/d/auth/openidlogin?"+r.URL.Query().Encode(), 303) + return + } + + if s == nil { + w.WriteHeader(http.StatusUnauthorized) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "Invalid credentials", + }) + return + } + + // Preload the user to get the group name + Preload(&s.User) + + ctx := context.WithValue(r.Context(), CKey("aud"), getAUD(redirectURI)) + r = r.WithContext(ctx) + jwt := createJWT(r, s) + + http.Redirect(w, r, redirectURI+"?access-token="+jwt, 303) + +} + +func getAUD(URL string) string { + aud := "" + + if strings.HasPrefix(URL, "https://") { + aud = "https://" + URL = strings.TrimPrefix(URL, "https://") + } + + if strings.HasPrefix(URL, "http://") { + aud = "http://" + URL = strings.TrimPrefix(URL, "http://") + } + + if strings.Contains(URL, "/") { + URL = URL[:strings.Index(URL, "/")] + aud += URL + } + + return aud +} diff --git a/global.go b/global.go index 16497d76..e923b301 100644 --- a/global.go +++ b/global.go @@ -487,6 +487,9 @@ var CompressJSON = false // CompressJSON is a variable that allows the user to reduce the size of JSON responses var RemoveZeroValueJSON = false +// SSOURL enables SSO using OpenID Connect +var SSOURL = "" + // Private Global Variables // Regex var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") diff --git a/go.mod b/go.mod index 4c2ac87f..f9aa324a 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( require ( github.com/boombuler/barcode v1.0.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.3.0 // indirect diff --git a/go.sum b/go.sum index 9a1dc4dc..5dea4bf6 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ 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-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/login_handler.go b/login_handler.go index 19053791..7173dcfc 100644 --- a/login_handler.go +++ b/login_handler.go @@ -19,6 +19,7 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { Password string Logo string FavIcon string + SSOURL string } c := Context{} @@ -27,6 +28,12 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { c.Language = getLanguage(r) c.Logo = Logo c.FavIcon = FavIcon + c.SSOURL = SSOURL + + if session := IsAuthenticated(r); session != nil { + session = session.User.GetActiveSession() + SetSessionCookie(w, r, session) + } if r.Method == cPOST { if r.FormValue("save") == "Send Request" { diff --git a/main_handler.go b/main_handler.go index e17bef0e..3b83a637 100644 --- a/main_handler.go +++ b/main_handler.go @@ -41,6 +41,7 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { // This session is preloaded with a user session := IsAuthenticated(r) if session == nil { + Trail(DEBUG, "no auth, Login page") loginHandler(w, r) return } @@ -80,6 +81,10 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { settingsHandler(w, r, session) return } + if URLParts[0] == "login" { + loginHandler(w, r) + return + } listHandler(w, r, session) return } else if len(URLParts) == 2 { diff --git a/openid_config_handler.go b/openid_config_handler.go new file mode 100644 index 00000000..f22d2e4a --- /dev/null +++ b/openid_config_handler.go @@ -0,0 +1,50 @@ +package uadmin + +import "net/http" + +func JWTConfigHandler(w http.ResponseWriter, r *http.Request) { + data := map[string]interface{}{ + "issuer": JWTIssuer, + "authorization_endpoint": JWTIssuer + "/api/d/auth/openidlogin", + "token_endpoint": "", + "userinfo_endpoint": JWTIssuer + "/api/d/auth/userinfo", + "jwks_uri": JWTIssuer + "/api/d/auth/certs", + "scopes_supported": []string{ + "openid", + "email", + "profile", + }, + "response_types_supported": []string{ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none", + }, + "subject_types_supported": []string{ + "public", + }, + "id_token_signing_alg_values_supported": []string{ + "RS256", + }, + "claims_supported": []string{ + "aud", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "iss", + "locale", + "name", + "picture", + "sub", + }, + } + + ReturnJSON(w, r, data) +} diff --git a/register.go b/register.go index fa7586c1..a90d7bd4 100644 --- a/register.go +++ b/register.go @@ -382,5 +382,9 @@ func registerHandlers() { http.HandleFunc(RootURL+"api/", Handler(apiHandler)) } + if !DisableDAPIAuth { + http.HandleFunc(RootURL+".well-known/openid-configuration/", Handler(JWTConfigHandler)) + } + handlersRegistered = true } diff --git a/templates/uadmin/default/login.html b/templates/uadmin/default/login.html index dca60b1b..76ca0f0d 100644 --- a/templates/uadmin/default/login.html +++ b/templates/uadmin/default/login.html @@ -95,6 +95,7 @@

{{Tf "uadmin/system" .La Forgot Password + {{if .SSOURL}}SSO Login{{end}}
{{if .ErrExists}}
@@ -150,6 +151,16 @@

+ + +
+ +
+ +
+
+
+ +
+
+
+ +
+
+
+ + + + +

+ Click Continue +

+

+ to login to {{.OpenIDWebsiteURL}} as {{.user.Username}} +

+
+
+
+ +
+ + +
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + \ No newline at end of file From 4f7e266154f8a2cca4ae3dd2e61fb79a4f011d7d Mon Sep 17 00:00:00 2001 From: Abdullah Alrasheed Date: Fri, 21 Jul 2023 12:09:40 +0400 Subject: [PATCH 106/109] updates for SSO for browser compatibility --- auth.go | 3 --- d_api_openid_login.go | 2 ++ login_handler.go | 3 +++ main_handler.go | 1 - templates/uadmin/default/login.html | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/auth.go b/auth.go index 8a57fa52..cb083b83 100644 --- a/auth.go +++ b/auth.go @@ -659,7 +659,6 @@ func getJWT(r *http.Request) string { if iss != JWTIssuer { accepted := false for _, fiss := range AcceptedJWTIssuers { - Trail(DEBUG, "fiss:%s, iss:%s", fiss, iss) if fiss == iss { accepted = true break @@ -860,8 +859,6 @@ func getJWTRSAPublicKeySSO(jwtToken *jwt.Token) *rsa.PublicKey { E: int(E.Int64()), } - Trail(DEBUG, publicCert) - return &publicCert } diff --git a/d_api_openid_login.go b/d_api_openid_login.go index 50bbc067..fa2e5f1d 100644 --- a/d_api_openid_login.go +++ b/d_api_openid_login.go @@ -10,6 +10,8 @@ func dAPIOpenIDLoginHandler(w http.ResponseWriter, r *http.Request, s *Session) _ = s redirectURI := r.FormValue("redirect_uri") + Trail(DEBUG, "HERE") + if r.Method == "GET" { if session := IsAuthenticated(r); session != nil { Preload(session, "User") diff --git a/login_handler.go b/login_handler.go index 7173dcfc..f87dadfc 100644 --- a/login_handler.go +++ b/login_handler.go @@ -33,6 +33,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { if session := IsAuthenticated(r); session != nil { session = session.User.GetActiveSession() SetSessionCookie(w, r, session) + if r.URL.Query().Get("next") != "" { + http.Redirect(w, r, r.URL.Query().Get("next"), 303) + } } if r.Method == cPOST { diff --git a/main_handler.go b/main_handler.go index 3b83a637..e9c4095a 100644 --- a/main_handler.go +++ b/main_handler.go @@ -41,7 +41,6 @@ func mainHandler(w http.ResponseWriter, r *http.Request) { // This session is preloaded with a user session := IsAuthenticated(r) if session == nil { - Trail(DEBUG, "no auth, Login page") loginHandler(w, r) return } diff --git a/templates/uadmin/default/login.html b/templates/uadmin/default/login.html index 76ca0f0d..081a4d82 100644 --- a/templates/uadmin/default/login.html +++ b/templates/uadmin/default/login.html @@ -154,7 +154,7 @@