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: diff --git a/admin.go b/admin.go index 8456fbf9..ed2a5880 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 := jsonMarshal(v) if safeEncoding { b = bytes.Replace(b, []byte("\\u003c"), []byte("<"), -1) @@ -164,22 +164,81 @@ 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 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 + } + 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 { + buf, err = json.Marshal(v) + if err == nil && RemoveZeroValueJSON { + buf = removeZeroValueStructs(buf) + } + } else { + buf, err = json.MarshalIndent(v, "", " ") + } + + return buf, err +} + // 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 := jsonMarshal(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, _ = jsonMarshal(response) w.Write(b) return } + + if CustomizeJSON != nil { + b = CustomizeJSON(w, r, v, b) + } + w.Write(b) } diff --git a/admin_test.go b/admin_test.go index 84bbe5dd..041741dc 100644 --- a/admin_test.go +++ b/admin_test.go @@ -204,12 +204,12 @@ func (t *UAdminTests) TestReturnJSON() { out string }{ {map[string]interface{}{"ID": 1, "Name": "Test"}, `{ - "ID": 1, - "Name": "Test" + "ID": 1, + "Name": "Test" }`}, {math.NaN(), `{ - "error_msg": "unable to encode JSON. json: unsupported value: NaN", - "status": "error" + "error_msg": "unable to encode JSON. json: unsupported value: NaN", + "status": "error" }`}, } diff --git a/auth.go b/auth.go index 8388c199..ac9e5120 100644 --- a/auth.go +++ b/auth.go @@ -4,12 +4,17 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" + "io" "math/big" "net" + "os" "path" + "sync" "crypto/hmac" "crypto/rand" + "crypto/rsa" "crypto/sha256" "math" "net/http" @@ -17,6 +22,7 @@ import ( "strings" "time" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) @@ -36,6 +42,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 @@ -47,6 +55,9 @@ var bcryptDiff = 12 // cachedSessions is variable for keeping active sessions var cachedSessions map[string]Session +// Need to have a lock to protect it from race conditions during concurrent writes. +var cachedSessionsMutex sync.RWMutex + // invalidAttempts keeps track of invalid password attempts // per IP address var invalidAttempts = map[string]int{} @@ -84,6 +95,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, bcryptDiff) if err != nil { Trail(ERROR, "uadmin.auth.hashPass.GenerateFromPassword: %s", err) @@ -154,15 +168,22 @@ func createJWT(r *http.Request, s *Session) string { if !isValidSession(r, s) { return "" } + alg := JWTAlgo + aud := JWTIssuer + SSO := false + if r.Context().Value(CKey("aud")) != nil { + aud = r.Context().Value(CKey("aud")).(string) + SSO = true + } 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() @@ -173,16 +194,98 @@ 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) + // TODO: Add custom handler to customize JWT + // This custom function show have parameters for: + // JWT Object + // SSO boolean + // Algorithm + // User + // *Session + + 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 "" + } + + // Customize JWT Data + header["kid"] = "1" + + // Extra customization for SSO + if SSO { + payload["name"] = s.User.String() + payload["given_name"] = s.User.FirstName + payload["family_name"] = s.User.LastName + payload["email"] = s.User.Email + if s.User.Photo != "" { + payload["picture"] = JWTIssuer + strings.TrimSuffix(RootURL, "/") + s.User.Photo + "?token=" + strings.TrimPrefix(hashPass(s.User.Photo), "$2a$12$") + } + + groups := []map[string]interface{}{} + + if s.User.UserGroupID != 0 { + Preload(&s.User, "UserGroup") + groups = append(groups, map[string]interface{}{ + "displayName": s.User.UserGroup.GroupName, + "id": s.User.UserGroupID, + }) + } + if s.User.Admin { + groups = append(groups, map[string]interface{}{ + "displayName": "$admin", + "id": 0, + }) + } + payload["groups"] = groups + + entitlements := []map[string]interface{}{} + for k := range models { + perm := s.User.GetAccess(k) + entitlements = append(entitlements, map[string]interface{}{ + "modelName": k, + "read": perm.Read, + "add": perm.Add, + "edit": perm.Edit, + "delete": perm.Delete, + "approval": perm.Approval, + }) + } + + payload["entitlements"] = entitlements + } + + 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 { @@ -228,7 +331,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 @@ -236,6 +339,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) @@ -354,6 +460,8 @@ func Logout(r *http.Request) { // Delete the cookie from memory if we sessions are cached if CacheSessions { + cachedSessionsMutex.Lock() // Lock the mutex in order to protect from concurrent writes + defer cachedSessionsMutex.Unlock() // Ensure the mutex is unlocked when the function exits delete(cachedSessions, s.Key) } @@ -559,6 +667,8 @@ func getNetSize(r *http.Request, net string) int { func getSessionByKey(key string) *Session { s := Session{} if CacheSessions { + cachedSessionsMutex.RLock() // Lock the mutex in order to protect from concurrent writes + defer cachedSessionsMutex.RUnlock() // Ensure the mutex is unlocked when the function exits s = cachedSessions[key] } else { Get(&s, "`key` = ?", key) @@ -569,137 +679,353 @@ 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 == "POST" { - r.ParseForm() - if r.FormValue("session") != "" { - return r.FormValue("session") - } + + jwtToken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + jwtParts := strings.Split(jwtToken, ".") + + 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 + SSOLogin := false + 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 "" } + SSOLogin = true + } + } 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 "" + sub := payload["sub"].(string) + user := User{} + Get(&user, "username = ?", sub) + + if user.ID == 0 && SSOLogin { + now := time.Now() + user := User{ + Username: sub, + FirstName: payload["given_name"].(string), + LastName: payload["family_name"].(string), + Active: true, + Admin: func() bool { + for _, group := range payload["groups"].([]interface{}) { + if group.(map[string]interface{})["id"].(float64) == 0 { + return true } } - } else { - return "" - } + return false + }(), + LastLogin: &now, + RemoteAccess: true, //TODO: add remote access in JWT + Password: GenerateBase64(64), + } - // 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 "" - } + // TODO: Add custom function to customize the user before saving + // this function will receive the following parameters: + // payload, *user - // if there is no subject, return empty session - if _, ok := payload["sub"].(string); !ok { - return "" - } + user.Save() - sub := payload["sub"].(string) - user := User{} - Get(&user, "username = ?", sub) + // process entitlements + // TODO: find a way to refresh entitlements every login + } else if user.ID == 0 { + return "" + } - if user.ID == 0 { - return "" - } + session := user.GetActiveSession() + if session == nil && SSOLogin { + session = &Session{ + UserID: user.ID, + Active: true, + LoginTime: time.Now(), + IP: GetRemoteIP(r), + } + session.GenerateKey() - session := user.GetActiveSession() - if session == nil { - return "" - } + // TODO: Add custom function to customize the user session + // this function will receive the following parameters: + // payload, user, *session - // TODO: verify exp + session.Save() + } else if session == nil { + 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 + // 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 "" } } + // verify signature + 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 "" + } + 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()), + } + + 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 { + 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 "" } @@ -707,11 +1033,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 +1066,44 @@ 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) + if len(hashedPassword) > 72 { + hashedPassword = hashedPassword[:72] + } + if len(password) > 72 { + password = password[:72] + } + 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/check_csrf.go b/check_csrf.go index 64f9c57c..5036244f 100644 --- a/check_csrf.go +++ b/check_csrf.go @@ -40,13 +40,16 @@ 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 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 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/cors_handler.go b/cors_handler.go new file mode 100644 index 00000000..162cf6a2 --- /dev/null +++ b/cors_handler.go @@ -0,0 +1,50 @@ +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 { + 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 + 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/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/d_api.go b/d_api.go index d7b9883d..537e5d04 100644 --- a/d_api.go +++ b/d_api.go @@ -3,6 +3,7 @@ package uadmin import ( "context" "net/http" + "strconv" "strings" ) @@ -113,17 +114,40 @@ 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, "/") urlParts := strings.Split(r.URL.Path, "/") - Trail(DEBUG, "%#v", urlParts) 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{}{ @@ -132,17 +156,8 @@ func dAPIHandler(w http.ResponseWriter, r *http.Request, s *Session) { }) return } - if urlParts[0] == "$allmodels" { - 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 } @@ -154,6 +169,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 } } @@ -165,69 +189,113 @@ 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 APIPreQueryReadHandler != nil && !APIPreQueryReadHandler(w, r) { + return + } if preQuery, ok := model.(APIPreQueryReader); ok && !preQuery.APIPreQueryRead(w, r) { } else { dAPIReadHandler(w, r, s) } return } - if urlParts[1] == "add" { + 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) } + return } - if urlParts[1] == "edit" { + 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) } + return } - if urlParts[1] == "delete" { + 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) } + 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 41bf8792..e8ef728a 100644 --- a/d_api_add.go +++ b/d_api_add.go @@ -13,14 +13,14 @@ 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 // Check CSRF if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", @@ -77,7 +77,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) @@ -93,7 +93,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{} @@ -105,7 +106,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{}{ @@ -154,6 +163,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_auth.go b/d_api_auth.go index 16ebbdd0..02c2af2d 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, "/") @@ -30,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_change_password.go b/d_api_change_password.go index c4d00b20..81d1bd30 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": "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 + } + + 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_delete.go b/d_api_delete.go index b4d9b9f8..da766790 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 @@ -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.", @@ -59,7 +60,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 +126,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 ada2444a..1cfe012e 100644 --- a/d_api_edit.go +++ b/d_api_edit.go @@ -9,13 +9,14 @@ 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 // Check CSRF if CheckCSRF(r) { + w.WriteHeader(http.StatusForbidden) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Failed CSRF protection.", @@ -98,16 +99,15 @@ 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) 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 { + w.WriteHeader(400) ReturnJSON(w, r, map[string]interface{}{ "status": "error", "err_msg": "Unable to update database. " + db.Error.Error(), @@ -142,7 +142,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", @@ -153,13 +162,21 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { createAPIEditLog(modelName, modelArray.Elem().Index(i).Interface(), &s.User, r) } } - } else if len(urlParts) == 3 { + + // Execute business logic + if _, ok := model.Addr().Interface().(saver); ok { + 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() + } + } + } else if len(urlParts) == 1 { // Edit One m, _ := NewModel(modelName, true) - if log { - 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", @@ -179,7 +196,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 @@ -190,7 +207,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() @@ -199,6 +216,14 @@ func dAPIEditHandler(w http.ResponseWriter, r *http.Request, s *Session) { createAPIEditLog(modelName, m.Interface(), &s.User, r) } + // Execute business logic + 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{}{ "status": "ok", "rows_count": rowsAffected, diff --git a/d_api_helper.go b/d_api_helper.go index 62ce8239..9bcb7b59 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{}{} @@ -144,6 +126,10 @@ 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 + 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)...) @@ -165,6 +151,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] == '!' @@ -621,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 @@ -628,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 @@ -635,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 @@ -642,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 @@ -655,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_login.go b/d_api_login.go index 19e5704d..608b90f2 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,16 +34,19 @@ 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{}{ + res := map[string]interface{}{ "status": "ok", "session": s.Key, "jwt": jwt, @@ -54,8 +54,12 @@ 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, }, - }) + } + if CustomDAPILoginHandler != nil { + res = CustomDAPILoginHandler(r, &s.User, res) + } + ReturnJSON(w, r, res) } diff --git a/d_api_logout.go b/d_api_logout.go index 671206fe..1dd276c0 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.StatusForbidden) + 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_method.go b/d_api_method.go index cf7d3299..48d9b18e 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", @@ -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.", @@ -31,31 +32,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_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..fa2e5f1d --- /dev/null +++ b/d_api_openid_login.go @@ -0,0 +1,73 @@ +package uadmin + +import ( + "context" + "net/http" + "strings" +) + +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") + 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/d_api_read.go b/d_api_read.go index 0f231af3..8ab946e5 100644 --- a/d_api_read.go +++ b/d_api_read.go @@ -9,9 +9,10 @@ import ( func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { var rowsCount int64 + var err error 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 +53,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{} @@ -68,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) @@ -93,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 } @@ -129,10 +136,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 +151,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 +167,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,8 +179,19 @@ 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 read query. " + err.Error(), + }) + return + } + // 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()) @@ -183,6 +201,26 @@ func dAPIReadHandler(w http.ResponseWriter, r *http.Request, s *Session) { // Process M2M getQueryM2M(params, m, customSchema, modelName) + // Process Full Media URL + // 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 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": m, @@ -193,29 +231,51 @@ 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]) + 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{} if int(GetID(m)) != 0 { i = m.Interface() rowsCount = 1 + } else { + w.WriteHeader(404) } if params["$preload"] == "1" || params["$preload"] == "true" { 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, }, 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_reset_password.go b/d_api_reset_password.go index 22034da9..6d1c6dd9 100644 --- a/d_api_reset_password.go +++ b/d_api_reset_password.go @@ -1,7 +1,174 @@ package uadmin -import "net/http" +import ( + "fmt" + "net/http" + "time" +) func dAPIResetPasswordHandler(w http.ResponseWriter, r *http.Request, s *Session) { + // Get parameters + 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 email == "" && uid == "" { + w.WriteHeader(400) + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": "No email or uid", + }) + // 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 email != "" { + Get(&user, "email = ? AND active = ?", email, true) + } else { + Get(&user, "id = ? AND active = ?", uid, 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) + identifier := "email" + identifierVal := email + if uid != "" { + identifier = "uid" + identifierVal = uid + } + ReturnJSON(w, r, map[string]interface{}{ + "status": "error", + "err_msg": fmt.Sprintf("%s: '%s' do not match any active user", identifier, identifierVal), + }) + // 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(&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.VerifyOTPAtPasswordReset(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_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") diff --git a/d_api_signup.go b/d_api_signup.go index c1131139..77b29a0b 100644 --- a/d_api_signup.go +++ b/d_api_signup.go @@ -3,5 +3,84 @@ 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", + }) + return + } + + // 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/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.go b/db.go index 9dbbb254..e0dae6d7 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 (?);", }, } @@ -70,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 @@ -77,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 { @@ -235,7 +247,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 { @@ -437,6 +449,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" { @@ -456,6 +471,30 @@ func Save(a interface{}) (err error) { return nil } +func fixDates(a interface{}) interface{} { + value := reflect.ValueOf(a).Elem() + 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).IsNil() { + if value.Field(i).Interface().(*time.Time).IsZero() { + value.Field(i).Set(timePointerValue) + } + } + } + + } + return value.Addr().Interface() +} + func customSave(m interface{}) (err error) { a := m t := reflect.TypeOf(a) 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/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/forgot_password_handler.go b/forgot_password_handler.go index a9a9d314..18c70f41 100644 --- a/forgot_password_handler.go +++ b/forgot_password_handler.go @@ -2,55 +2,40 @@ package uadmin import ( "fmt" - "net" "net/http" "strings" ) // 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}, - -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 -` - // 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(r.Host); err != nil { - host = r.Host + 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. + + Regards, + {WEBSITE} Support + ` } - for _, v := range strings.Split(AllowedHosts, ",") { - if allowedHost, _, err = net.SplitHostPort(v); err != nil { - allowedHost = v - } - if allowedHost == host { - allowed = true - } - } - if !allowed { - Trail(CRITICAL, "Reset password request for host: (%s) which is not in AllowedHosts settings", host) - return nil + + link, err := u.GeneratePasswordResetLink(r, link) + if err != nil { + return err } - 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) + 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) return err 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/generate_translation.go b/generate_translation.go index 7aa860a8..33c4d6c8 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) @@ -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/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/global.go b/global.go index c73bd3e3..e923b301 100644 --- a/global.go +++ b/global.go @@ -1,6 +1,7 @@ package uadmin import ( + "net/http" "os" "regexp" ) @@ -80,7 +81,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.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. @@ -89,7 +90,8 @@ const Version = "0.9.1" // 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 @@ -380,7 +382,113 @@ 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: +// "{SCHEMA}://{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 +` + +// 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) + +// 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 + +// 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{} + +// FullMediaURL allows uAdmin to send you full path URL instead on relative +// path for dAPI read requests +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 + +// 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, 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 + +// SSOURL enables SSO using OpenID Connect +var SSOURL = "" // Private Global Variables // Regex diff --git a/go.mod b/go.mod index 409f1962..f9aa324a 100644 --- a/go.mod +++ b/go.mod @@ -5,30 +5,26 @@ 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/golang-jwt/jwt/v5 v5.0.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 +32,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..5dea4bf6 100644 --- a/go.sum +++ b/go.sum @@ -1,96 +1,32 @@ -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/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/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 +34,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= diff --git a/load_initial_data.go b/load_initial_data.go index d8deb0a0..f81e7dc5 100644 --- a/load_initial_data.go +++ b/load_initial_data.go @@ -103,6 +103,8 @@ 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) diff --git a/login_handler.go b/login_handler.go index d83c49e6..f87dadfc 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,15 @@ 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.URL.Query().Get("next") != "" { + http.Redirect(w, r, r.URL.Query().Get("next"), 303) + } + } if r.Method == cPOST { if r.FormValue("save") == "Send Request" { @@ -39,7 +49,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/main_handler.go b/main_handler.go index e17bef0e..e9c4095a 100644 --- a/main_handler.go +++ b/main_handler.go @@ -80,6 +80,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/media_handler.go b/media_handler.go index 719e1c38..785cfa5a 100644 --- a/media_handler.go +++ b/media_handler.go @@ -1,7 +1,6 @@ package uadmin import ( - "io" "net/http" "os" "path" @@ -10,23 +9,55 @@ import ( func mediaHandler(w http.ResponseWriter, r *http.Request) { session := IsAuthenticated(r) - if session == nil && !PublicMedia { + token := r.URL.Query().Get("token") + if session == nil && !PublicMedia && token == "" { + w.WriteHeader(401) loginHandler(w, r) 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) + + if session == nil && !PublicMedia && token != "" { + // this request for a limited request for one resource + if verifyPassword("$2a$12$"+token, fName) != nil { + w.WriteHeader(401) + return + } + } + + f, err := os.Open("." + fName) if err != nil { - pageErrorHandler(w, r, session) + w.WriteHeader(404) return } - io.Copy(w, file) - file.Close() + defer f.Close() + stat, err := os.Stat("." + fName) + if err != nil || stat.IsDir() { + w.WriteHeader(404) + return + } + 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/model.go b/model.go index 7f886063..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"` + DeletedAt gorm.DeletedAt `sql:"index" json:"-"` } diff --git a/openapi.go b/openapi.go index e9ae80f6..857f60f5 100644 --- a/openapi.go +++ b/openapi.go @@ -32,7 +32,31 @@ 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" + 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 { @@ -71,7 +95,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 +182,59 @@ 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 + 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 { + 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) + } + 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 + 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 + } + return nil + }() } // Add parameters @@ -255,70 +327,77 @@ 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 cFILE: - fallthrough - case cIMAGE: - fallthrough - case cHTML: - fallthrough - case cLINK: - fallthrough - case cMULTILINGUAL: - fallthrough - case cPASSWORD: - return &openapi.SchemaObject{ - Type: "string", - } - 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", + Description: v.Fields[i].Help, + } + case cFILE: + fallthrough + case cIMAGE: + return &openapi.SchemaObject{ + Type: "string", + Format: "binary", + Description: v.Fields[i].Help, + } + case cFK: + 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", + Description: v.Fields[i].Help, + } + case cNUMBER: + fallthrough + case cPROGRESSBAR: + return &openapi.SchemaObject{ + Type: "number", + Description: v.Fields[i].Help, + } + case cBOOL: + return &openapi.SchemaObject{ + Type: "string", + Enum: []interface{}{"", "0", "1"}, + Description: v.Fields[i].Help, + } + case cDATE: + return &openapi.SchemaObject{ + Type: "string", + Description: v.Fields[i].Help, + } + default: + return &openapi.SchemaObject{ + Type: "string", + Description: v.Fields[i].Help, + } + } + }() // Add required fields if v.Fields[i].Required { @@ -328,17 +407,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{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, + Summary: "Read one " + v.DisplayName + " record", + Description: "Read one " + v.DisplayName + " record", Responses: map[string]openapi.Response{ "200": { Description: v.DisplayName + " record", @@ -355,40 +430,110 @@ 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", + Put: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Edit one " + v.DisplayName + " record", + Description: "Edit one " + v.DisplayName + " record", + RequestBody: &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: writeParameters, + }, + }, + }, }, - { - Ref: "#/components/parameters/deleted", + Responses: map[string]openapi.Response{ + "200": { + Description: v.DisplayName + " record 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"}, + }, + }, + }, + }, + }, }, - { - Ref: "#/components/parameters/m2m", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, }, - { - Ref: "#/components/parameters/preload", + }, + Delete: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Delete one " + v.DisplayName + " record", + Description: "Delete one " + v.DisplayName + " record", + 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"}, + }, + }, + }, + }, + }, }, - { - Ref: "#/components/parameters/stat", + Parameters: []openapi.Parameter{ + { + Ref: "#/components/parameters/PathID", + }, + { + Ref: "#/components/parameters/CSRF", + }, + { + 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, + 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{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, + Summary: "Read many " + v.DisplayName + " records", + Description: "Read many " + v.DisplayName + " records", Responses: map[string]openapi.Response{ "200": { - Description: v.DisplayName + " record", + Description: v.DisplayName + " records", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -405,58 +550,59 @@ func GenerateOpenAPISchema() { }, }, }, + 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(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, Post: &openapi.Operation{ - Tags: []string{v.Name, func() string { - if v.Category != "" { - return v.Category - } else { - return "Other" - } - }()}, + Tags: []string{tag}, + Summary: "Add one " + v.DisplayName + " record", + Description: "Add one " + v.DisplayName + " record", + 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 + " record", + Description: v.DisplayName + " record added", Content: map[string]openapi.MediaType{ "application/json": { Schema: &openapi.SchemaObject{ @@ -474,51 +620,84 @@ func GenerateOpenAPISchema() { }, }, }, - }, - Parameters: append(writeParameters, []openapi.Parameter{ - { - 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 + " records", + Description: "Edit many " + v.DisplayName + " records", + RequestBody: &openapi.RequestBody{ + Content: map[string]openapi.MediaType{ + "multipart/form-data": { + Schema: &openapi.SchemaObject{ + Type: "object", + Properties: 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, - 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", + Description: v.DisplayName + " records 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([]openapi.Parameter{ + { + Ref: "#/components/parameters/CSRF", + }, + { + Ref: "#/components/parameters/stat", + }, + }, parameters...), }, - Parameters: append(writeParameters, []openapi.Parameter{ - { - Ref: "#/components/parameters/CSRF", - }, - { - Ref: "#/components/parameters/stat", + Delete: &openapi.Operation{ + Tags: []string{tag}, + Summary: "Delete many " + v.DisplayName + " records", + Description: "Delete many " + v.DisplayName + " records", + 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{ Type: "object", Properties: fields, @@ -536,7 +715,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/auth_paths.go b/openapi/auth_paths.go new file mode 100644 index 00000000..f3e49ca5 --- /dev/null +++ b/openapi/auth_paths.go @@ -0,0 +1,446 @@ +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: "uid", + In: "query", + Description: "Email or uid is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "email", + In: "query", + Description: "Email or uid is required", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "password", + In: "query", + Description: "New password which is required in the second step with the OTP", + Schema: &SchemaObject{ + Type: "string", + }, + }, + { + Name: "otp", + In: "query", + Description: "OTP is required in the second step with a 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..83a8d76e 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"}, @@ -124,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", @@ -159,10 +173,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", + Default: "", }, Examples: map[string]Example{ "multiColumn": { @@ -178,10 +190,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", + Default: "", }, Examples: map[string]Example{ "multiColumn": { @@ -205,10 +215,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", + Default: "", }, Examples: map[string]Example{ "simple": { @@ -229,7 +237,9 @@ func GenerateBaseSchema() *Schema { AllowReserved: true, AllowEmptyValue: true, Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", + Enum: []interface{}{"", "true"}, }, Examples: map[string]Example{ "getDeleted": { @@ -245,10 +255,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", + Default: "", }, Examples: map[string]Example{ "getGroupName": { @@ -269,6 +277,8 @@ func GenerateBaseSchema() *Schema { AllowReserved: true, 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{ @@ -289,7 +299,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, }, "preload": { @@ -299,7 +310,9 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", + Enum: []interface{}{"", "true", "false"}, }, Examples: map[string]Example{ "getDeleted": { @@ -315,7 +328,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", }, }, "stat": { @@ -325,10 +339,12 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "string", + Type: "string", + Default: "", + Enum: []interface{}{"", "true", "false"}, }, Examples: map[string]Example{ - "getDeleted": { + "getStats": { Summary: "An example of a query that measures the execution time", Value: "$stat=1", }, @@ -341,10 +357,8 @@ func GenerateBaseSchema() *Schema { Required: false, AllowReserved: true, Schema: &SchemaObject{ - Type: "array", - Items: &SchemaObject{ - Type: "string", - }, + Type: "string", + Default: "", }, Examples: map[string]Example{ "simple": { @@ -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..8b4a2304 100644 --- a/openapi/schema_object.go +++ b/openapi/schema_object.go @@ -9,8 +9,9 @@ type SchemaObject struct { 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"` + Default interface{} `json:"default,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"` @@ -20,7 +21,9 @@ 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"` + Deprecated *bool `json:"deprecated,omitempty"` XFilters []XModifier `json:"x-filter,omitempty"` XAggregator []XModifier `json:"x-aggregator,omitempty"` } 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/otp.go b/otp.go index b3b17d3c..13946b72 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)) @@ -69,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/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/password_reset_handler.go b/password_reset_handler.go index e6dab260..8910a81a 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() @@ -39,9 +42,12 @@ 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") != "" { + 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/process_upload.go b/process_upload.go index 73484757..53bb1a99 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 "" @@ -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, modelName, f) + } + return val } diff --git a/register.go b/register.go index b5cd1fd2..a90d7bd4 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 } @@ -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) @@ -363,15 +363,28 @@ 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("/static/", 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)) } // dAPI handler - http.HandleFunc(RootURL+"api/", Handler(apiHandler)) + if EnableDAPICORS { + http.HandleFunc(RootURL+"api/", CORSHandler(Handler(apiHandler))) + } else { + http.HandleFunc(RootURL+"api/", Handler(apiHandler)) + } + + if !DisableDAPIAuth { + http.HandleFunc(RootURL+".well-known/openid-configuration/", Handler(JWTConfigHandler)) + } handlersRegistered = true } 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) diff --git a/schema.go b/schema.go index 07641f09..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 { @@ -146,6 +157,7 @@ type F struct { ApprovalID uint WebCam bool Stringer bool + Deprecated bool } // MarshalJSON customizes F json export @@ -191,6 +203,7 @@ func (f F) MarshalJSON() ([]byte, error) { ApprovalID uint WebCam bool Stringer bool + Deprecated bool }{ Name: f.Name, DisplayName: f.DisplayName, @@ -244,6 +257,7 @@ func (f F) MarshalJSON() ([]byte, error) { ApprovalID: f.ApprovalID, WebCam: f.WebCam, Stringer: f.Stringer, + Deprecated: f.Deprecated, }) } diff --git a/send_email.go b/send_email.go index 5d9bbd2b..fb2f1bf4 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) @@ -30,7 +44,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", "