diff --git a/.gitignore b/.gitignore index 50b1f1968..7f26c8e2d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,14 +4,13 @@ go.work go.work.sum *.code-workspace *.toml -*.txt -*.env -*log* +*log # API Ignore main deploy/mdb/* *.DS_Store +API/.env # CLI Ignore .idea/ @@ -19,4 +18,4 @@ cli cli.exe cli.mac .history -*.ocli \ No newline at end of file +*.ocli diff --git a/API/Jenkinsfile b/API/Jenkinsfile index 98df161be..7a11c9243 100644 --- a/API/Jenkinsfile +++ b/API/Jenkinsfile @@ -142,6 +142,13 @@ pipeline { } } + stage('Update Endpoint Doc') { + steps { + echo 'Updating endpoint doc' + sh '' + } + } + stage('Deploy') { steps { echo 'Deploying....' diff --git a/API/ReadMe.md b/API/ReadMe.md index 0ebc9abd7..9db67e4df 100644 --- a/API/ReadMe.md +++ b/API/ReadMe.md @@ -53,14 +53,24 @@ You can modify the port of the API in the .env file. This is the port that the A - Navigate in your terminal to the ```init_db``` directory - Execute the bash script ```ogreeBoot.sh``` - Enter your password when the prompt asks you - - Execute the bash script ```addTenant.sh``` with the flag --name myCompanyName (specify your company name here) - Be sure to enter your user password and the desired the DB access password - Update your .env file ```db_user=myCompanyName``` and ```db_pass=dbAccessPassword``` - Execute the binary ```main``` -This .env file is not provided, so you must create it yourself. To view an example of the ```.env``` file: https://ogree.ditrit.io/htmls/apiReference.html - - +This .env file is not provided, so you must create it yourself. Here is an example of the ```.env``` file: +``` +api_port = 3001 +db_host = 0.0.0.0 +db_port = 27017 +db_user = "" +db_pass = "" +db = "TenantName" +token_password = thisIsTheJwtSecretPassword +signing_password = thisIsTheRBACSecretPassword +email_account = "test@test.com" +email_password = "" +reset_url = "http://localhost:8082/#/reset?token=" +``` Jenkins -------------------------- diff --git a/API/app/auth.go b/API/app/auth.go index 7231d1741..0d347545a 100644 --- a/API/app/auth.go +++ b/API/app/auth.go @@ -10,6 +10,7 @@ import ( "p3/models" jwt "github.com/dgrijalva/jwt-go" + "go.mongodb.org/mongo-driver/bson/primitive" ) var Log = func(next http.Handler) http.Handler { @@ -26,13 +27,13 @@ var JwtAuthentication = func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { //Endpoints that don't require auth - notAuth := []string{"/api", "/api/login"} + notAuth := []string{"/api", "/api/login", "/api/users/password/forgot"} requestPath := r.URL.Path //current request path + println(requestPath) //check if request needs auth //serve the request if not needed for _, value := range notAuth { - if value == requestPath { next.ServeHTTP(w, r) return @@ -80,7 +81,7 @@ var JwtAuthentication = func(next http.Handler) http.Handler { } //Token is invalid - if !token.Valid { + if !token.Valid || ((tk.Email == u.RESET_TAG) != (requestPath == "/api/users/password/reset")) { response = u.Message(false, "Token is not valid.") w.WriteHeader(http.StatusForbidden) w.Header().Add("Content-Type", "application/json") @@ -91,11 +92,47 @@ var JwtAuthentication = func(next http.Handler) http.Handler { //Success //set the caller to the user retrieved from the parsed token //Useful for monitoring - - //fmt.Sprintf("User %", tk.UserId) - userData := map[string]interface{}{"email": tk.Email} + userData := map[string]interface{}{"email": tk.Email, "userID": tk.UserId} ctx := context.WithValue(r.Context(), "user", userData) r = r.WithContext(ctx) next.ServeHTTP(w, r) //proceed in the middleware chain! }) } + +func ParseToken(w http.ResponseWriter, r *http.Request) map[string]primitive.ObjectID { + //Grab the token from the header + tokenHeader := r.Header.Get("Authorization") + + //Token is missing return 403 + if tokenHeader == "" { + return nil + } + + //Token format `Bearer {token-body}` + splitted := strings.Split(tokenHeader, " ") + if len(splitted) != 2 { + return nil + } + + //Grab the token body + tokenPart := splitted[1] + tk := &models.Token{} + + token, err := jwt.ParseWithClaims(tokenPart, tk, func(token *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("token_password")), nil + }) + + //Malformed token + if err != nil { + return nil + } + + //Token is invalid + if !token.Valid { + return nil + } + + //Success + return map[string]primitive.ObjectID{ + "userID": tk.UserId} +} diff --git a/API/controllers/authControllers.go b/API/controllers/authControllers.go index 7e6aa2e31..f3adb1c3c 100644 --- a/API/controllers/authControllers.go +++ b/API/controllers/authControllers.go @@ -3,41 +3,58 @@ package controllers import ( "encoding/json" "fmt" + "math/rand" "net/http" "p3/models" u "p3/utils" + "time" + + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/bson/primitive" ) -// swagger:operation POST /api auth Create +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// swagger:operation POST /api/users auth Create // Generate credentials for a user. -// Create an account with Email credentials, it returns -// a JWT key to use with the API. The -// authorize and 'Try it out' buttons don't work +// Create an account with email credentials, it returns +// a JWT key to use with the API. // --- // produces: // - application/json // parameters: -// - name: username -// in: body -// description: Your Email Address -// type: string -// required: true -// default: "infiniti@nissan.com" -// - name: password -// in: json -// description: Your password -// required: true -// format: password -// default: "secret" +// - name: name +// in: json +// description: User name +// type: string +// required: false +// default: "John Doe" +// - name: email +// in: json +// description: User Email Address +// type: string +// required: true +// default: "user@email.com" +// - name: password +// in: json +// description: User password +// required: true +// format: password +// default: "secret123" +// // responses: -// '200': -// description: Authenticated +// '201': +// description: Authenticated and new account created // '400': // description: Bad request +// '403': +// description: User not authorised to create an account // '500': // description: Internal server error -// swagger:operation OPTIONS /api auth CreateOptions +// swagger:operation OPTIONS /api/users auth CreateOptions // Displays possible operations for the resource in response header. // --- // produces: @@ -59,15 +76,24 @@ var CreateAccount = func(w http.ResponseWriter, r *http.Request) { account := &models.Account{} err := json.NewDecoder(r.Body).Decode(account) if err != nil { + w.WriteHeader(http.StatusBadRequest) u.Respond(w, u.Message(false, "Invalid request")) return } - resp, e := account.Create() + + callerUser := getUserFromToken(w, r) + if callerUser == nil { + return + } + + resp, e := account.Create(callerUser.Roles) switch e { case "internal": w.WriteHeader(http.StatusInternalServerError) - case "clientError": + case "clientError", "validate": w.WriteHeader(http.StatusBadRequest) + case "unauthorised": + w.WriteHeader(http.StatusForbidden) case "exists": w.WriteHeader(http.StatusConflict) default: @@ -77,6 +103,89 @@ var CreateAccount = func(w http.ResponseWriter, r *http.Request) { } } +// swagger:operation POST /api/users/bulk auth CreateBulk +// Create multiples users with one request. +// --- +// produces: +// - application/json +// parameters: +// - name: name +// in: json +// description: User name +// type: string +// required: false +// default: "John Doe" +// - name: email +// in: body +// description: User Email Address +// type: string +// required: true +// default: "user@email.com" +// - name: password +// in: json +// description: User password +// required: true +// format: password +// default: "secret123" +// +// responses: +// +// '200': +// description: Request processed, check response body for results +// '400': +// description: Bad request +// '500': +// description: Internal server error +var CreateBulkAccount = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: CreateBulkAccount ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + var accounts []models.Account + err := json.NewDecoder(r.Body).Decode(&accounts) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request")) + return + } + + callerUser := getUserFromToken(w, r) + if callerUser == nil { + return + } + + resp := map[string]interface{}{} + for _, account := range accounts { + password := "" + if len(account.Password) <= 0 { + password = randStringBytes(8) + account.Password = password + } + resp[account.Email] = map[string]interface{}{} + res, _ := account.Create(callerUser.Roles) + for key, value := range res { + if key == "account" && password != "" { + resp[account.Email].(map[string]interface{})["password"] = password + } else { + resp[account.Email].(map[string]interface{})[key] = value + } + } + } + w.WriteHeader(http.StatusOK) + u.Respond(w, resp) +} + +const passChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func randStringBytes(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = passChars[rand.Intn(len(passChars))] + } + return string(b) +} + // swagger:operation POST /api/login auth Authenticate // Generates a new JWT Key for the client. // Create a new JWT Key. This can also be used to verify credentials @@ -133,7 +242,7 @@ var Authenticate = func(w http.ResponseWriter, r *http.Request) { resp, e := models.Login(account.Email, account.Password) if resp["status"] == false { - if e == "invalid" { + if e == "validate" { w.WriteHeader(http.StatusUnauthorized) } else if e == "internal" { w.WriteHeader(http.StatusInternalServerError) @@ -180,3 +289,404 @@ var Verify = func(w http.ResponseWriter, r *http.Request) { u.Respond(w, u.Message(true, "working")) } } + +// swagger:operation GET /api/users auth GetAllAccounts +// Get a list of users that the caller is allowed to see. +// --- +// produces: +// - application/json +// responses: +// +// '200': +// description: Got all possible users +// '500': +// description: Internal server error +var GetAllAccounts = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetAllAccount ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + var resp map[string]interface{} + + // Get caller user + callerUser := getUserFromToken(w, r) + if callerUser == nil { + return + } + + // Get users + data, err := models.GetAllUsers(callerUser.Roles) + if err != "" { + w.WriteHeader(http.StatusInternalServerError) + resp = u.Message(false, "Error: "+err) + } else { + resp = u.Message(true, "successfully got users") + resp["data"] = data + } + u.Respond(w, resp) + } +} + +// swagger:operation DELETE /api/users/{id} auth RemoveAccount +// Remove the specified user account. +// --- +// produces: +// - application/json +// responses: +// +// '200': +// description: User removed +// '400': +// description: User ID not valid or not found +// '403': +// description: Caller not authorised to delete this user +// '500': +// description: Internal server error +var RemoveAccount = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: RemoveAccount ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "DELETE, OPTIONS, HEAD") + } else { + var resp map[string]interface{} + + // Get caller user + callerUser := getUserFromToken(w, r) + if callerUser == nil { + return + } + + // Get user to delete + userId := mux.Vars(r)["id"] + objID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + resp = u.Message(false, "User ID is not valid") + u.Respond(w, resp) + return + } + deleteUser := models.GetUser(objID) + + // Check permissions + if !models.CheckCanManageUser(callerUser.Roles, deleteUser.Roles) { + w.WriteHeader(http.StatusUnauthorized) + resp = u.Message(false, "Caller does not have permission to delete this user") + u.Respond(w, resp) + return + } + + // Delete it + e := models.DeleteUser(objID) + if e != "" { + w.WriteHeader(http.StatusInternalServerError) + resp = u.Message(false, "Error: "+e) + } else { + resp = u.Message(true, "successfully removed user") + } + u.Respond(w, resp) + } +} + +// swagger:operation PATCH /api/users/{id} auth ModifyUserRoles +// Modify user permissions: domain and role. +// --- +// produces: +// - application/json +// parameters: +// - name: roles +// in: body +// description: An object with domains as keys and roles as values +// type: json +// required: true +// +// responses: +// +// '200': +// description: User roles modified +// '400': +// description: Bad request +// '403': +// description: Caller not authorised to modify this user +// '500': +// description: Internal server error +var ModifyUserRoles = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: ModifyUserRoles ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "PATCH, OPTIONS, HEAD") + } else { + var resp map[string]interface{} + userId := mux.Vars(r)["id"] + + // Check if POST body is valid + var data map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request")) + return + } + roles, ok := data["roles"].(map[string]interface{}) + if len(data) > 1 || !ok { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Only 'roles' should be provided to patch")) + return + } + rolesConverted := map[string]models.Role{} + for k := range roles { + if v, ok := roles[k].(string); ok { + rolesConverted[k] = models.Role(v) + } else { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid roles format")) + return + } + } + + // Get caller user + callerUser := getUserFromToken(w, r) + if callerUser == nil { + return + } + + // Get user to modify + objID, err := primitive.ObjectIDFromHex(userId) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + resp = u.Message(false, "User ID is not valid") + u.Respond(w, resp) + return + } + modifyUser := models.GetUser(objID) + + // Check permissions + if !models.CheckCanManageUser(callerUser.Roles, modifyUser.Roles) { + w.WriteHeader(http.StatusUnauthorized) + resp = u.Message(false, "Caller does not have permission to modify this user") + u.Respond(w, resp) + return + } + + // Modify it + e, eType := models.ModifyUser(userId, rolesConverted) + if e != "" { + switch eType { + case "internal": + w.WriteHeader(http.StatusInternalServerError) + case "validate": + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusInternalServerError) + } + resp = u.Message(false, "Error: "+e) + } else { + resp = u.Message(true, "successfully updated user roles") + } + u.Respond(w, resp) + } +} + +// swagger:operation POST /api/users/password/change auth ModifyUserPassword +// For logged in user to change own password. +// --- +// produces: +// - application/json +// parameters: +// - name: currentPassword +// in: body +// description: User current password +// type: string +// required: true +// - name: newPassword +// in: body +// description: User new desired password +// type: string +// required: true +// +// responses: +// +// '200': +// description: Password changed +// '400': +// description: Bad request +// '500': +// description: Internal server error + +// swagger:operation POST /api/users/password/reset auth ModifyUserPassword +// To change password of user that forgot password and received a reset token by email. +// --- +// produces: +// - application/json +// parameters: +// - name: newPassword +// in: body +// description: User new desired password +// type: string +// required: true +// +// responses: +// +// '200': +// description: Password changed +// '400': +// description: Bad request +// '500': +// description: Internal server error +var ModifyUserPassword = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: ModifyUserPassword ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "POST, OPTIONS, HEAD") + } else { + var resp map[string]interface{} + + // Get user ID and email from token + userData := r.Context().Value("user") + if userData == nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Error while parsing path params")) + u.ErrLog("Error while parsing path params", "GET GENERIC", "", r) + return + } + userId := userData.(map[string]interface{})["userID"].(primitive.ObjectID) + userEmail := userData.(map[string]interface{})["email"].(string) + + // Check if POST body is valid + var data map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request")) + return + } + isReset := false + hasCurrent := true + currentPassword := "" + if userEmail == u.RESET_TAG { + isReset = true + } else { + currentPassword, hasCurrent = data["currentPassword"].(string) + } + newPassword, hasNew := data["newPassword"].(string) + if !hasCurrent || !hasNew { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request: wrong body format")) + return + } + + // Check if user is valid + var user *models.Account + if isReset { + user = models.GetUser(userId) + } else { + user = models.GetUserByEmail(userEmail) + } + if user == nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid token: no valid user found")) + u.ErrLog("Unable to find user associated to token", "GET GENERIC", "", r) + return + } + + // Change user password + response, errType := user.ChangePassword(currentPassword, newPassword, isReset) + if errType != "" { + switch errType { + case "internal": + w.WriteHeader(http.StatusInternalServerError) + case "validate": + w.WriteHeader(http.StatusBadRequest) + default: + w.WriteHeader(http.StatusInternalServerError) + } + resp = u.Message(false, "Error: "+response) + } else { + resp = u.Message(true, "successfully updated user password") + if !isReset { + resp["token"] = response + } + } + u.Respond(w, resp) + + } +} + +// swagger:operation POST /api/users/password/forgot auth UserForgotPassword +// To request a reset of a user's password (forgot my password). +// --- +// produces: +// - application/json +// parameters: +// - name: email +// in: body +// description: User email +// type: string +// required: true +// +// responses: +// +// '200': +// description: request processed. If account exists, an email with a reset token will be sent to it +// '400': +// description: Bad request +// '500': +// description: Internal server error +var UserForgotPassword = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: UserForgotPassword ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "POST, OPTIONS, HEAD") + } else { + resp := map[string]interface{}{} + + // Check if POST body is valid + var data map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request")) + return + } + userEmail, hasEmail := data["email"].(string) + if !hasEmail { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request: email should be provided")) + return + } + + // Create token, if user exists, and send it by email + user := models.GetUserByEmail(userEmail) + if user != nil { + token := models.GenerateToken(u.RESET_TAG, user.ID, time.Minute*15) + if e := u.SendEmail(token, user.Email); e != "" { + w.WriteHeader(http.StatusInternalServerError) + u.Respond(w, u.Message(false, "Unable to send email: "+e)) + return + } + } + + resp = u.Message(true, "request processed") + u.Respond(w, resp) + } +} diff --git a/API/controllers/entityController.go b/API/controllers/entityController.go index ac8a96946..1be91436e 100644 --- a/API/controllers/entityController.go +++ b/API/controllers/entityController.go @@ -58,6 +58,25 @@ func getFiltersFromQueryParams(r *http.Request) u.RequestFilters { return filters } +func getUserFromToken(w http.ResponseWriter, r *http.Request) *models.Account { + userData := r.Context().Value("user") + if userData == nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Error while parsing path params")) + u.ErrLog("Error while parsing path params", "GET GENERIC", "", r) + return nil + } + userId := userData.(map[string]interface{})["userID"].(primitive.ObjectID) + user := models.GetUser(userId) + if user == nil || len(user.Roles) <= 0 { + w.WriteHeader(http.StatusUnauthorized) + u.Respond(w, u.Message(false, "Invalid token: no valid user found")) + u.ErrLog("Unable to find user associated to token", "GET GENERIC", "", r) + return nil + } + return user +} + // swagger:operation POST /api/{obj} objects CreateObject // Creates an object in the system. // --- @@ -125,17 +144,20 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { var e string var resp map[string]interface{} - entity := map[string]interface{}{} - err := json.NewDecoder(r.Body).Decode(&entity) + object := map[string]interface{}{} + err := json.NewDecoder(r.Body).Decode(&object) - entStr, e1 := mux.Vars(r)["entity"] - if !e1 { - w.WriteHeader(http.StatusBadRequest) - u.Respond(w, u.Message(false, "Error while parsing path params")) - u.ErrLog("Error while parsing path params", "CREATE "+entStr, "", r) + entStr, _ := mux.Vars(r)["entity"] + + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { return } + println("User Roles:") + fmt.Println(user.Roles) + entUpper := strings.ToUpper(entStr) if err != nil { @@ -148,12 +170,12 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { //If creating templates, format them entStr = strings.Replace(entStr, "-", "_", 1) - i := u.EntityStrToInt(entStr) + entInt := u.EntityStrToInt(entStr) println("ENT: ", entStr) - println("ENUM VAL: ", i) + println("ENUM VAL: ", entInt) //Prevents Mongo from creating a new unidentified collection - if i < 0 { + if entInt < 0 { w.WriteHeader(http.StatusBadRequest) u.Respond(w, u.Message(false, "Invalid entity in URL: '"+mux.Vars(r)["entity"]+"' Please provide a valid object")) u.ErrLog("Cannot create invalid object", "CREATE "+mux.Vars(r)["entity"], "", r) @@ -161,8 +183,8 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { } //Check if category and endpoint match, except for templates and strays - if i < u.ROOMTMPL { - if entity["category"] != entStr { + if entInt < u.ROOMTMPL { + if object["category"] != entStr { w.WriteHeader(http.StatusBadRequest) u.Respond(w, u.Message(false, "Category in request body does not correspond with desired object in endpoint")) u.ErrLog("Cannot create invalid object", "CREATE "+mux.Vars(r)["entity"], "", r) @@ -171,9 +193,9 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { } //Clean the data of 'id' attribute if present - delete(entity, "id") + delete(object, "id") - resp, e = models.CreateEntity(i, entity) + resp, e = models.CreateEntity(entInt, object, user.Roles) switch e { case "validate", "duplicate": @@ -181,6 +203,8 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { u.ErrLog("Error while creating "+entStr, "CREATE "+entUpper, e, r) case "": w.WriteHeader(http.StatusCreated) + case "permission": + w.WriteHeader(http.StatusUnauthorized) default: if strings.Split(e, " ")[1] == "duplicate" { w.WriteHeader(http.StatusBadRequest) @@ -195,7 +219,112 @@ var CreateEntity = func(w http.ResponseWriter, r *http.Request) { u.Respond(w, resp) } -// swagger:operation GET /api/objects/{name} objects GetObject +var CreateBulkDomain = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: CreateBulkDomain ") + fmt.Println("******************************************************") + + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + listDomains := []map[string]interface{}{} + err := json.NewDecoder(r.Body).Decode(&listDomains) + if err != nil || len(listDomains) < 0 { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Error while decoding request body")) + u.ErrLog("Error while decoding request body", "CREATE BULK DOMAIN", "", r) + return + } + + domainsToCreate, e := getBulkDomainsRecursively("", listDomains) + if e != "" { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, e)) + u.ErrLog(e, "CREATE BULK DOMAIN", "", r) + return + } + fmt.Println(domainsToCreate) + + resp := map[string]interface{}{} + for _, domain := range domainsToCreate { + // Convert back to json to avoid invalid types in json schema validation + bytes, _ := json.Marshal(domain) + json.Unmarshal(bytes, &domain) + // Create and save response + result, _ := models.CreateEntity(u.DOMAIN, domain, user.Roles) + var name string + if v, ok := domain["parentId"].(string); ok && v != "" { + name = v + "." + domain["name"].(string) + } else { + name = domain["name"].(string) + } + resp[name] = result["message"] + } + w.WriteHeader(http.StatusOK) + u.Respond(w, resp) +} + +func getBulkDomainsRecursively(parent string, listDomains []map[string]interface{}) ([]map[string]interface{}, string) { + domainsToCreate := []map[string]interface{}{} + for _, domain := range listDomains { + domainObj := map[string]interface{}{} + // Name is the only required attribute + name, ok := domain["name"].(string) + if !ok { + return nil, "Invalid format: Name is required for all domains" + } + domainObj["name"] = name + + // Optional/default attributes + if parent != "" { + domainObj["parentId"] = parent + } + domainObj["category"] = "domain" + if desc, ok := domain["description"].(string); ok { + domainObj["description"] = []string{desc} + } else { + domainObj["description"] = []string{name} + } + domainObj["attributes"] = map[string]string{} + if color, ok := domain["color"].(string); ok { + domainObj["attributes"].(map[string]string)["color"] = color + } else { + domainObj["attributes"].(map[string]string)["color"] = "ffffff" + } + + domainsToCreate = append(domainsToCreate, domainObj) + + // Add children domain, if any + if children, ok := domain["domains"].([]interface{}); ok { + if len(children) > 0 { + // Convert from interface to map + dChildren := []map[string]interface{}{} + for _, d := range children { + dChildren = append(dChildren, d.(map[string]interface{})) + } + // Set parentId for children + var parentId string + if parent == "" { + parentId = domain["name"].(string) + } else { + parentId = parent + "." + domain["name"].(string) + } + // Add children + childDomains, e := getBulkDomainsRecursively(parentId, dChildren) + if e != "" { + return nil, e + } + domainsToCreate = append(domainsToCreate, childDomains...) + } + } + } + return domainsToCreate, "" +} + +// swagger:operation GET /api/objects/{name} objects GetObjectByName // Gets an Object from the system. // The hierarchyName must be provided in the URL parameter // --- @@ -240,13 +369,18 @@ var GetGenericObject = func(w http.ResponseWriter, r *http.Request) { DispRequestMetaData(r) var data map[string]interface{} var e1 string - var resp map[string]interface{} + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + name, e := mux.Vars(r)["name"] filters := getFiltersFromQueryParams(r) if e { - data, e1 = models.GetObjectByName(name, filters) + data, e1 = models.GetObjectByName(name, filters, user.Roles) } else { u.Respond(w, u.Message(false, "Error while parsing path parameters")) u.ErrLog("Error while parsing path parameters", "GET ENTITY", "", r) @@ -271,7 +405,7 @@ var GetGenericObject = func(w http.ResponseWriter, r *http.Request) { } -// swagger:operation GET /api/{objs}/{id} objects GetObject +// swagger:operation GET /api/{objs}/{id} objects GetObjectById // Gets an Object from the system. // The ID must be provided in the URL parameter // The name can be used instead of ID if the obj is site @@ -314,7 +448,7 @@ var GetGenericObject = func(w http.ResponseWriter, r *http.Request) { // in: query // description: 'Only values of "sites","domains", // "buildings", "rooms", "racks", "devices", "room-templates", -// "obj-templates","bldg-templates", "acs", "panels","cabinets", "groups", +// "obj-templates", "bldg-templates","acs", "panels","cabinets", "groups", // "corridors","sensors","stray-devices","stray-sensors", are acceptable' // - name: id // in: query @@ -344,7 +478,13 @@ var GetEntity = func(w http.ResponseWriter, r *http.Request) { var resp map[string]interface{} - //Get entity type + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + //Get entity type and strip trailing 'entityStr' entityStr := mux.Vars(r)["entity"] filters := getFiltersFromQueryParams(r) @@ -368,14 +508,17 @@ var GetEntity = func(w http.ResponseWriter, r *http.Request) { return } - data, e1 = models.GetEntity(bson.M{"_id": x}, entityStr, filters) + req := bson.M{"_id": x} + data, e1 = models.GetEntity(req, entityStr, filters, user.Roles) - } else if id, e = mux.Vars(r)["name"]; e { //GET By String - if strings.Contains(entityStr, "template") { - data, e1 = models.GetEntity(bson.M{"slug": id}, entityStr, filters) //GET By Slug (template) + } else if id, e = mux.Vars(r)["name"]; e == true { //GET By String + if strings.Contains(entityStr, "template") { //GET By Slug (template) + req := bson.M{"slug": id} + data, e1 = models.GetEntity(req, entityStr, filters, user.Roles) } else { println(id) - data, e1 = models.GetEntity(bson.M{"hierarchyName": id}, entityStr, filters) // GET By hierarchyName + req := bson.M{"hierarchyName": id} + data, e1 = models.GetEntity(req, entityStr, filters, user.Roles) // GET By hierarchyName } } @@ -385,20 +528,20 @@ var GetEntity = func(w http.ResponseWriter, r *http.Request) { return } - if data == nil { + if e1 != "" { resp = u.Message(false, "Error while getting "+entityStr+": "+e1) u.ErrLog("Error while getting "+entityStr, "GET "+strings.ToUpper(entityStr), "", r) switch e1 { case "record not found": w.WriteHeader(http.StatusNotFound) - case "mongo: no documents in result": resp = u.Message(false, "Error while getting :"+entityStr+", No Objects Found!") w.WriteHeader(http.StatusNotFound) - case "invalid request": w.WriteHeader(http.StatusBadRequest) + case "permission": + w.WriteHeader(http.StatusUnauthorized) default: w.WriteHeader(http.StatusNotFound) //For now } @@ -461,7 +604,12 @@ var GetAllEntities = func(w http.ResponseWriter, r *http.Request) { var data []map[string]interface{} var e, entStr string - //Main hierarchy objects + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + entStr = mux.Vars(r)["entity"] println("ENTSTR: ", entStr) @@ -476,7 +624,8 @@ var GetAllEntities = func(w http.ResponseWriter, r *http.Request) { return } - data, e = models.GetManyEntities(entStr, bson.M{}, u.RequestFilters{}) + req := bson.M{} + data, e = models.GetManyEntities(entStr, req, u.RequestFilters{}, user.Roles) var resp map[string]interface{} if len(data) == 0 { @@ -550,6 +699,12 @@ var DeleteEntity = func(w http.ResponseWriter, r *http.Request) { id, e := mux.Vars(r)["id"] name, e2 := mux.Vars(r)["name"] + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + //Get entity from URL entity := mux.Vars(r)["entity"] @@ -568,10 +723,10 @@ var DeleteEntity = func(w http.ResponseWriter, r *http.Request) { switch { case e2 && !e: // DELETE by name if strings.Contains(entity, "template") { - v, errType = models.DeleteEntityManual(entity, bson.M{"slug": name}) + v, errType = models.DeleteSingleEntity(entity, bson.M{"slug": name}) } else { //use hierarchyName - v, errType = models.DeleteEntityByName(entity, name) + v, errType = models.DeleteEntityByName(entity, name, user.Roles) } @@ -583,10 +738,17 @@ var DeleteEntity = func(w http.ResponseWriter, r *http.Request) { return } - if entity == "device" { - v, errType = models.DeleteDeviceF(objID) + req, ok := models.GetRequestFilterByDomain(user.Roles) + if !ok { + errType = "permisson" + v = u.Message(false, "User does not have permission to delete") } else { - v, errType = models.DeleteEntity(entity, objID) + if entity == "device" { + v, errType = models.DeleteDeviceF(objID, req) + } else { + + v, errType = models.DeleteEntity(entity, objID, req) + } } default: @@ -598,6 +760,8 @@ var DeleteEntity = func(w http.ResponseWriter, r *http.Request) { if v["status"] == false { if errType == "domain" { w.WriteHeader(http.StatusBadRequest) + } else if errType == "permission" { + w.WriteHeader(http.StatusUnauthorized) } else { w.WriteHeader(http.StatusNotFound) } @@ -747,6 +911,12 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { var e3 string var entity string + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + updateData := map[string]interface{}{} id, e := mux.Vars(r)["id"] name, e2 := mux.Vars(r)["name"] @@ -764,7 +934,7 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { return } - //Get entity from URL and strip trailing 's' + //Get entity from URL entity = mux.Vars(r)["entity"] //If templates, format them @@ -787,7 +957,7 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { req = bson.M{"hierarchyName": name} } - v, e3 = models.UpdateEntity(entity, req, updateData, isPatch) + v, e3 = models.UpdateEntity(entity, req, updateData, isPatch, user.Roles) case e: // Update with id objID, err := primitive.ObjectIDFromHex(id) @@ -801,7 +971,8 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { println("OBJID:", objID.Hex()) println("Entity;", entity) - v, e3 = models.UpdateEntity(entity, bson.M{"_id": objID}, updateData, isPatch) + req := bson.M{"_id": objID} + v, e3 = models.UpdateEntity(entity, req, updateData, isPatch, user.Roles) default: w.WriteHeader(http.StatusBadRequest) @@ -811,6 +982,8 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { } switch e3 { + case "": + w.WriteHeader(http.StatusOK) case "validate", "Invalid ParentID", "Need ParentID", "invalid": w.WriteHeader(http.StatusBadRequest) u.ErrLog("Error while updating "+entity, "UPDATE "+strings.ToUpper(entity), e3, r) @@ -820,13 +993,18 @@ var UpdateEntity = func(w http.ResponseWriter, r *http.Request) { case "mongo: no documents in result", "parent not found": w.WriteHeader(http.StatusNotFound) u.ErrLog("Error while updating "+entity, "UPDATE "+strings.ToUpper(entity), e3, r) + case "permission": + w.WriteHeader(http.StatusUnauthorized) default: + w.WriteHeader(http.StatusInternalServerError) + println("Not handled error while trying to update entity") } u.Respond(w, v) } -// swagger:operation GET /api/{objs}? objects GetObject +// swagger:operation GET /api/{objs}? objects GetObjectQuery +// Gets an Object using any attribute. // Gets an Object using any attribute (with the exception of description) // via query in the system // The attributes are in the form {attr}=xyz&{attr1}=abc @@ -884,6 +1062,12 @@ var GetEntityByQuery = func(w http.ResponseWriter, r *http.Request) { var bsonMap bson.M var e, entStr string + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + entStr = r.URL.Path[5 : len(r.URL.Path)-1] filters := getFiltersFromQueryParams(r) @@ -902,7 +1086,7 @@ var GetEntityByQuery = func(w http.ResponseWriter, r *http.Request) { return } - data, e = models.GetManyEntities(entStr, bsonMap, filters) + data, e = models.GetManyEntities(entStr, bsonMap, filters, user.Roles) if len(data) == 0 { resp = u.Message(false, "Error: "+e) @@ -1065,9 +1249,17 @@ var GetEntitiesOfAncestor = func(w http.ResponseWriter, r *http.Request) { var id string var e bool var resp map[string]interface{} + //Extract string between /api and /{id} entStr := mux.Vars(r)["ancestor"] + entStr = entStr[:len(entStr)-1] // remove s enum := u.EntityStrToInt(entStr) + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + //Prevents Mongo from creating a new unidentified collection if enum < 0 { w.WriteHeader(http.StatusNotFound) @@ -1091,8 +1283,8 @@ var GetEntitiesOfAncestor = func(w http.ResponseWriter, r *http.Request) { //Could be: "ac", "panel", "corridor", "cabinet", "sensor" indicator := mux.Vars(r)["sub"] - //TODO: hierarchyName - data, e1 := models.GetEntitiesOfAncestor(id, enum, entStr, indicator) + req := bson.M{} + data, e1 := models.GetEntitiesOfAncestor(id, req, enum, entStr, indicator) if data == nil { resp = u.Message(false, "Error while getting "+entStr+"s: "+e1) u.ErrLog("Error while getting children of "+entStr, @@ -1196,6 +1388,14 @@ var GetEntityHierarchy = func(w http.ResponseWriter, r *http.Request) { var data map[string]interface{} var e1 string + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + req := bson.M{} + //If template or stray convert '-' -> '_' entity = strings.Replace(entity, "-", "_", 1) @@ -1215,7 +1415,9 @@ var GetEntityHierarchy = func(w http.ResponseWriter, r *http.Request) { if end == 0 { // It's a GetEntity, treat it here objID, _ := primitive.ObjectIDFromHex(id) - data, e1 := models.GetEntity(bson.M{"_id": objID}, entity, filters) + newReq := req + newReq["_id"] = objID + data, e1 := models.GetEntity(newReq, entity, filters, user.Roles) if e1 != "" { resp = u.Message(false, "Error while getting :"+entity+","+e1) @@ -1260,7 +1462,7 @@ var GetEntityHierarchy = func(w http.ResponseWriter, r *http.Request) { // Get hierarchy println("Entity: ", entity, " & OID: ", oID.Hex()) - data, e1 = models.GetEntityHierarchy(oID, entity, entNum, limit, filters) + data, e1 = models.GetEntityHierarchy(oID, req, entity, entNum, limit, filters, user.Roles) if data == nil { resp = u.Message(false, "Error while getting :"+entity+","+e1) @@ -1288,7 +1490,8 @@ var GetEntityHierarchy = func(w http.ResponseWriter, r *http.Request) { } // swagger:operation GET /api/hierarchy objects GetCompleteHierarchy -// Returns all objects hierarchyName arranged by relationship (father:[children]) +// Returns all objects hierarchyName. +// Return is arranged by relationship (father:[children]) // and category (category:[objects]) // --- // produces: @@ -1306,7 +1509,43 @@ var GetCompleteHierarchy = func(w http.ResponseWriter, r *http.Request) { DispRequestMetaData(r) var resp map[string]interface{} - data, err := models.GetCompleteHierarchy() + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + data, err := models.GetCompleteHierarchy(user.Roles) + if err != "" { + w.WriteHeader(http.StatusInternalServerError) + resp = u.Message(false, "Error: "+err) + } else { + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + resp = u.Message(true, "successfully got hierarchy") + resp["data"] = data + } + } + + u.Respond(w, resp) +} + +var GetCompleteDomainHierarchy = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetCompleteHierarchy ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + var resp map[string]interface{} + + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + data, err := models.GetCompleteDomainHierarchy(user.Roles) if err != "" { w.WriteHeader(http.StatusInternalServerError) resp = u.Message(false, "Error: "+err) @@ -1323,6 +1562,36 @@ var GetCompleteHierarchy = func(w http.ResponseWriter, r *http.Request) { u.Respond(w, resp) } +var GetCompleteHierarchyAttributes = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetCompleteHierarchyAttributes ") + fmt.Println("******************************************************") + DispRequestMetaData(r) + var resp map[string]interface{} + + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + + data, err := models.GetCompleteHierarchyAttributes(user.Roles) + if err != "" { + w.WriteHeader(http.StatusNotFound) + resp = u.Message(false, "Error: "+err) + } else { + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + resp = u.Message(true, "successfully got hierarchy attributes") + resp["data"] = data + } + } + + u.Respond(w, resp) +} + // swagger:operation GET /api/{entity}/{name}/all objects GetFromObject // Obtain all objects related to Site or stray-device in the system using name. // Returns JSON body with all subobjects @@ -1368,6 +1637,12 @@ var GetHierarchyByName = func(w http.ResponseWriter, r *http.Request) { var resp map[string]interface{} var limit int + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + name, e := mux.Vars(r)["name"] if !e { u.Respond(w, u.Message(false, "Error while parsing name")) @@ -1393,12 +1668,10 @@ var GetHierarchyByName = func(w http.ResponseWriter, r *http.Request) { } else { limit = 999 } + println("The limit is: ", limit) - // Get hierarchy - var req primitive.M - req = bson.M{"hierarchyName": name} - data, e1 := models.GetEntity(req, entity, filters) + data, e1 := models.GetEntity(bson.M{"hierarchyName": name}, entity, filters, user.Roles) if limit >= 1 && e1 == "" { data["children"], e1 = models.GetHierarchyByName(entity, name, limit, filters) } @@ -1416,7 +1689,7 @@ var GetHierarchyByName = func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) default: - println("DEBUG check e1:", e1) + w.WriteHeader(http.StatusNotFound) } } else { @@ -1514,9 +1787,15 @@ var GetEntitiesUsingNamesOfParents = func(w http.ResponseWriter, r *http.Request //If template or stray convert '-' -> '_' entity = strings.Replace(entity, "-", "_", 1) + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + id, e := mux.Vars(r)["id"] tname, e1 := mux.Vars(r)["site_name"] - if e == false && e1 == false { + if !e && !e1 { u.Respond(w, u.Message(false, "Error while parsing path parameters")) u.ErrLog("Error while parsing path parameters", "GET ENTITIESUSINGANCESTORNAMES", "", r) return @@ -1577,12 +1856,13 @@ var GetEntitiesUsingNamesOfParents = func(w http.ResponseWriter, r *http.Request if len(arr)%2 != 0 { //This means we are getting entities var data []map[string]interface{} var e3 string + req := bson.M{} if e1 { println("we are getting entities here") - data, e3 = models.GetEntitiesUsingSiteAsAncestor(entity, tname, ancestry) + data, e3 = models.GetEntitiesUsingSiteAsAncestor(entity, tname, req, ancestry, user.Roles) } else { - data, e3 = models.GetEntitiesUsingAncestorNames(entity, oID, ancestry) + data, e3 = models.GetEntitiesUsingAncestorNames(entity, oID, req, ancestry, user.Roles) } if len(data) == 0 { @@ -1619,10 +1899,12 @@ var GetEntitiesUsingNamesOfParents = func(w http.ResponseWriter, r *http.Request } else { //We are only retrieving an entity var data map[string]interface{} var e3 string - if e1 == true { - data, e3 = models.GetEntityUsingSiteAsAncestor(entity, tname, ancestry) + if e1 { + req := bson.M{"name": tname} + data, e3 = models.GetEntityUsingSiteAsAncestor(req, entity, ancestry) } else { - data, e3 = models.GetEntityUsingAncestorNames(entity, oID, ancestry) + req := bson.M{"_id": oID} + data, e3 = models.GetEntityUsingAncestorNames(req, entity, ancestry) } if len(data) == 0 { @@ -1671,7 +1953,7 @@ var GetEntitiesUsingNamesOfParents = func(w http.ResponseWriter, r *http.Request // in: query // description: 'Only values of "sites", // "buildings", "rooms", "racks", "devices", "room-templates", -// "obj-templates","bldg-templates", "rooms", "acs", "panels", +// "obj-templates", "bldg-templates","rooms", "acs", "panels", // "cabinets", "groups", "corridors","sensors","stray-devices" // "stray-sensors" are acceptable' // @@ -1794,7 +2076,7 @@ var GetStats = func(w http.ResponseWriter, r *http.Request) { // in: query // description: 'Only values of "domains", "sites", // "buildings", "rooms", "racks", "devices", "room-templates", -// "obj-templates","bldg-templates", "rooms", "acs", "panels", +// "obj-templates", "bldg-templates","rooms", "acs", "panels", // "cabinets", "groups", "corridors","sensors","stray-devices" // "stray-sensors" are acceptable' // @@ -1812,6 +2094,12 @@ var ValidateEntity = func(w http.ResponseWriter, r *http.Request) { var obj map[string]interface{} entity, e1 := mux.Vars(r)["entity"] + // Get user roles for permissions + user := getUserFromToken(w, r) + if user == nil { + return + } + //If templates or stray-devices, format them if idx := strings.Index(entity, "-"); idx != -1 { //entStr[idx] = '_' @@ -1838,6 +2126,18 @@ var ValidateEntity = func(w http.ResponseWriter, r *http.Request) { return } + if entInt != u.BLDGTMPL && entInt != u.ROOMTMPL && entInt != u.OBJTMPL { + if permission := models.CheckUserPermissions(user.Roles, entInt, obj["domain"].(string)); permission < models.WRITE { + w.WriteHeader(http.StatusUnauthorized) + u.Respond(w, u.Message(false, "This user"+ + " does not have sufficient permissions to create"+ + " this object under this domain ")) + u.ErrLog("Cannot validate object creation due to limited user privilege", + "Validate CREATE "+entity, "", r) + return + } + } + ans, status := models.ValidateEntity(entInt, obj) if status { u.Respond(w, map[string]interface{}{"status": true, "message": "This object can be created"}) @@ -1884,128 +2184,3 @@ var Version = func(w http.ResponseWriter, r *http.Request) { } u.Respond(w, data) } - -// DEAD CODE -var GetEntityHierarchyNonStd = func(w http.ResponseWriter, r *http.Request) { - fmt.Println("******************************************************") - fmt.Println("FUNCTION CALL: GetEntityHierarchyNonStd ") - fmt.Println("******************************************************") - DispRequestMetaData(r) - var e, e1 bool - var err string - //Extract string between /api and /{id} - idx := strings.Index(r.URL.Path[5:], "/") + 4 - entity := r.URL.Path[5:idx] - - id, e := mux.Vars(r)["id"] - resp := u.Message(true, "success") - data := map[string]interface{}{} - //result := map[string][]map[string]interface{}{} - - if e == false { - if id, e1 = mux.Vars(r)["site_name"]; e1 == false { - u.Respond(w, u.Message(false, "Error while parsing path parameters")) - u.ErrLog("Error while parsing path parameters", "GETHIERARCHYNONSTD", "", r) - return - } - } - - entNum := u.EntityStrToInt(entity) - - if entity == "site" { - println("Getting SITE HEIRARCHY") - println("With ID: ", id) - // data, err = models.GetHierarchyByName(entity, id, entNum, u.AC) - // if err != "" { - // println("We have ERR") - // } - } else { - oID, _ := getObjID(id) - data, err = models.GetEntityHierarchy(oID, entity, entNum, u.AC, u.RequestFilters{}) - } - - if data == nil { - resp = u.Message(false, "Error while getting NonStandard Hierarchy: "+err) - u.ErrLog("Error while getting NonStdHierarchy", "GETNONSTDHIERARCHY", err, r) - - switch err { - case "record not found": - w.WriteHeader(http.StatusNotFound) - default: - } - - } else { - resp = u.Message(true, "success") - result := parseDataForNonStdResult(entity, entNum, data) - resp["data"] = result - //u.Respond(w, resp) - } - - //resp["data"] = data - /*resp["data"] = sites - resp["buildings"] = bldgs - resp["rooms"] = rooms - resp["racks"] = racks - resp["devices"] = devices*/ - u.Respond(w, resp) -} - -// DEAD CODE -func parseDataForNonStdResult(ent string, eNum int, data map[string]interface{}) map[string][]map[string]interface{} { - - ans := map[string][]map[string]interface{}{} - add := []map[string]interface{}{} - - firstIndex := u.EntityToString(eNum + 1) - firstArr := data[firstIndex+"s"].([]map[string]interface{}) - - ans[firstIndex+"s"] = firstArr - - for i := range firstArr { - nxt := u.EntityToString(eNum + 2) - add = append(add, firstArr[i][nxt+"s"].([]map[string]interface{})...) - } - - ans[u.EntityToString(eNum+2)+"s"] = add - newAdd := []map[string]interface{}{} - for i := range add { - nxt := u.EntityToString(eNum + 3) - newAdd = append(newAdd, add[i][nxt+"s"].([]map[string]interface{})...) - } - - ans[u.EntityToString(eNum+3)+"s"] = newAdd - - newAdd2 := []map[string]interface{}{} - for i := range newAdd { - nxt := u.EntityToString(eNum + 4) - newAdd2 = append(newAdd2, newAdd[i][nxt+"s"].([]map[string]interface{})...) - } - - ans[u.EntityToString(eNum+4)+"s"] = newAdd2 - newAdd3 := []map[string]interface{}{} - - for i := range newAdd2 { - nxt := u.EntityToString(eNum + 5) - newAdd3 = append(newAdd3, newAdd2[i][nxt+"s"].([]map[string]interface{})...) - } - ans[u.EntityToString(eNum+5)+"s"] = newAdd3 - - newAdd4 := []map[string]interface{}{} - - for i := range newAdd3 { - nxt := u.EntityToString(eNum + 6) - newAdd4 = append(newAdd4, newAdd3[i][nxt+"s"].([]map[string]interface{})...) - } - - ans[u.EntityToString(eNum+6)+"s"] = newAdd4 - - newAdd5 := []map[string]interface{}{} - - for i := range newAdd4 { - nxt := u.EntityToString(eNum + 7) - newAdd5 = append(newAdd5, newAdd4[i][nxt+"s"].([]map[string]interface{})...) - } - - ans[u.EntityToString(eNum+7)+"s"] = newAdd5 - return ans -} diff --git a/API/controllers/webController.go b/API/controllers/webController.go new file mode 100644 index 000000000..41353c8e4 --- /dev/null +++ b/API/controllers/webController.go @@ -0,0 +1,106 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "p3/models" + u "p3/utils" + + "github.com/gorilla/mux" +) + +var GetProjects = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: GetProjects ") + fmt.Println("******************************************************") + var resp map[string]interface{} + + query, _ := url.ParseQuery(r.URL.RawQuery) + + if len(query["user"]) <= 0 { + w.WriteHeader(http.StatusBadRequest) + resp = u.Message(false, "Error: user should be sent as query param") + u.Respond(w, resp) + return + } + + data, err := models.GetProjectsByUserEmail(query["user"][0]) + if err != "" { + w.WriteHeader(http.StatusNotFound) + resp = u.Message(false, "Error: "+err) + } else { + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + resp = u.Message(true, "successfully got projects") + resp["data"] = data + } + } + + u.Respond(w, resp) +} + +var CreateOrUpdateProject = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: CreateOrUpdateProject ") + fmt.Println("******************************************************") + var resp map[string]interface{} + + project := &models.Project{} + err := json.NewDecoder(r.Body).Decode(project) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + u.Respond(w, u.Message(false, "Invalid request")) + return + } + + var errStr string + if r.Method == "POST" { + // Create project + errStr = models.AddProject(*project) + } else { + // Update project + errStr = models.UpdateProject(*project, mux.Vars(r)["id"]) + } + + if errStr != "" { + w.WriteHeader(http.StatusNotFound) + resp = u.Message(false, "Error: "+errStr) + } else { + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + resp = u.Message(true, "successfully handled project request") + resp["data"] = project + } + } + + u.Respond(w, resp) +} + +var DeleteProject = func(w http.ResponseWriter, r *http.Request) { + fmt.Println("******************************************************") + fmt.Println("FUNCTION CALL: DeleteProject ") + fmt.Println("******************************************************") + var resp map[string]interface{} + + errStr := models.DeleteProject(mux.Vars(r)["id"]) + + if errStr != "" { + w.WriteHeader(http.StatusNotFound) + resp = u.Message(false, "Error: "+errStr) + } else { + if r.Method == "OPTIONS" { + w.Header().Add("Content-Type", "application/json") + w.Header().Add("Allow", "GET, OPTIONS, HEAD") + } else { + resp = u.Message(true, "successfully removed project request") + } + } + + u.Respond(w, resp) +} diff --git a/API/doc.go b/API/doc.go index 981586a94..0c5d00a6e 100644 --- a/API/doc.go +++ b/API/doc.go @@ -1,25 +1,24 @@ -// Golang OGREE API +// OGrEE API // -// Testing Swagger spec. It's still in progress +// This the swagger documentation for the API of the OGrEE project developed by DitRit. +// Check our project here: https://github.com/ditrit/OGrEE-Core // -// The API may return incorrect response codes and have bugs. +// Schemes: http +// BasePath: /api +// Version: 1.0 +// Contact: DitRit https://ditrit.io // -// For a list of endpoints please consult: https://nextcloud.ditrit.io/index.php/apps/files/?dir=/Ogree/1_Core/1_API/Endpoint_List&openfile=20692 -// Schemes: http -// BasePath: /api/user -// Version: 1.0 -// Contact: Ziad Khalaf +// Consumes: +// - application/json // -// Consumes: -// - application/json +// Produces: +// - application/json // -// Produces: -// - application/json +// SecurityDefinitions: +// JWT: +// type: jwt +// name: Authorization +// in: header // -// SecurityDefinitions: -// JWT: -// type: jwt -// name: Authorization -// in: header // swagger:meta package main diff --git a/API/go.mod b/API/go.mod index 4829e5770..c9ba44058 100644 --- a/API/go.mod +++ b/API/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gorilla/handlers v1.5.1 github.com/go-playground/assert/v2 v2.2.0 github.com/gorilla/mux v1.8.0 github.com/gorilla/schema v1.2.0 // indirect diff --git a/API/go.sum b/API/go.sum index af664bc84..fa1dec486 100644 --- a/API/go.sum +++ b/API/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ= +github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= @@ -36,6 +38,8 @@ github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= +github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= diff --git a/API/init_db/package.json b/API/init_db/package.json new file mode 100644 index 000000000..6dce470fb --- /dev/null +++ b/API/init_db/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "bcrypt": "^5.1.0" + } +} diff --git a/API/main.go b/API/main.go index 6f8044a12..c1ceb7471 100644 --- a/API/main.go +++ b/API/main.go @@ -9,6 +9,7 @@ import ( "os" "regexp" + "github.com/gorilla/handlers" "github.com/gorilla/mux" ) @@ -27,7 +28,7 @@ var dmatch mux.MatcherFunc = func(request *http.Request, match *mux.RouteMatch) // Obtain object hierarchy var hmatch mux.MatcherFunc = func(request *http.Request, match *mux.RouteMatch) bool { println("CHECKING H-MATCH") - return regexp.MustCompile(`(^(\/api\/(site|building|room|rack|device|stray-device|domain)\/[a-zA-Z0-9]{24}\/all)(\/(sites|buildings|rooms|racks|devices|stray-(devices|sensors)|domains))*$)|(^(\/api\/(sites|buildings|rooms|racks|devices|stray-devices|domains)\/[a-zA-Z0-9]{24}\/all)(\?limit=[0-9]+)*$)`). + return regexp.MustCompile(`(^(\/api\/(site|building|room|rack|device|stray-device|domain)\/[a-zA-Z0-9]{24}\/all)(\/(sites|buildings|rooms|racks|devices|stray-(devices|sensors)|domains))*$)|(^(\/api\/(sites|buildings|rooms|racks|devices|stray-devices|domains)\/[a-zA-Z0-9]{24}\/all)(\?.*)*$)`). MatchString(request.URL.String()) } @@ -48,27 +49,49 @@ var tmatch mux.MatcherFunc = func(request *http.Request, match *mux.RouteMatch) // For Obtaining hierarchy with hierarchyName var hnmatch mux.MatcherFunc = func(request *http.Request, match *mux.RouteMatch) bool { println("CHECKING HN-MATCH") - return regexp.MustCompile(`^\/api\/(sites|buildings|rooms|racks|devices|stray-devices|domains)+\/[A-Za-z0-9_.]+\/all(\?limit=[0-9]+)*$`). + return regexp.MustCompile(`^\/api\/(sites|buildings|rooms|racks|devices|stray-devices|domains)+\/[A-Za-z0-9_.]+\/all(\?.*)*$`). MatchString(request.URL.String()) } func Router(jwt func(next http.Handler) http.Handler) *mux.Router { router := mux.NewRouter() - router.HandleFunc("/api", - controllers.CreateAccount).Methods("POST", "OPTIONS") - router.HandleFunc("/api/stats", controllers.GetStats).Methods("GET", "OPTIONS", "HEAD") + router.HandleFunc("/api/version", + controllers.Version).Methods("GET", "OPTIONS", "HEAD") + + // User and Authentication router.HandleFunc("/api/login", controllers.Authenticate).Methods("POST", "OPTIONS") router.HandleFunc("/api/token/valid", controllers.Verify).Methods("GET", "OPTIONS", "HEAD") - router.HandleFunc("/api/version", - controllers.Version).Methods("GET", "OPTIONS", "HEAD") + router.HandleFunc("/api/users", + controllers.CreateAccount).Methods("POST", "OPTIONS") + + router.HandleFunc("/api/users/bulk", + controllers.CreateBulkAccount).Methods("POST", "OPTIONS") + + router.HandleFunc("/api/users", + controllers.GetAllAccounts).Methods("GET", "OPTIONS", "HEAD") + + router.HandleFunc("/api/users/{id}", + controllers.RemoveAccount).Methods("DELETE", "OPTIONS") + + router.HandleFunc("/api/users/{id}", + controllers.ModifyUserRoles).Methods("PATCH", "OPTIONS") + + router.HandleFunc("/api/users/password/change", + controllers.ModifyUserPassword).Methods("POST", "OPTIONS") + + router.HandleFunc("/api/users/password/reset", + controllers.ModifyUserPassword).Methods("POST", "OPTIONS") + + router.HandleFunc("/api/users/password/forgot", + controllers.UserForgotPassword).Methods("POST", "OPTIONS") // For obtaining temperatureUnit from object's site router.HandleFunc("/api/tempunits/{id}", @@ -78,6 +101,25 @@ func Router(jwt func(next http.Handler) http.Handler) *mux.Router { router.HandleFunc("/api/hierarchy", controllers.GetCompleteHierarchy).Methods("GET", "OPTIONS", "HEAD") + router.HandleFunc("/api/hierarchy/domains", + controllers.GetCompleteDomainHierarchy).Methods("GET", "OPTIONS", "HEAD") + + router.HandleFunc("/api/hierarchy/attributes", + controllers.GetCompleteHierarchyAttributes).Methods("GET", "OPTIONS", "HEAD") + + // FLUTTER FRONT + router.HandleFunc("/api/projects", + controllers.GetProjects).Methods("HEAD", "GET", "OPTIONS") + + router.HandleFunc("/api/projects", + controllers.CreateOrUpdateProject).Methods("POST") + + router.HandleFunc("/api/projects/{id:[a-zA-Z0-9]{24}}", + controllers.CreateOrUpdateProject).Methods("PUT") + + router.HandleFunc("/api/projects/{id:[a-zA-Z0-9]{24}}", + controllers.DeleteProject).Methods("DELETE", "OPTIONS") + // ------ GET ------ // router.HandleFunc("/api/objects/{name}", controllers.GetGenericObject).Methods("GET", "HEAD", "OPTIONS") @@ -127,6 +169,9 @@ func Router(jwt func(next http.Handler) http.Handler) *mux.Router { router.HandleFunc("/api/{entity}s", controllers.CreateEntity).Methods("POST") + router.HandleFunc("/api/domains/bulk", + controllers.CreateBulkDomain).Methods("POST") + //DELETE ENTITY router.HandleFunc("/api/{entity}s/{id:[a-zA-Z0-9]{24}}", controllers.DeleteEntity).Methods("DELETE") @@ -174,7 +219,10 @@ func main() { fmt.Println(port) //Start app, localhost:8000/api - err := http.ListenAndServe(":"+port, router) + corsObj := handlers.AllowedOrigins([]string{"*"}) + headersOk := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization", "Origin", "Accept"}) + methodsOk := handlers.AllowedMethods([]string{"GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"}) + err := http.ListenAndServe(":"+port, handlers.CORS(corsObj, headersOk, methodsOk)(router)) if err != nil { fmt.Print(err) } diff --git a/API/main_test.go b/API/main_test.go index 7469e2a9d..cff5693e8 100644 --- a/API/main_test.go +++ b/API/main_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "p3/app" "p3/models" u "p3/utils" "reflect" @@ -14,29 +15,42 @@ import ( "testing" "github.com/go-playground/assert/v2" + "go.mongodb.org/mongo-driver/bson/primitive" ) func TestMain(m *testing.M) { - //teardown() + // teardown() + getAdminToken() exitCode := m.Run() os.Exit(exitCode) } +var adminId primitive.ObjectID +var adminToken string + +func getAdminToken() { + // Create admin account + admin := models.Account{} + admin.Email = "admin@admin.com" + admin.Password = "admin123" + admin.Roles = map[string]models.Role{"*": "manager"} + response, _ := admin.Create(map[string]models.Role{"*": "manager"}) + if response["account"] != nil { + adminId = response["account"].(*models.Account).ID + adminToken = response["account"].(*models.Account).Token + } +} + func teardown() { ctx, _ := u.Connect() models.GetDB().Drop(ctx) } -var JwtAuthSkip = func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r) - }) -} - func makeRequest(method, url string, requestBody []byte) *httptest.ResponseRecorder { - router := Router(JwtAuthSkip) + router := Router(app.JwtAuthentication) recorder := httptest.NewRecorder() request, _ := http.NewRequest(method, url, bytes.NewBuffer(requestBody)) + request.Header.Set("Authorization", "Bearer "+adminToken) router.ServeHTTP(recorder, request) return recorder } @@ -45,10 +59,10 @@ func TestCreateLoginAccount(t *testing.T) { // Test create new account requestBody := []byte(`{ "email": "test@test.com", - "password": "pass123secret" + "password": "pass123secret", + "roles":{"*":"manager"} }`) - recorder := makeRequest("POST", "/api", requestBody) - + recorder := makeRequest("POST", "/api/users", requestBody) assert.Equal(t, http.StatusCreated, recorder.Code) var response map[string]interface{} @@ -57,8 +71,8 @@ func TestCreateLoginAccount(t *testing.T) { assert.Equal(t, true, exists) // Test recreate existing account - recorder = makeRequest("POST", "/api", requestBody) - assert.Equal(t, http.StatusConflict, recorder.Code) + recorder = makeRequest("POST", "/api/users", requestBody) + assert.Equal(t, http.StatusBadRequest, recorder.Code) // Test login recorder = makeRequest("POST", "/api/login", requestBody) diff --git a/API/models/accounts.go b/API/models/accounts.go index 24c3755a5..2fd8fc92b 100644 --- a/API/models/accounts.go +++ b/API/models/accounts.go @@ -4,24 +4,32 @@ import ( "os" u "p3/utils" "regexp" + "time" "github.com/dgrijalva/jwt-go" "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "golang.org/x/crypto/bcrypt" ) +const token_expiration = time.Hour * 72 + // JWT Claims struct type Token struct { - Email string `json:"email"` + Email string `json:"email"` + UserId primitive.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` jwt.StandardClaims } // a struct for rep user account type Account struct { - Email string `json:"email"` - Password string `json:"password"` - Token string `json:"token" sql:"-"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + Name string `bson:"name" json:"name"` + Email string `bson:"email" json:"email"` + Password string `bson:"password" json:"password"` + Roles map[string]Role `bson:"roles" json:"roles"` + Token string `bson:"token,omitempty" json:"token,omitempty"` } // Validate incoming user @@ -32,9 +40,8 @@ func (account *Account) Validate() (map[string]interface{}, bool) { return u.Message(false, "A valid email address is required"), false } - if len(account.Password) < 7 { - return u.Message(false, - "Please provide a Password with a length greater than 6"), false + if e := validatePasswordFormat(account.Password); e != "" { + return u.Message(false, e), false } //Error checking and duplicate emails @@ -50,13 +57,47 @@ func (account *Account) Validate() (map[string]interface{}, bool) { return u.Message(false, "Error: User already exists"), false } defer cancel() + + // Validate domains and roles + if e := validateDomainRoles(account.Roles); e != "" { + return u.Message(false, e), false + } + return u.Message(false, "Requirement passed"), true } -func (account *Account) Create() (map[string]interface{}, string) { +func validateDomainRoles(roles map[string]Role) string { + // Validate domains and roles + if len(roles) <= 0 { + return "Object 'roles' with domain names as keys and roles as values is mandatory" + } + for domain, role := range roles { + if !CheckDomainExists(domain) { + return "Domain does not exist: " + domain + } + switch role { + case Manager: + case Viewer: + case User: + break + default: + return "Role assigned is not valid: " + } + } + return "" +} + +func (account *Account) Create(callerRoles map[string]Role) (map[string]interface{}, string) { + // Check if user is allowed to create new users + if !CheckCanManageUser(callerRoles, account.Roles) { + return u.Message(false, + "Invalid credentials for creating an account."+ + " Manager role in requested domains is needed."), "unauthorised" + } + // Validate new user if resp, ok := account.Validate(); !ok { - return resp, "exists" + return resp, "validate" } hashedPassword, _ := bcrypt.GenerateFromPassword( @@ -65,23 +106,17 @@ func (account *Account) Create() (map[string]interface{}, string) { account.Password = string(hashedPassword) ctx, cancel := u.Connect() - search := GetDB().Collection("account").FindOne(ctx, bson.M{"email": account.Email}) - if search.Err() != nil { - GetDB().Collection("account").InsertOne(ctx, account) - } else { + res, err := GetDB().Collection("account").InsertOne(ctx, account) + if err != nil { return u.Message(false, - "Error: User already exists:"), "clientError" + "DB error when creating user: "+err.Error()), "internal" + } else { + account.ID = res.InsertedID.(primitive.ObjectID) } - defer cancel() //Create new JWT token for the newly created account - tk := &Token{Email: account.Email} - token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk) - tokenString, _ := token.SignedString([]byte(os.Getenv("token_password"))) - - account.Token = tokenString - + account.Token = GenerateToken(account.Email, account.ID, token_expiration) account.Password = "" response := u.Message(true, "Account has been created") @@ -89,51 +124,108 @@ func (account *Account) Create() (map[string]interface{}, string) { return response, "" } +func validatePasswordFormat(password string) string { + if len(password) < 7 { + return "Please provide a password with a length greater than 6" + } + return "" +} + +func comparePasswordToAccount(account Account, inputPassword string) (string, string) { + err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(inputPassword)) + if err == bcrypt.ErrMismatchedHashAndPassword { + return "Password is not correct", "validate" + } else if err == bcrypt.ErrHashTooShort { + if account.Email == "admin" && + account.Password == inputPassword && account.Password == "admin" { + return "", "change" + } else { + return "Password is not correct", "validate" + } + } else if err != nil { + return "Internal error comparing passwords", "internal" + } + return "", "" +} + +func (account *Account) ChangePassword(password string, newPassword string, isReset bool) (string, string) { + if !isReset { + // Check if current password is correct + errStr, errType := comparePasswordToAccount(*account, password) + if errStr != "" { + return errStr, errType + } + } + + // Validate new password + if e := validatePasswordFormat(newPassword); e != "" { + return "New password not valid: " + e, "validate" + } + + // Update user + ctx, cancel := u.Connect() + defer cancel() + user := map[string]interface{}{} + hashedPassword, _ := bcrypt.GenerateFromPassword( + []byte(newPassword), bcrypt.DefaultCost) + user["password"] = string(hashedPassword) + err := GetDB().Collection("account").FindOneAndUpdate(ctx, bson.M{"_id": account.ID}, bson.M{"$set": user}).Err() + if err != nil { + return "Internal error while updating user password", "internal" + } + + return GenerateToken(account.Email, account.ID, token_expiration), "" +} + func Login(email, password string) (map[string]interface{}, string) { account := &Account{} + resp := u.Message(true, "Logged In") ctx, cancel := u.Connect() err := GetDB().Collection("account").FindOne(ctx, bson.M{"email": email}).Decode(account) - //err := GetDB().Collection("accounts").FindOne(ctx, bson.M{"email": email}).Decode(account) - //err := GetDB().Table("account").Where("email = ?", email).First(account).Error if err != nil { if err == mongo.ErrNoDocuments { - return u.Message(false, "Error, email not found"), "internal" + return u.Message(false, "User does not exist"), "internal" } return u.Message(false, "Connection error. Please try again later"), "internal" } defer cancel() - //Should investigate if the password is sent in - //cleartext over the wire - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) - - if err != nil && err == bcrypt.ErrMismatchedHashAndPassword { + //Check password + errStr, errType := comparePasswordToAccount(*account, password) + if errStr != "" { return u.Message(false, - "Invalid login credentials. Please try again"), "invalid" + "Invalid login credentials. Please try again"), errType + } else if errType != "" { + resp["shouldChange"] = true } //Success account.Password = "" //Create JWT token - tk := &Token{Email: account.Email} - token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk) - tokenString, _ := token.SignedString([]byte(os.Getenv("token_password"))) - account.Token = tokenString + account.Token = GenerateToken(account.Email, account.ID, token_expiration) - resp := u.Message(true, "Logged In") resp["account"] = account return resp, "" } -func GetUser(user int) *Account { +func GenerateToken(email string, id primitive.ObjectID, expire time.Duration) string { + // Create JWT token + tk := &Token{Email: email, UserId: id} + tk.ExpiresAt = time.Now().Add(expire).Unix() + token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), tk) + tokenString, _ := token.SignedString([]byte(os.Getenv("token_password"))) + return tokenString +} +// Returns user omitting password +func GetUser(userId primitive.ObjectID) *Account { acc := &Account{} ctx, cancel := u.Connect() - GetDB().Collection("account").FindOne(ctx, bson.M{"_id": user}).Decode(acc) - if acc.Email == "" { + err := GetDB().Collection("account").FindOne(ctx, bson.M{"_id": userId}).Decode(acc) + if err != nil || acc.Email == "" { return nil } defer cancel() @@ -141,3 +233,77 @@ func GetUser(user int) *Account { acc.Password = "" return acc } + +// Returns user with password in clear text +func GetUserByEmail(email string) *Account { + acc := &Account{} + ctx, cancel := u.Connect() + err := GetDB().Collection("account").FindOne(ctx, bson.M{"email": email}).Decode(acc) + if err != nil || acc.Email == "" { + return nil + } + defer cancel() + + return acc +} + +func GetAllUsers(callerRoles map[string]Role) ([]Account, string) { + // Get all users + ctx, cancel := u.Connect() + c, err := GetDB().Collection("account").Find(ctx, bson.M{}) + if err != nil { + println(err.Error()) + return nil, err.Error() + } + users := []Account{} + err = c.All(ctx, &users) + if err != nil { + println(err.Error()) + return nil, err.Error() + } + + // Return allowed users according to caller permissions + allowedUser := []Account{} + for _, user := range users { + if CheckCanManageUser(callerRoles, user.Roles) { + allowedUser = append(allowedUser, user) + } + } + + defer cancel() + return allowedUser, "" +} + +func DeleteUser(userId primitive.ObjectID) string { + ctx, cancel := u.Connect() + req := bson.M{"_id": userId} + c, _ := GetDB().Collection("account").DeleteOne(ctx, req) + if c.DeletedCount == 0 { + return "Internal error try to delete user" + } + defer cancel() + return "" +} + +func ModifyUser(id string, roles map[string]Role) (string, string) { + objID, err := primitive.ObjectIDFromHex(id) + if err != nil { + return "User ID not valid", "validate" + } + + if e := validateDomainRoles(roles); e != "" { + return e, "validate" + } + + println("UPDATE!") + ctx, cancel := u.Connect() + defer cancel() + user := map[string]interface{}{} + user["roles"] = roles + err = GetDB().Collection("account").FindOneAndUpdate(ctx, bson.M{"_id": objID}, bson.M{"$set": user}).Err() + if err != nil { + return "Internal error while updating user roles", "internal" + } + + return "", "" +} diff --git a/API/models/model.go b/API/models/model.go index 4fcba027a..2960b7667 100644 --- a/API/models/model.go +++ b/API/models/model.go @@ -17,12 +17,27 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" ) -func CreateEntity(entity int, t map[string]interface{}) (map[string]interface{}, string) { +func CreateEntity(entity int, t map[string]interface{}, userRoles map[string]Role) (map[string]interface{}, string) { message := "" if resp, ok := ValidateEntity(entity, t); !ok { return resp, "validate" } + // Check user permissions + if entity != u.BLDGTMPL && entity != u.ROOMTMPL && entity != u.OBJTMPL { + var domain string + if entity == u.DOMAIN { + domain = t["hierarchyName"].(string) + } else { + domain = t["domain"].(string) + } + if permission := CheckUserPermissions(userRoles, entity, domain); permission < WRITE { + return u.Message(false, + "User does not have permission to create this object"), + "permission" + } + } + //Set timestamp t["createdDate"] = primitive.NewDateTimeFromTime(time.Now()) t["lastUpdated"] = t["createdDate"] @@ -64,7 +79,7 @@ func CreateEntity(entity int, t map[string]interface{}) (map[string]interface{}, } // GetObjectByName: search for hierarchyName in all possible collections -func GetObjectByName(hierarchyName string, filters u.RequestFilters) (map[string]interface{}, string) { +func GetObjectByName(hierarchyName string, filters u.RequestFilters, userRoles map[string]Role) (map[string]interface{}, string) { var resp map[string]interface{} // Get possible collections for this name rangeEntities := u.HierachyNameToEntity(hierarchyName) @@ -72,11 +87,8 @@ func GetObjectByName(hierarchyName string, filters u.RequestFilters) (map[string // Search each collection for _, entity := range rangeEntities { req := bson.M{"hierarchyName": hierarchyName} - if entity == u.SITE { - req = bson.M{"name": hierarchyName} - } entityStr := u.EntityToString(entity) - data, _ := GetEntity(req, entityStr, filters) + data, _ := GetEntity(req, entityStr, filters, userRoles) if data != nil { resp = data break @@ -90,7 +102,7 @@ func GetObjectByName(hierarchyName string, filters u.RequestFilters) (map[string } } -func GetEntity(req bson.M, ent string, filters u.RequestFilters) (map[string]interface{}, string) { +func GetEntity(req bson.M, ent string, filters u.RequestFilters, userRoles map[string]Role) (map[string]interface{}, string) { t := map[string]interface{}{} ctx, cancel := u.Connect() var e error @@ -117,9 +129,29 @@ func GetEntity(req bson.M, ent string, filters u.RequestFilters) (map[string]int return nil, e.Error() } defer cancel() + //Remove _id t = fixID(t) + // Check permissions + if !strings.Contains(ent, "template") { + var domain string + if ent == "domain" { + domain = t["name"].(string) + } else { + domain = t["domain"].(string) + } + if userRoles != nil { + if permission := CheckUserPermissions(userRoles, u.EntityStrToInt(ent), domain); permission == NONE { + return u.Message(false, + "User does not have permission to see this object"), + "permission" + } else if permission == READONLYNAME { + t = FixReadOnlyName(t) + } + } + } + //If entity has '_' remove it if strings.Contains(ent, "_") { FixUnderScore(t) @@ -151,7 +183,7 @@ func getDateFilters(req bson.M, filters u.RequestFilters) error { return nil } -func GetManyEntities(ent string, req bson.M, filters u.RequestFilters) ([]map[string]interface{}, string) { +func GetManyEntities(ent string, req bson.M, filters u.RequestFilters, userRoles map[string]Role) ([]map[string]interface{}, string) { ctx, cancel := u.Connect() var err error var c *mongo.Cursor @@ -180,7 +212,7 @@ func GetManyEntities(ent string, req bson.M, filters u.RequestFilters) ([]map[st } defer cancel() - data, e1 := ExtractCursor(c, ctx) + data, e1 := ExtractCursor(c, ctx, u.EntityStrToInt(ent), userRoles) if e1 != "" { fmt.Println(e1) return nil, e1 @@ -202,49 +234,89 @@ func GetManyEntities(ent string, req bson.M, filters u.RequestFilters) ([]map[st // - categories: map with category name as key and corresponding objects // as an array value // categories: {categoryName:[children]} -func GetCompleteHierarchy() (map[string]interface{}, string) { +func GetCompleteDomainHierarchy(userRoles map[string]Role) (map[string]interface{}, string) { response := make(map[string]interface{}) - categories := make(map[string][]string) hierarchy := make(map[string][]string) - rootCollectionName := "site" // Get all collections names ctx, cancel := u.Connect() db := GetDB() - collNames, err := db.ListCollectionNames(ctx, bson.D{}) + collName := "domain" + + // Get all objects hierarchyNames for each collection + opts := options.Find().SetProjection(bson.D{{Key: "hierarchyName", Value: 1}, {Key: "domain", Value: 1}}) + + c, err := db.Collection(collName).Find(ctx, bson.M{}, opts) if err != nil { - fmt.Println(err.Error()) - return nil, err.Error() + println(err.Error()) + } + data, error := ExtractCursor(c, ctx, u.EntityStrToInt(collName), userRoles) + if error != "" { + fmt.Println(error) + return nil, error } + for _, obj := range data { + if strings.Contains(obj["hierarchyName"].(string), ".") { + fillHierarchyMap(obj["hierarchyName"].(string), hierarchy) + } else { + hierarchy["Root"] = append(hierarchy["Root"], obj["hierarchyName"].(string)) + } + } + + response["tree"] = hierarchy + defer cancel() + return response, "" +} + +// GetCompleteHierarchy: gets all objects in db using hierachyName and returns: +// - tree: map with parents as key and their children as an array value +// tree: {parent:[children]} +// - categories: map with category name as key and corresponding objects +// as an array value +// categories: {categoryName:[children]} +func GetCompleteHierarchy(userRoles map[string]Role) (map[string]interface{}, string) { + response := make(map[string]interface{}) + categories := make(map[string][]string) + hierarchy := make(map[string][]string) + rootCollectionName := "site" + + // Get all collections names + var collNames []string + for i := u.SITE; i <= u.GROUP; i++ { + collNames = append(collNames, u.EntityToString(i)) + } + + ctx, cancel := u.Connect() + db := GetDB() + // Get all objects hierarchyNames for each collection for _, collName := range collNames { - opts := options.Find().SetProjection(bson.D{{Key: "hierarchyName", Value: 1}}) - if collName == rootCollectionName { - opts = options.Find().SetProjection(bson.D{{Key: "name", Value: 1}}) - } + opts := options.Find().SetProjection(bson.D{{Key: "hierarchyName", Value: 1}, {Key: "domain", Value: 1}}) c, err := db.Collection(collName).Find(ctx, bson.M{}, opts) if err != nil { println(err.Error()) } - data, error := ExtractCursor(c, ctx) + data, error := ExtractCursor(c, ctx, u.EntityStrToInt(collName), userRoles) if error != "" { fmt.Println(error) return nil, error } for _, obj := range data { - if obj["hierarchyName"] != nil { + if collName == rootCollectionName { + categories[rootCollectionName] = append(categories[rootCollectionName], obj["hierarchyName"].(string)) + hierarchy["Root"] = append(hierarchy["Root"], obj["hierarchyName"].(string)) + + } else if obj["hierarchyName"] != nil { categories[collName] = append(categories[collName], obj["hierarchyName"].(string)) fillHierarchyMap(obj["hierarchyName"].(string), hierarchy) - } else if obj["name"] != nil { - categories[rootCollectionName] = append(categories[rootCollectionName], obj["name"].(string)) - hierarchy["Root"] = append(hierarchy["Root"], obj["name"].(string)) } } } + categories["KeysOrder"] = []string{"site", "building", "room", "rack"} response["tree"] = hierarchy response["categories"] = categories defer cancel() @@ -260,6 +332,49 @@ func fillHierarchyMap(hierarchyName string, data map[string][]string) { } } +func GetCompleteHierarchyAttributes(userRoles map[string]Role) (map[string]interface{}, string) { + response := make(map[string]interface{}) + // Get all collections names + ctx, cancel := u.Connect() + db := GetDB() + collNames, err := db.ListCollectionNames(ctx, bson.D{}) + if err != nil { + fmt.Println(err.Error()) + return nil, err.Error() + } + + for _, collName := range collNames { + if entInt := u.EntityStrToInt(collName); entInt > -1 { + projection := bson.D{{Key: "hierarchyName", Value: 1}, {Key: "attributes", Value: 1}, + {Key: "domain", Value: 1}} + + opts := options.Find().SetProjection(projection) + + c, err := db.Collection(collName).Find(ctx, bson.M{}, opts) + if err != nil { + println(err.Error()) + } + data, error := ExtractCursor(c, ctx, entInt, userRoles) + if error != "" { + fmt.Println(error) + return nil, error + } + + for _, obj := range data { + if obj["attributes"] != nil { + if obj["hierarchyName"] != nil { + response[obj["hierarchyName"].(string)] = obj["attributes"] + } else if obj["name"] != nil { + response[obj["name"].(string)] = obj["attributes"] + } + } + } + } + } + defer cancel() + return response, "" +} + func domainHasObjects(domain string) bool { data := map[string]interface{}{} // Get all collections names @@ -417,7 +532,7 @@ func GetDBName() string { // DeleteEntityByName: delete object of given hierarchyName // search for all its children and delete them too, return: // - success or fail message map -func DeleteEntityByName(entity string, name string) (map[string]interface{}, string) { +func DeleteEntityByName(entity string, name string, userRoles map[string]Role) (map[string]interface{}, string) { if entity == "domain" { if name == os.Getenv("db") { return u.Message(false, "Cannot delete tenant's default domain"), "domain" @@ -427,13 +542,12 @@ func DeleteEntityByName(entity string, name string) (map[string]interface{}, str } } - var req primitive.M - if entity == "site" { - req = bson.M{"name": name} - } else { - req = bson.M{"hierarchyName": name} + req, ok := GetRequestFilterByDomain(userRoles) + if !ok { + return u.Message(false, "User does not have permission to delete"), "permission" } - resp, err := DeleteEntityManual(entity, req) + req["hierarchyName"] = name + resp, err := DeleteSingleEntity(entity, req) if err != "" { // Unable to delete given object @@ -455,7 +569,7 @@ func DeleteEntityByName(entity string, name string) (map[string]interface{}, str return u.Message(true, "success"), "" } -func DeleteEntityManual(entity string, req bson.M) (map[string]interface{}, string) { +func DeleteSingleEntity(entity string, req bson.M) (map[string]interface{}, string) { //Finally delete the Entity ctx, cancel := u.Connect() c, _ := GetDB().Collection(entity).DeleteOne(ctx, req) @@ -467,15 +581,15 @@ func DeleteEntityManual(entity string, req bson.M) (map[string]interface{}, stri return u.Message(true, "success"), "" } -func DeleteEntity(entity string, id primitive.ObjectID) (map[string]interface{}, string) { +func DeleteEntity(entity string, id primitive.ObjectID, rnd map[string]interface{}) (map[string]interface{}, string) { var t map[string]interface{} var e string eNum := u.EntityStrToInt(entity) if eNum > u.DEVICE { //Delete the non hierarchal objects - t, e = GetEntityHierarchy(id, entity, eNum, eNum+eNum, u.RequestFilters{}) + t, e = GetEntityHierarchy(id, rnd, entity, eNum, eNum+eNum, u.RequestFilters{}, nil) } else { - t, e = GetEntityHierarchy(id, entity, eNum, u.AC, u.RequestFilters{}) + t, e = GetEntityHierarchy(id, rnd, entity, eNum, u.AC, u.RequestFilters{}, nil) } if e != "" { @@ -534,7 +648,7 @@ func deleteHelper(t map[string]interface{}, ent int) (map[string]interface{}, st } if ent == u.DEVICE { - DeleteDeviceF(t["id"].(primitive.ObjectID)) + DeleteDeviceF(t["id"].(primitive.ObjectID), nil) } else { if ent == u.DOMAIN { if t["name"] == os.Getenv("db") { @@ -574,18 +688,32 @@ func updateOldObjWithPatch(old map[string]interface{}, patch map[string]interfac return "" } -func UpdateEntity(ent string, req bson.M, t map[string]interface{}, isPatch bool) (map[string]interface{}, string) { +func UpdateEntity(ent string, req bson.M, t map[string]interface{}, isPatch bool, userRoles map[string]Role) (map[string]interface{}, string) { var e *mongo.SingleResult updatedDoc := bson.M{} retDoc := options.ReturnDocument(options.After) + entInt := u.EntityStrToInt(ent) //Update timestamp requires first obj retrieval //there isn't any way for mongoDB to make a field //immutable in a document - oldObj, e1 := GetEntity(req, ent, u.RequestFilters{}) + oldObj, e1 := GetEntity(req, ent, u.RequestFilters{}, userRoles) if e1 != "" { + if e1 == "permission" { + return oldObj, e1 + } return u.Message(false, "Error: "+e1), e1 } + + //Check if permission is only readonly + if entInt != u.BLDGTMPL && entInt != u.ROOMTMPL && entInt != u.OBJTMPL && + (oldObj["description"] == nil) { + // Description is always present, unless GetEntity was called with readonly permission + return u.Message(false, + "User does not have permission to change this object"), + "permission" + } + t["lastUpdated"] = primitive.NewDateTimeFromTime(time.Now()) t["createdDate"] = oldObj["createdDate"] @@ -612,6 +740,18 @@ func UpdateEntity(ent string, req bson.M, t map[string]interface{}, isPatch bool if !ok { return msg, "invalid" } + + // Check user permissions in case domain is being updated + if entInt != u.DOMAIN && entInt != u.BLDGTMPL && entInt != u.ROOMTMPL && entInt != u.OBJTMPL && + (oldObj["domain"] != t["domain"]) { + if permission := CheckUserPermissions(userRoles, entInt, t["domain"].(string)); permission < WRITE { + return u.Message(false, + "User does not have permission to change this object"), + "permission" + } + } + + // Update database e = GetDB().Collection(ent).FindOneAndReplace(ctx, req, t, &options.FindOneAndReplaceOptions{ReturnDocument: &retDoc}) @@ -620,10 +760,6 @@ func UpdateEntity(ent string, req bson.M, t map[string]interface{}, isPatch bool } // Changes to hierarchyName should be propagated to its children - if ent == "site" && oldObj["name"] != t["name"] { - propagateParentNameChange(ctx, oldObj["name"].(string), - t["name"].(string), u.EntityStrToInt(ent)) - } if oldObj["hierarchyName"] != t["hierarchyName"] { propagateParentNameChange(ctx, oldObj["hierarchyName"].(string), t["hierarchyName"].(string), u.EntityStrToInt(ent)) @@ -666,7 +802,6 @@ func propagateParentNameChange(ctx context.Context, oldParentName, newName strin "input": "$hierarchyName", "find": oldParentName, "replacement": newName}}}}} - if entityInt == u.DEVICE { _, e := GetDB().Collection(u.EntityToString(u.DEVICE)).UpdateMany(ctx, req, mongo.Pipeline{update}) @@ -690,11 +825,20 @@ func propagateParentNameChange(ctx context.Context, oldParentName, newName strin } } -func GetEntityHierarchy(ID primitive.ObjectID, ent string, start, end int, filters u.RequestFilters) (map[string]interface{}, string) { +func GetEntityHierarchy(ID primitive.ObjectID, req bson.M, ent string, start, end int, filters u.RequestFilters, userRoles map[string]Role) (map[string]interface{}, string) { var childEnt string if start < end { - top, e := GetEntity(bson.M{"_id": ID}, ent, filters) + //We want to filter using RBAC requirements and the ID + //The RBAC requirements are included in req + newReq := req + if req == nil { + newReq = bson.M{"_id": ID} + } else { + newReq["_id"] = ID + } + + top, e := GetEntity(newReq, ent, filters, userRoles) if top == nil { return nil, e } @@ -705,10 +849,10 @@ func GetEntityHierarchy(ID primitive.ObjectID, ent string, start, end int, filte //Get sensors & groups if ent == "rack" || ent == "device" { //Get sensors - sensors, _ := GetManyEntities("sensor", bson.M{"parentId": pid}, filters) + sensors, _ := GetManyEntities("sensor", bson.M{"parentId": pid}, filters, userRoles) //Get groups - groups, _ := GetManyEntities("group", bson.M{"parentId": pid}, filters) + groups, _ := GetManyEntities("group", bson.M{"parentId": pid}, filters, userRoles) if sensors != nil { children = append(children, sensors...) @@ -724,10 +868,10 @@ func GetEntityHierarchy(ID primitive.ObjectID, ent string, start, end int, filte childEnt = u.EntityToString(start + 1) } - subEnts, _ := GetManyEntities(childEnt, bson.M{"parentId": pid}, filters) + subEnts, _ := GetManyEntities(childEnt, bson.M{"parentId": pid}, filters, userRoles) for idx := range subEnts { - tmp, _ := GetEntityHierarchy(subEnts[idx]["id"].(primitive.ObjectID), childEnt, start+1, end, filters) + tmp, _ := GetEntityHierarchy(subEnts[idx]["id"].(primitive.ObjectID), req, childEnt, start+1, end, filters, userRoles) if tmp != nil { subEnts[idx] = tmp } @@ -739,29 +883,29 @@ func GetEntityHierarchy(ID primitive.ObjectID, ent string, start, end int, filte if ent == "room" { for i := u.AC; i < u.CABINET+1; i++ { - roomEnts, _ := GetManyEntities(u.EntityToString(i), bson.M{"parentId": pid}, filters) + roomEnts, _ := GetManyEntities(u.EntityToString(i), bson.M{"parentId": pid}, filters, userRoles) if roomEnts != nil { children = append(children, roomEnts...) } } for i := u.PWRPNL; i < u.SENSOR+1; i++ { - roomEnts, _ := GetManyEntities(u.EntityToString(i), bson.M{"parentId": pid}, filters) + roomEnts, _ := GetManyEntities(u.EntityToString(i), bson.M{"parentId": pid}, filters, userRoles) if roomEnts != nil { children = append(children, roomEnts...) } } - roomEnts, _ := GetManyEntities(u.EntityToString(u.CORRIDOR), bson.M{"parentId": pid}, filters) + roomEnts, _ := GetManyEntities(u.EntityToString(u.CORRIDOR), bson.M{"parentId": pid}, filters, userRoles) if roomEnts != nil { children = append(children, roomEnts...) } - roomEnts, _ = GetManyEntities(u.EntityToString(u.GROUP), bson.M{"parentId": pid}, filters) + roomEnts, _ = GetManyEntities(u.EntityToString(u.GROUP), bson.M{"parentId": pid}, filters, userRoles) if roomEnts != nil { children = append(children, roomEnts...) } } if ent == "stray_device" { - sSensors, _ := GetManyEntities("stray_sensor", bson.M{"parentId": pid}, filters) + sSensors, _ := GetManyEntities("stray_sensor", bson.M{"parentId": pid}, filters, userRoles) if sSensors != nil { children = append(children, sSensors...) } @@ -776,8 +920,15 @@ func GetEntityHierarchy(ID primitive.ObjectID, ent string, start, end int, filte return nil, "" } -func GetEntitiesUsingAncestorNames(ent string, id primitive.ObjectID, ancestry []map[string]string) ([]map[string]interface{}, string) { - top, e := GetEntity(bson.M{"_id": id}, ent, u.RequestFilters{}) +func GetEntitiesUsingAncestorNames(ent string, id primitive.ObjectID, req map[string]interface{}, ancestry []map[string]string, userRoles map[string]Role) ([]map[string]interface{}, string) { + + newReq := req + if newReq == nil { + newReq = bson.M{"_id": id} + } else { + newReq["_id"] = id + } + top, e := GetEntity(newReq, ent, u.RequestFilters{}, nil) if e != "" { return nil, e } @@ -800,10 +951,10 @@ func GetEntitiesUsingAncestorNames(ent string, id primitive.ObjectID, ancestry [ /*if k == "device" { return GetDeviceFByParentID(pid) nil, "" }*/ - return GetManyEntities(k, bson.M{"parentId": pid}, u.RequestFilters{}) + return GetManyEntities(k, bson.M{"parentId": pid}, u.RequestFilters{}, userRoles) } - x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}) + x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}, userRoles) if e1 != "" { println("Failing here") return nil, "" @@ -815,8 +966,8 @@ func GetEntitiesUsingAncestorNames(ent string, id primitive.ObjectID, ancestry [ return nil, "" } -func GetEntityUsingAncestorNames(ent string, id primitive.ObjectID, ancestry []map[string]string) (map[string]interface{}, string) { - top, e := GetEntity(bson.M{"_id": id}, ent, u.RequestFilters{}) +func GetEntityUsingAncestorNames(req map[string]interface{}, ent string, ancestry []map[string]string) (map[string]interface{}, string) { + top, e := GetEntity(req, ent, u.RequestFilters{}, nil) if e != "" { return nil, e } @@ -833,7 +984,7 @@ func GetEntityUsingAncestorNames(ent string, id primitive.ObjectID, ancestry []m println("KEY:", k, " VAL:", v) - x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}) + x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}, nil) if e1 != "" { println("Failing here") return nil, "" @@ -868,7 +1019,7 @@ func GetHierarchyByName(entity, hierarchyName string, limit int, filters u.Reque // Obj should include parentName and not surpass limit range pattern := primitive.Regex{Pattern: hierarchyName + "(.[A-Za-z0-9_\" \"]+){1," + strconv.Itoa(limit) + "}$", Options: ""} - children, e1 := GetManyEntities(checkEntName, bson.M{"hierarchyName": pattern}, filters) + children, e1 := GetManyEntities(checkEntName, bson.M{"hierarchyName": pattern}, filters, nil) if e1 != "" { println("SUBENT: ", checkEntName) println("ERR: ", e1) @@ -906,7 +1057,11 @@ func getChildrenCollections(limit int, parentEntStr string) []int { rangeEntities := []int{} startEnt := u.EntityStrToInt(parentEntStr) + 1 endEnt := startEnt + limit - if parentEntStr == "device" { + if parentEntStr == "domain" { + // device special case (devices can have devices) + startEnt = u.DOMAIN + endEnt = u.DOMAIN + } else if parentEntStr == "device" { // device special case (devices can have devices) startEnt = u.DEVICE endEnt = u.DEVICE @@ -920,9 +1075,11 @@ func getChildrenCollections(limit int, parentEntStr string) []int { // but no need to search further than group endEnt = u.GROUP } + for i := startEnt; i <= endEnt; i++ { rangeEntities = append(rangeEntities, i) } + if startEnt == u.ROOM && endEnt == u.RACK { // ROOM limit=1 special case should include extra // ROOM children but avoiding DEVICE (big collection) @@ -932,8 +1089,15 @@ func getChildrenCollections(limit int, parentEntStr string) []int { return rangeEntities } -func GetEntitiesUsingSiteAsAncestor(ent, id string, ancestry []map[string]string) ([]map[string]interface{}, string) { - top, e := GetEntity(bson.M{"name": id}, ent, u.RequestFilters{}) +func GetEntitiesUsingSiteAsAncestor(ent, id string, req map[string]interface{}, ancestry []map[string]string, userRoles map[string]Role) ([]map[string]interface{}, string) { + + newReq := req + if newReq == nil { + newReq = bson.M{"name": id} + } else { + newReq["name"] = id + } + top, e := GetEntity(newReq, ent, u.RequestFilters{}, nil) if e != "" { return nil, e } @@ -953,10 +1117,10 @@ func GetEntitiesUsingSiteAsAncestor(ent, id string, ancestry []map[string]string if v == "all" { println("K:", k) - return GetManyEntities(k, bson.M{"parentId": pid}, u.RequestFilters{}) + return GetManyEntities(k, bson.M{"parentId": pid}, u.RequestFilters{}, userRoles) } - x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}) + x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}, userRoles) if e1 != "" { println("Failing here") println("E1: ", e1) @@ -969,8 +1133,8 @@ func GetEntitiesUsingSiteAsAncestor(ent, id string, ancestry []map[string]string return nil, "" } -func GetEntityUsingSiteAsAncestor(ent, id string, ancestry []map[string]string) (map[string]interface{}, string) { - top, e := GetEntity(bson.M{"name": id}, ent, u.RequestFilters{}) +func GetEntityUsingSiteAsAncestor(req map[string]interface{}, ent string, ancestry []map[string]string) (map[string]interface{}, string) { + top, e := GetEntity(req, ent, u.RequestFilters{}, nil) if e != "" { return nil, e } @@ -984,7 +1148,7 @@ func GetEntityUsingSiteAsAncestor(ent, id string, ancestry []map[string]string) println("KEY:", k, " VAL:", v) - x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}) + x, e1 = GetEntity(bson.M{"parentId": pid, "name": v}, k, u.RequestFilters{}, nil) if e1 != "" { println("Failing here") return nil, "" @@ -996,27 +1160,42 @@ func GetEntityUsingSiteAsAncestor(ent, id string, ancestry []map[string]string) return x, "" } -func GetEntitiesOfAncestor(id interface{}, ent int, entStr, wantedEnt string) ([]map[string]interface{}, string) { +func GetEntitiesOfAncestor(id interface{}, req bson.M, ent int, entStr, wantedEnt string) ([]map[string]interface{}, string) { var ans []map[string]interface{} var t map[string]interface{} var e, e1 string + newReq := req if ent == u.SITE { - t, e = GetEntity(bson.M{"name": id}, "site", u.RequestFilters{}) + if newReq == nil { + newReq = bson.M{"name": id} + } else { + newReq["name"] = id + } + + t, e = GetEntity(newReq, "site", u.RequestFilters{}, nil) if e != "" { return nil, e } } else { ID, _ := primitive.ObjectIDFromHex(id.(string)) - t, e = GetEntity(bson.M{"_id": ID}, entStr, u.RequestFilters{}) + + //Apply the RBAC filter + if newReq == nil { + newReq = bson.M{"_id": ID} + } else { + newReq["_id"] = ID + } + + t, e = GetEntity(newReq, entStr, u.RequestFilters{}, nil) if e != "" { return nil, e } } sub, e1 := GetManyEntities(u.EntityToString(ent+1), - bson.M{"parentId": t["id"].(primitive.ObjectID).Hex()}, u.RequestFilters{}) + bson.M{"parentId": t["id"].(primitive.ObjectID).Hex()}, u.RequestFilters{}, nil) if e1 != "" { return nil, e1 } @@ -1027,7 +1206,7 @@ func GetEntitiesOfAncestor(id interface{}, ent int, entStr, wantedEnt string) ([ for i := range sub { x, _ := GetManyEntities(wantedEnt, - bson.M{"parentId": sub[i]["id"].(primitive.ObjectID).Hex()}, u.RequestFilters{}) + bson.M{"parentId": sub[i]["id"].(primitive.ObjectID).Hex()}, u.RequestFilters{}, nil) ans = append(ans, x...) } return ans, "" @@ -1035,10 +1214,8 @@ func GetEntitiesOfAncestor(id interface{}, ent int, entStr, wantedEnt string) ([ //DEV FAMILY FUNCS -func DeleteDeviceF(entityID primitive.ObjectID) (map[string]interface{}, string) { - //var deviceType string - - t, e := GetEntityHierarchy(entityID, "device", 0, 999, u.RequestFilters{}) +func DeleteDeviceF(entityID primitive.ObjectID, req bson.M) (map[string]interface{}, string) { + t, e := GetEntityHierarchy(entityID, req, "device", 0, 999, u.RequestFilters{}, nil) if e != "" { return u.Message(false, "There was an error in deleting the entity"), "not found" @@ -1048,7 +1225,6 @@ func DeleteDeviceF(entityID primitive.ObjectID) (map[string]interface{}, string) } func deleteDeviceHelper(t map[string]interface{}) (map[string]interface{}, string) { - println("entered ddH") if t != nil { if v, ok := t["children"]; ok { @@ -1077,7 +1253,7 @@ func deleteDeviceHelper(t map[string]interface{}) (map[string]interface{}, strin return nil, "" } -func ExtractCursor(c *mongo.Cursor, ctx context.Context) ([]map[string]interface{}, string) { +func ExtractCursor(c *mongo.Cursor, ctx context.Context, entity int, userRoles map[string]Role) ([]map[string]interface{}, string) { ans := []map[string]interface{}{} for c.Next(ctx) { x := map[string]interface{}{} @@ -1088,36 +1264,24 @@ func ExtractCursor(c *mongo.Cursor, ctx context.Context) ([]map[string]interface } //Remove _id x = fixID(x) - ans = append(ans, x) - } - return ans, "" -} - -// DEAD CODE -// Function will recursively iterate through nested obj -// and accumulate whatever is found into category arrays -func parseDataForNonStdResult(ent string, eNum, end int, data map[string]interface{}) map[string][]map[string]interface{} { - var nxt string - ans := map[string][]map[string]interface{}{} - add := data[u.EntityToString(eNum+1)+"s"].([]map[string]interface{}) - - //NEW REWRITE - for i := eNum; i+2 < end; i++ { - idx := u.EntityToString(i + 1) - //println("trying IDX: ", idx) - firstArr := add - - ans[idx+"s"] = firstArr - - for q := range firstArr { - nxt = u.EntityToString(i + 2) - println("NXT: ", nxt) - ans[nxt+"s"] = append(ans[nxt+"s"], - ans[idx+"s"][q][nxt+"s"].([]map[string]interface{})...) + if entity != u.BLDGTMPL && entity != u.ROOMTMPL && entity != u.OBJTMPL && userRoles != nil { + //Check permissions + var domain string + if entity == u.DOMAIN { + domain = x["hierarchyName"].(string) + } else { + domain = x["domain"].(string) + } + if permission := CheckUserPermissions(userRoles, entity, domain); permission >= READONLYNAME { + if permission == READONLYNAME { + x = FixReadOnlyName(x) + } + ans = append(ans, x) + } + } else { + ans = append(ans, x) } - add = ans[nxt+"s"] } - - return ans + return ans, "" } diff --git a/API/models/rbac.go b/API/models/rbac.go new file mode 100644 index 000000000..a9dde54ac --- /dev/null +++ b/API/models/rbac.go @@ -0,0 +1,132 @@ +package models + +import ( + u "p3/utils" + "regexp" + "strings" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Roles +type Role string + +const ( + Manager Role = "manager" + User Role = "user" + Viewer Role = "viewer" +) + +// Actions +type Permission int + +const ( + NONE Permission = iota + READONLYNAME + READ + WRITE +) + +const ROOT_DOMAIN = "*" + +func CheckParentDomain(parentDomain, childDomain string) bool { + match, _ := regexp.MatchString("^"+parentDomain, childDomain) + return match +} + +func CheckDomainExists(domain string) bool { + if domain == ROOT_DOMAIN { + return true + } + x, e := GetEntity(bson.M{"hierarchyName": domain}, "domain", u.RequestFilters{}, nil) + return e == "" && x != nil +} + +func GetRequestFilterByDomain(userRoles map[string]Role) (bson.M, bool) { + filter := bson.M{} + if userRoles[ROOT_DOMAIN] == Manager || userRoles[ROOT_DOMAIN] == User { + return filter, true + } + domainPattern := "" + for domain, role := range userRoles { + switch role { + case User: + case Manager: + if domainPattern == "" { + domainPattern = domain + } else { + domainPattern = domainPattern + "|" + domain + } + } + } + if domainPattern == "" { + return filter, false + } else { + filter["domain"] = primitive.Regex{Pattern: domainPattern, Options: ""} + return filter, true + } +} + +func CheckUserPermissions(userRoles map[string]Role, objEntity int, objDomain string) Permission { + permission := NONE + if objEntity == u.DOMAIN { + if userRoles[ROOT_DOMAIN] == Manager { + return WRITE + } + for userDomain, role := range userRoles { + if domainIsEqualOrChild(userDomain, objDomain) && role == Manager { + //objDomain is equal or child of userDomain + return WRITE + } + } + } else { + if userRoles[ROOT_DOMAIN] == User || userRoles[ROOT_DOMAIN] == Manager { + return WRITE + } else if userRoles[ROOT_DOMAIN] == Viewer { + return READ + } + + for userDomain, role := range userRoles { + if domainIsEqualOrChild(userDomain, objDomain) { + //objDomain is equal or child of userDomain + if role == Viewer { + permission = READ + } else { + permission = WRITE + break // highest possible + } + } else if domainIsEqualOrChild(objDomain, userDomain) { + // objDomain is father of userDomain + if permission < READONLYNAME { + permission = READONLYNAME + } + } + } + } + return permission +} + +func domainIsEqualOrChild(refDomain, domainToCheck string) bool { + match, _ := regexp.MatchString("^"+refDomain+"\\.", domainToCheck) + return match || refDomain == domainToCheck +} + +func CheckCanManageUser(callerRoles map[string]Role, newUserRoles map[string]Role) bool { + if callerRoles[ROOT_DOMAIN] != Manager { + for newUserDomain := range newUserRoles { + roleValidated := false + for callerDomain, callerRole := range callerRoles { + if callerRole == Manager && strings.Contains(newUserDomain, callerDomain) { + //newUserDomain is equal or child of callerDomain + roleValidated = true + break + } + } + if !roleValidated { + return false + } + } + } + return true +} diff --git a/API/models/sanitiseEntity.go b/API/models/sanitiseEntity.go index 71790e777..108fec169 100644 --- a/API/models/sanitiseEntity.go +++ b/API/models/sanitiseEntity.go @@ -20,7 +20,7 @@ func fixID(data map[string]interface{}) map[string]interface{} { // Removes underscore in object category if present func FixUnderScore(x map[string]interface{}) { if catInf, ok := x["category"]; ok { - if cat, _ := catInf.(string); strings.Contains(cat, "_") == true { + if cat, _ := catInf.(string); strings.Contains(cat, "_") { x["category"] = strings.Replace(cat, "_", "-", 1) } } @@ -31,8 +31,20 @@ func FixAttributesBeforeInsert(entity int, data map[string]interface{}) { if entity == u.RACK { pid, _ := primitive.ObjectIDFromHex(data["parentId"].(string)) req := bson.M{"_id": pid} - parent, _ := GetEntity(req, "room", u.RequestFilters{}) + parent, _ := GetEntity(req, "room", u.RequestFilters{}, nil) parentUnit := parent["attributes"].(map[string]interface{})["posXYUnit"] data["attributes"].(map[string]interface{})["posXYUnit"] = parentUnit } } + +func FixReadOnlyName(data map[string]interface{}) map[string]interface{} { + cleanData := map[string]interface{}{} + cleanData["id"] = data["id"] + cleanData["category"] = data["category"] + cleanData["name"] = data["name"] + cleanData["hierarchyName"] = data["hierarchyName"] + if _, ok := data["children"]; ok { + cleanData["children"] = data["children"] + } + return cleanData +} diff --git a/API/models/schemas/domain_schema.json b/API/models/schemas/domain_schema.json index f59d1a4b4..37d6beb3f 100644 --- a/API/models/schemas/domain_schema.json +++ b/API/models/schemas/domain_schema.json @@ -43,7 +43,6 @@ "required": [ "category", "description", - "domain", "name", "attributes" ], diff --git a/API/models/schemas/tenant_schema.json b/API/models/schemas/tenant_schema.json deleted file mode 100644 index f984fb5a0..000000000 --- a/API/models/schemas/tenant_schema.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "OGrEE Tenant Schema", - "type": "object", - "properties": { - "attributes": { - "type": "object", - "properties": { - "color": { - "type": "string", - "$ref": "refs/types.json#/definitions/color" - } - }, - "required": [ - "color" - ] - }, - "category": { - "type": "string" - }, - "createdDate": { - }, - "description": { - "type": "array", - "items": { - "type": "string" - } - }, - "domain": { - "type": "string" - }, - "id": { - "type": "string" - }, - "lastUpdated": { - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "attributes", - "category", - "description", - "domain", - "name" - ] - } \ No newline at end of file diff --git a/API/models/validateEntity.go b/API/models/validateEntity.go index 6b5973794..68cea190d 100644 --- a/API/models/validateEntity.go +++ b/API/models/validateEntity.go @@ -69,16 +69,18 @@ func validateParent(ent string, entNum int, t map[string]interface{}) (map[strin parent := map[string]interface{}{"parent": ""} switch entNum { case u.DEVICE: - x, _ := GetEntity(req, "rack", u.RequestFilters{}) + x, _ := GetEntity(req, "rack", u.RequestFilters{}, nil) if x != nil { parent["parent"] = "rack" + parent["domain"] = x["domain"] parent["hierarchyName"] = getHierarchyName(x) return parent, true } - y, _ := GetEntity(req, "device", u.RequestFilters{}) + y, _ := GetEntity(req, "device", u.RequestFilters{}, nil) if y != nil { parent["parent"] = "device" + parent["domain"] = y["domain"] parent["hierarchyName"] = getHierarchyName(y) return parent, true } @@ -87,30 +89,34 @@ func validateParent(ent string, entNum int, t map[string]interface{}) (map[strin "ParentID should be correspond to Existing ID"), false case u.SENSOR, u.GROUP: - w, _ := GetEntity(req, "device", u.RequestFilters{}) + w, _ := GetEntity(req, "device", u.RequestFilters{}, nil) if w != nil { parent["parent"] = "device" + parent["domain"] = w["domain"] parent["hierarchyName"] = getHierarchyName(w) return parent, true } - x, _ := GetEntity(req, "rack", u.RequestFilters{}) + x, _ := GetEntity(req, "rack", u.RequestFilters{}, nil) if x != nil { parent["parent"] = "rack" + parent["domain"] = x["domain"] parent["hierarchyName"] = getHierarchyName(x) return parent, true } - y, _ := GetEntity(req, "room", u.RequestFilters{}) + y, _ := GetEntity(req, "room", u.RequestFilters{}, nil) if y != nil { parent["parent"] = "room" + parent["domain"] = y["domain"] parent["hierarchyName"] = getHierarchyName(y) return parent, true } - z, _ := GetEntity(req, "building", u.RequestFilters{}) + z, _ := GetEntity(req, "building", u.RequestFilters{}, nil) if z != nil { parent["parent"] = "building" + parent["domain"] = z["domain"] parent["hierarchyName"] = getHierarchyName(z) return parent, true } @@ -123,9 +129,10 @@ func validateParent(ent string, entNum int, t map[string]interface{}) (map[strin if pid, ok := t["parentId"].(string); ok { ID, _ := primitive.ObjectIDFromHex(pid) - p, err := GetEntity(bson.M{"_id": ID}, "stray_device", u.RequestFilters{}) + p, err := GetEntity(bson.M{"_id": ID}, "stray_device", u.RequestFilters{}, nil) if len(p) > 0 { parent["parent"] = "stray_device" + parent["domain"] = p["domain"] parent["hierarchyName"] = getHierarchyName(p) return parent, true } else if err != "" { @@ -142,16 +149,17 @@ func validateParent(ent string, entNum int, t map[string]interface{}) (map[strin parentInt := u.GetParentOfEntityByInt(entNum) parentStr := u.EntityToString(parentInt) - p, err := GetEntity(req, parentStr, u.RequestFilters{}) + p, err := GetEntity(req, parentStr, u.RequestFilters{}, nil) if len(p) > 0 { parent["parent"] = parentStr + parent["domain"] = p["domain"] parent["hierarchyName"] = getHierarchyName(p) return parent, true } else if err != "" { println("ENTITY VALUE: ", ent) println("We got Parent: ", parent, " with ID:", t["parentId"].(string)) return u.Message(false, - "ParentID should correspond to Existing ID"), false + "ParentID should correspond to Existing ID: "+err), false } } return nil, true @@ -165,12 +173,6 @@ func getHierarchyName(parent map[string]interface{}) string { } } -func validateDomain(domainName string) bool { - req := bson.M{"hierarchyName": domainName} - _, err := GetEntity(req, "domain", u.RequestFilters{}) - return err == "" -} - func validateJsonSchema(entity int, t map[string]interface{}) (map[string]interface{}, bool) { // Get JSON schema var schemaName string @@ -244,9 +246,14 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ } //Check domain if entity != u.DOMAIN { - if !validateDomain(t["domain"].(string)) { + if !CheckDomainExists(t["domain"].(string)) { return u.Message(false, "Domain not found: "+t["domain"].(string)), false } + if parentDomain, ok := parent["domain"].(string); ok { + if !CheckParentDomain(parentDomain, t["domain"].(string)) { + return u.Message(false, "Object domain is not equal or child of parent's domain"), false + } + } } } @@ -262,7 +269,8 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ case u.RACK: //Ensure the name is also unique among corridors req := bson.M{"name": t["name"].(string)} - nameCheck, _ := GetManyEntities("corridor", req, u.RequestFilters{}) + req["domain"] = t["domain"].(string) + nameCheck, _ := GetManyEntities("corridor", req, u.RequestFilters{}, nil) if nameCheck != nil { if len(nameCheck) != 0 { msg := "Rack name must be unique among corridors and racks" @@ -289,7 +297,8 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ //Ensure the name is also unique among racks req := bson.M{"name": t["name"].(string)} - nameCheck, _ := GetManyEntities("rack", req, u.RequestFilters{}) + req["domain"] = t["domain"].(string) + nameCheck, _ := GetManyEntities("rack", req, u.RequestFilters{}, nil) if nameCheck != nil { if len(nameCheck) != 0 { msg := "Corridor name must be unique among corridors and racks" @@ -303,7 +312,7 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ orReq := bson.A{bson.D{{"name", racks[0]}}, bson.D{{"name", racks[1]}}} filter = bson.M{"parentId": t["parentId"], "$or": orReq} - ans, e := GetManyEntities("rack", filter, u.RequestFilters{}) + ans, e := GetManyEntities("rack", filter, u.RequestFilters{}, nil) if e != "" { msg := "The racks you specified were not found." + " Please verify your input and try again" @@ -364,7 +373,7 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ //If parent is rack, retrieve devices if parent["parent"].(string) == "rack" { - ans, ok := GetManyEntities("device", filter, u.RequestFilters{}) + ans, ok := GetManyEntities("device", filter, u.RequestFilters{}, nil) if ok != "" { return u.Message(false, ok), false } @@ -377,12 +386,12 @@ func ValidateEntity(entity int, t map[string]interface{}) (map[string]interface{ } else if parent["parent"].(string) == "room" { //If parent is room, retrieve corridors and racks - corridors, e1 := GetManyEntities("corridor", filter, u.RequestFilters{}) + corridors, e1 := GetManyEntities("corridor", filter, u.RequestFilters{}, nil) if e1 != "" { return u.Message(false, e1), false } - racks, e2 := GetManyEntities("rack", filter, u.RequestFilters{}) + racks, e2 := GetManyEntities("rack", filter, u.RequestFilters{}, nil) if e2 != "" { return u.Message(false, e1), false } diff --git a/API/models/web_projects.go b/API/models/web_projects.go new file mode 100644 index 000000000..7fe3eb28d --- /dev/null +++ b/API/models/web_projects.go @@ -0,0 +1,182 @@ +package models + +import ( + "context" + "fmt" + u "p3/utils" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +const WEB_PROJECTS = "web_project" + +// Project represents data about a recorded web project +type Project struct { + Id string `bson:"_id,omitempty"` + Name string `json:"name" binding:"required"` + DateRange string `json:"dateRange" binding:"required"` + Namespace string `json:"namespace" binding:"required"` + Attributes []string `json:"attributes" binding:"required"` + Objects []string `json:"objects" binding:"required"` + Permissions []string `json:"permissions" binding:"required,dive,email"` + Author string `json:"authorLastUpdate" binding:"required"` + LastUpdate string `json:"lastUpdate" binding:"required"` + ShowAvg bool `json:"showAvg"` + ShowSum bool `json:"showSum"` + IsPublic bool `json:"isPublic"` +} + +// PROJECTS +// GET +func GetProjectsByUserEmail(userEmail string) (map[string]interface{}, string) { + response := make(map[string]interface{}) + response["projects"] = make([]interface{}, 0) + println("Get projects for " + userEmail) + + // Get projects with user permitted + var results []Project + filter := bson.D{ + {Key: "$or", + Value: bson.A{ + bson.D{{Key: "permissions", Value: userEmail}}, + bson.D{{Key: "isPublic", Value: true}}, + }, + }, + } + ctx, cancel := u.Connect() + cursor, err := GetDB().Collection(WEB_PROJECTS).Find(ctx, filter) + if err != nil { + fmt.Println(err) + } else { + if err = cursor.All(ctx, &results); err != nil { + fmt.Println(err) + } else if len(results) > 0 { + response["projects"] = results + } + } + + defer cancel() + + return response, "" +} + +// POST +func AddProject(newProject Project) string { + // Add the new project + ctx, cancel := u.Connect() + _, err := GetDB().Collection(WEB_PROJECTS).InsertOne(ctx, newProject) + if err != nil { + println(err.Error()) + return err.Error() + } + + defer cancel() + return "" +} + +// PUT +func UpdateProject(newProject Project, projectId string) string { + // Update existing project, if exists + ctx, cancel := u.Connect() + objId, _ := primitive.ObjectIDFromHex(projectId) + res, err := GetDB().Collection(WEB_PROJECTS).UpdateOne(ctx, + bson.M{"_id": objId}, bson.M{"$set": newProject}) + defer cancel() + + if err != nil { + return err.Error() + } + if res.MatchedCount <= 0 { + return "No project found with this ID" + } + return "" +} + +// DELETE +func DeleteProject(projectId string) string { + println(projectId) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + objId, _ := primitive.ObjectIDFromHex(projectId) + res, err := GetDB().Collection(WEB_PROJECTS).DeleteOne(ctx, bson.M{"_id": objId}) + defer cancel() + + if err != nil { + return err.Error() + } else if res.DeletedCount <= 0 { + return "Project not found" + } + return "" +} + +// IF USERS HAVE LIST OF PROJECTS +// CURRENTLY NOT USED +func getProjectsFromUser() { + data := map[string]interface{}{} + response := make(map[string]interface{}) + response["projects"] = make([]interface{}, 0) + // Get query params + userId := "test" //c.Query("userid") + println("Get projects for " + userId) + + // Get user project ids + objId, err := primitive.ObjectIDFromHex(userId) + if err != nil { + // c.JSON(http.StatusBadRequest, "Invalid user ID format: "+err.Error()) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err = GetDB().Collection("account").FindOne(ctx, bson.M{"_id": objId}).Decode(&data) + if err != nil { + // c.JSON(http.StatusBadRequest, "Unable to find user: "+err.Error()) + } else { + println(data["web_projects"]) + projects, _ := data["web_projects"].(primitive.A) + projectIds := []interface{}(projects) + if len(projectIds) > 0 { + // Convert IDs to good format + println(projectIds) + var objIds []primitive.ObjectID + for _, id := range projectIds { + println("ID " + id.(string)) + objId, err := primitive.ObjectIDFromHex(id.(string)) + println(err != nil) + if err == nil { + objIds = append(objIds, objId) + } + } + // Get projects + println(objIds) + var results []Project + cursor, err := GetDB().Collection("web_project").Find(ctx, bson.M{"_id": bson.M{"$in": objIds}}) + if err != nil { + fmt.Println(err) + } else { + if err = cursor.All(ctx, &results); err != nil { + fmt.Println(err) + } else { + response["projects"] = results + } + } + } + } + + defer cancel() + + // c.IndentedJSON(http.StatusOK, response) + + // FOR ADD + // Add project id to users with permissions + // addedPermissions := []string{} + // for _, userEmail := range newProject.Permissions { + // println(userEmail) + // res, err := m.GetDB().Collection("account").UpdateOne(ctx, + // bson.M{"email": userEmail}, bson.M{"$push": bson.M{"web_projects": result.InsertedID.(primitive.ObjectID).Hex()}}) + // if err == nil && res.MatchedCount > 0 { + // addedPermissions = append(addedPermissions, userEmail) + // } + // } + // newProject.Permissions = addedPermissions +} diff --git a/API/swagger.json b/API/swagger.json index 52f5d64de..0ecb62cc7 100644 --- a/API/swagger.json +++ b/API/swagger.json @@ -10,27 +10,41 @@ ], "swagger": "2.0", "info": { - "description": "Testing Swagger spec. It's still in progress\n\nThe API may return incorrect response codes and have bugs.\n\nFor a list of endpoints please consult: https://nextcloud.ditrit.io/index.php/apps/files/?dir=/Ogree/1_Core/1_API/Endpoint_List\u0026openfile=20692", - "title": "Golang OGREE API", + "description": "This the swagger documentation for the API of the OGrEE project developed by DitRit.\nCheck our project here: https://github.com/ditrit/OGrEE-Core", + "title": "OGrEE API", "contact": { - "name": "Ziad Khalaf", - "email": "ziad.khalaf@orness.com" + "name": "DitRit", + "url": "https://ditrit.io", + "email": "contact@ditrit.io" }, "version": "1.0" }, - "basePath": "/api/user", + "basePath": "/api", "paths": { - "/api": { + "/api/hierarchy": { + "get": { + "description": "Return is arranged by relationship (father:[children])\nand category (category:[objects])", + "produces": [ + "application/json" + ], + "tags": [ + "objects" + ], + "summary": "Returns all objects hierarchyName.", + "operationId": "GetCompleteHierarchy" + } + }, + "/api/login": { "post": { - "description": "Create an account with Email credentials, it returns\na JWT key to use with the API. The\nauthorize and 'Try it out' buttons don't work", + "description": "Create a new JWT Key. This can also be used to verify credentials\nThe authorize and 'Try it out' buttons don't work", "produces": [ "application/json" ], "tags": [ "auth" ], - "summary": "Generate credentials for a user.", - "operationId": "Create", + "summary": "Generates a new JWT Key for the client.", + "operationId": "Authenticate", "parameters": [ { "type": "string", @@ -47,14 +61,6 @@ "name": "password", "in": "json", "required": true - }, - { - "format": "string", - "default": "ORNESS", - "description": "Name of the the customer", - "name": "customer", - "in": "json", - "required": true } ], "responses": { @@ -77,64 +83,44 @@ "auth" ], "summary": "Displays possible operations for the resource in response header.", - "operationId": "CreateOptions", - "responses": { - "200": { - "description": "Returns header with possible operations" - } - } + "operationId": "CreateOptions" } }, - "/api/login": { - "post": { - "description": "Create a new JWT Key. This can also be used to verify credentials\nThe authorize and 'Try it out' buttons don't work", + "/api/objects/{name}": { + "get": { + "description": "The hierarchyName must be provided in the URL parameter", "produces": [ "application/json" ], "tags": [ - "auth" + "objects" ], - "summary": "Generates a new JWT Key for the client.", - "operationId": "Authenticate", + "summary": "Gets an Object from the system.", + "operationId": "GetObjectByName", "parameters": [ { - "type": "string", - "default": "infiniti@nissan.com", - "description": "Your Email Address", - "name": "username", - "in": "body", - "required": true - }, - { - "format": "password", - "default": "secret", - "description": "Your password", - "name": "password", - "in": "json", - "required": true - } - ], - "responses": { - "200": { - "description": "Authenticated" - }, - "400": { - "description": "Bad request" - }, - "500": { - "description": "Internal server error" + "description": "hierarchyName of the object", + "name": "name", + "in": "query" } - } + ] }, "options": { "produces": [ "application/json" ], "tags": [ - "auth" + "objects" ], "summary": "Displays possible operations for the resource in response header.", - "operationId": "CreateOptions" + "operationId": "ObjectOptions", + "parameters": [ + { + "description": "hierarchyName of the object", + "name": "name", + "in": "query" + } + ] } }, "/api/stats": { @@ -187,7 +173,7 @@ "operationId": "GetTempUnit", "parameters": [ { - "description": "ID of any object.", + "description": "ID or hierarchyName of any object.", "name": "id", "in": "query", "required": true @@ -228,6 +214,221 @@ "operationId": "VerifyToken" } }, + "/api/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get a list of users that the caller is allowed to see.", + "operationId": "GetAllAccounts" + }, + "post": { + "description": "Create an account with email credentials, it returns\na JWT key to use with the API.", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Generate credentials for a user.", + "operationId": "Create", + "parameters": [ + { + "type": "string", + "default": "John Doe", + "description": "User name", + "name": "name", + "in": "json" + }, + { + "type": "string", + "default": "user@email.com", + "description": "User Email Address", + "name": "email", + "in": "json", + "required": true + }, + { + "format": "password", + "default": "secret123", + "description": "User password", + "name": "password", + "in": "json", + "required": true + } + ], + "responses": { + "201": { + "description": "Authenticated and new account created" + }, + "400": { + "description": "Bad request" + }, + "403": { + "description": "User not authorised to create an account" + }, + "500": { + "description": "Internal server error" + } + } + }, + "options": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Displays possible operations for the resource in response header.", + "operationId": "CreateOptions", + "responses": { + "200": { + "description": "Returns header with possible operations" + } + } + } + }, + "/api/users/bulk": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Create multiples users with one request.", + "operationId": "CreateBulk", + "parameters": [ + { + "type": "string", + "default": "John Doe", + "description": "User name", + "name": "name", + "in": "json" + }, + { + "type": "string", + "default": "user@email.com", + "description": "User Email Address", + "name": "email", + "in": "body", + "required": true + }, + { + "format": "password", + "default": "secret123", + "description": "User password", + "name": "password", + "in": "json", + "required": true + } + ] + } + }, + "/api/users/password/change": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "For logged in user to change own password.", + "operationId": "ModifyUserPassword", + "parameters": [ + { + "type": "string", + "description": "User current password", + "name": "currentPassword", + "in": "body", + "required": true + }, + { + "type": "string", + "description": "User new desired password", + "name": "newPassword", + "in": "body", + "required": true + } + ] + } + }, + "/api/users/password/forgot": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "To request a reset of a user's password (forgot my password).", + "operationId": "UserForgotPassword", + "parameters": [ + { + "type": "string", + "description": "User email", + "name": "email", + "in": "body", + "required": true + } + ] + } + }, + "/api/users/password/reset": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "To change password of user that forgot password and received a reset token by email.", + "operationId": "ModifyUserPassword", + "parameters": [ + { + "type": "string", + "description": "User new desired password", + "name": "newPassword", + "in": "body", + "required": true + } + ] + } + }, + "/api/users/{id}": { + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Remove the specified user account.", + "operationId": "RemoveAccount" + }, + "patch": { + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Modify user permissions: domain and role.", + "operationId": "ModifyUserRoles", + "parameters": [ + { + "type": "json", + "description": "An object with domains as keys and roles as values", + "name": "roles", + "in": "body", + "required": true + } + ] + } + }, "/api/validate/{obj}": { "post": { "produces": [ @@ -317,7 +518,7 @@ "operationId": "ValidateObjectOptions", "parameters": [ { - "description": "Only values of \"domains\", \"sites\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\",\"bldg-templates\", \"rooms\", \"acs\", \"panels\", \"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\" \"stray-sensors\" are acceptable", + "description": "Only values of \"domains\", \"sites\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\", \"bldg-templates\",\"rooms\", \"acs\", \"panels\", \"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\" \"stray-sensors\" are acceptable", "name": "obj", "in": "query" } @@ -434,7 +635,7 @@ "operationId": "ObjectOptions", "parameters": [ { - "description": "Only values of \"sites\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\",\"bldg-templates\", \"rooms\", \"acs\", \"panels\", \"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\" \"stray-sensors\" are acceptable", + "description": "Only values of \"sites\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\", \"bldg-templates\",\"rooms\", \"acs\", \"panels\", \"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\" \"stray-sensors\" are acceptable", "name": "objs", "in": "query" } @@ -451,7 +652,7 @@ "objects" ], "summary": "Gets an Object from the system.", - "operationId": "GetObject", + "operationId": "GetObjectById", "parameters": [ { "type": "string", @@ -594,7 +795,7 @@ "operationId": "ObjectOptions", "parameters": [ { - "description": "Only values of \"sites\",\"domains\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\",\"bldg-templates\", \"acs\", \"panels\",\"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\",\"stray-sensors\", are acceptable", + "description": "Only values of \"sites\",\"domains\", \"buildings\", \"rooms\", \"racks\", \"devices\", \"room-templates\", \"obj-templates\", \"bldg-templates\",\"acs\", \"panels\",\"cabinets\", \"groups\", \"corridors\",\"sensors\",\"stray-devices\",\"stray-sensors\", are acceptable", "name": "objs", "in": "query" }, @@ -689,7 +890,7 @@ "objects" ], "summary": "A category of objects of a Parent Object can be retrieved from the system.", - "operationId": "GetFromObect", + "operationId": "GetFromObject", "parameters": [ { "type": "string", @@ -833,7 +1034,8 @@ "tags": [ "objects" ], - "operationId": "GetObject", + "summary": "Gets an Object using any attribute.", + "operationId": "GetObjectQuery", "parameters": [ { "type": "string", diff --git a/API/utils/email.go b/API/utils/email.go new file mode 100644 index 000000000..818bc7afd --- /dev/null +++ b/API/utils/email.go @@ -0,0 +1,47 @@ +package utils + +import ( + "fmt" + "net/smtp" + "os" +) + +func SendEmail(token string, email string) string { + // Sender data. + from := os.Getenv("email_account") + password := os.Getenv("email_password") + if from == "" || password == "" { + return "Unable to send reset email: sender credentials not provided" + } + + // Receiver email address. + to := []string{ + email, + } + + // smtp server configuration. + smtpHost := "smtp.gmail.com" + smtpPort := "587" + + // Message. + var message []byte + reset_url := os.Getenv("reset_url") + if reset_url == "" { + message = []byte("Hello! Use the reset token below to change your OGrEE password:\r\n" + + token + "\r\n") + } else { + message = []byte("Hello! Use the link below to reset your OGrEE password.\r\n" + + reset_url + token + "\r\n") + } + + // Authentication. + auth := smtp.PlainAuth("", from, password, smtpHost) + + // Sending email. + err := smtp.SendMail(smtpHost+":"+smtpPort, auth, from, to, message) + if err != nil { + return err.Error() + } + fmt.Println("Email Sent Successfully!") + return "" +} diff --git a/API/utils/util.go b/API/utils/util.go index f068df55d..71de0e884 100644 --- a/API/utils/util.go +++ b/API/utils/util.go @@ -39,7 +39,8 @@ const ( STRAYSENSOR ) -const HN_DELIMETER = "." // hierarchyName path delimiter +const HN_DELIMETER = "." // hierarchyName path delimiter +const RESET_TAG = "RESET" // used as email to identify a reset token type RequestFilters struct { FieldsToShow []string `schema:"fieldOnly"` diff --git a/APP/Dockerfile b/APP/Dockerfile index 055d46483..1e0f5ef6d 100644 --- a/APP/Dockerfile +++ b/APP/Dockerfile @@ -1,8 +1,5 @@ # Install OS and dependencies to build frontend FROM ubuntu:20.04 AS build -ARG API_URL -ARG ALLOW_SET_BACK -ARG BACK_URLS ENV GIN_MODE=release ENV TZ=Europe/Paris \ DEBIAN_FRONTEND=noninteractive @@ -22,10 +19,10 @@ ENV PATH="/usr/local/flutter/bin:/usr/local/flutter/bin/cache/dart-sdk/bin:${PAT RUN flutter doctor # Copy files to container and build -COPY ogree_app/ /app/ +COPY APP/ogree_app/ /app/ WORKDIR /app/ RUN flutter pub get -RUN flutter build web --dart-define=API_URL=$API_URL --dart-define=ALLOW_SET_BACK=$ALLOW_SET_BACK --dart-define=BACK_URLS=$BACK_URLS +RUN flutter build web # Runtime image FROM nginx:1.21.1-alpine diff --git a/APP/README.md b/APP/README.md index ad59ce5ef..cee3e7852 100644 --- a/APP/README.md +++ b/APP/README.md @@ -1,59 +1,62 @@ # OGrEE-APP A Flutter application for OGrEE. It includes a frontend (ogree_app) mainly compiled as a web app and a backend (ogree_app_backend) only used for Super Admin mode. The flutter app can interact directly with OGrEE-API. -## Getting Starded: Frontend +## Frontend ```console cd ogree_app ``` -With Flutter, we can use the same base code to build applications for different platforms (web, Android, iOS, Windows, Linux, MacOS). To understand how it works and set up your environment, check out the [Flutter](https://docs.flutter.dev/get-started/install) documentation. +With Flutter, we can use the same base code to build applications for different platforms (web, Android, iOS, Windows, Linux, MacOS). To understand how it works and set up your environment, check out the [Flutter](https://docs.flutter.dev/get-started/install) documentation. For docker deployment, we build and run it as a web app. -For development, you should install the Flutter SDK and all its dependencies (depending on your OS). We recommed you use VSCode with the Flutter and Dart extensions. +### Building and running with Docker +Our dockerfile is multi-stage: the first image install flutter and its dependencies, then compiles the web app; the second image is nginx based and runs the web server for the previously compiled app. -### Run web app on debug mode -Only Google Chrome can run a Flutter web app on debug mode. If the `flutter doctor` command gives you a green pass, other than directly on VSCode, you can also compile and run the web app in the terminal. To configure the list of possible backend URLs to which the frontend can connect (displayed as a dropdown menu in the login page), you can pass it as a environment variable: +From the root of OGrEE-Core, run the following to build the Docker image: ```console -flutter run -d chrome --dart-define=BACK_URLS=http://localhost:5551,https://banana.com --dart-define=ALLOW_SET_BACK=true +OGrEE-Core$ docker build -f .\APP\Dockerfile . -t ogree-app ``` -To use the frontend with just one backend, run: +To run a container with the built image and expose the app in the local port 8080: ```console -flutter run -d chrome --dart-define=API_URL=http://localhost:5551 +docker run -p 8080:80 -d ogree-app:latest ``` -## Building and running with Docker -Our dockerfile is multi-stage: the first image install flutter and its dependencies, then compiles the web app; the second image is nginx based and runs the web server for the previously compiled app. +If all goes well, you should be able to acess the OGrEE Web App on http://localhost:8080. -To build the Docker image to use the frontend for tenants (Super Admin), run in the root of this project: -```console -docker build . -t ogree-app --build-arg BACK_URLS=http://localhost:5551,https://banana.com --build-arg ALLOW_SET_BACK=true +### Pick which OGrEE-API to connect +You can configure to which API you wish to connect. This is set by a `.env` file located under `ogree_app/assets/custom` that should contain the following definitions: +``` +API_URL=http://localhost:3001 +ALLOW_SET_BACK=false +BACK_URLS=http://localhost:3001,http://localhost:8082 ``` -If not, to build in normal mode, set the API URL: -```console -docker build . -t ogree-app --build-arg API_URL=http://localhost:5551 +- If `ALLOW_SET_BACK=true`, the App will display a dropdown menu in the login page allowing the user to type the URL of the API to connect. `BACK_URLS` will be the selectable hints displayed when the use click on the dropdown menu, serving as shortcuts. +- If `ALLOW_SET_BACK=false`, the App will only connect to the given `API_URL` and not give the user any choice. Instead of the dropdown menu, a logo will be displayed, the image file at: `ogree_app/assets/custom/logo.png`. +### Pick which OGrEE-API under docker +The easiest way to edit the `.env` file in a docker container is to mount your local folder containing the file and a logo image (not mandatory) as a volume when running it: ``` - -To run a container with the built image: -```console -docker run -p 8080:80 -d ogree-app:latest +docker run -p 8080:80 -v [your/custom/folder]:/usr/share/nginx/html/assets/assets/custom -d ogree-app:latest ``` -If all goes well, you should be able to acess the OGrEE Web App on http://localhost:8080. +### Frontend SuperAdmin mode +Instead of interacting directly with a OGrEE-API, the App can connect to the backend avaible in this same repository to enter SuperAdmin mode. In this mode, instead of creating projects to consult an OGrEE-API database, you can create new Tenants, that is, to launch new OGrEE deployments (new OGrEE-APIs). All you have to do is connect your App to the URL of an `ogree_app_backend`. -## Getting Started: Backend +## Backend ```console cd ogree_app_backend ``` -This is a backend that connects to a local instance of docker to create new tenants. A new tenant consists of a docker compose deployment of 5 containers: API, DB, CLI, WebApp and Swagger Doc. Once the frontend connects to this backend, it changes its interface to tenant mode. +This is a backend that connects to a local instance of docker to create new tenants. A new tenant consists of a docker compose deployment of up to 4 containers: API, DB, WebApp and Swagger Doc. Once the frontend connects to this backend, it changes its interface to SuperAdmin mode. ### Building and running -You should have Go installed We are currently using at least the 1.19.3 version. In the backend directory, run the following to install dependecies: +Since the backend connects to docker to launch containers, it has to be run **locally**. To build it, you should have Go installed (version >= 1.20). To run it, first docker should be up and running. + +In the backend directory, run the following to install dependecies: ```console go mod download ``` -It is mandatory to have the deploy folder of OGrEE-Core to properly run the backend and also a .env file which should include: +It is mandatory to have the `deploy` folder of OGrEE-Core to properly run the backend and also a .env file under `ogree_app_backend/` which should include: ``` TOKEN_SECRET=yoursecretstring TOKEN_HOUR_LIFESPAN=1 @@ -61,7 +64,10 @@ ADM_PASSWORD=adminHashedPassword DEPLOY_DIR = ../../deploy ``` -Only one user (admin) can login to the superadmin backend with the password that should be added hashed to the .env file. If DEPLOY_DIR is omitted, the default as given in the example will be set. +Only one user (admin) can login to the superadmin backend with the password that should be added *hashed* to the .env file. If DEPLOY_DIR is omitted, the default as given in the example will be set. Example of hashed password that translates to `Ogree@148`: +``` +ADM_PASSWORD="\$2a\$10\$YlOHvFzIBKzfgSxLLQkT0.7PeMsMGv/LhlL0FzDS63XKIZCCDRvim" +``` Then, to compile and run: ```console @@ -69,9 +75,9 @@ go build -o ogree_app_backend ./ogree_app_backend ``` -Or run directly: -```console -go run . +To choose in what port the backend should run (default port is 8082): +``` +./ogree_app_backend -port 8083 ``` To cross compile: @@ -79,7 +85,7 @@ To cross compile: # Linux 64-bit GOOS=linux GOARCH=amd64 go build -o ogree_app_backend_linux # Windows 64-bit -GOOS=windows GOARCH=amd64 go build -o ogree_app_backend_linux +GOOS=windows GOARCH=amd64 go build -o ogree_app_backend_win # MacOS 64-bit -GOOS=darwin GOARCH=amd64 go build -o ogree_app_backend_linux +GOOS=darwin GOARCH=amd64 go build -o ogree_app_backend_mac ``` \ No newline at end of file diff --git a/APP/ogree_app/README.md b/APP/ogree_app/README.md index f2b51c07a..1448dcc2b 100644 --- a/APP/ogree_app/README.md +++ b/APP/ogree_app/README.md @@ -1,20 +1,15 @@ # Flutter OGrEE-APP -An application that connects to an OGrEE-API and lets the user visualize and create reports of the complete hierarchy of objects (sites, buildings, rooms, racks, devices, etc.) and all their attributes. +An application that connects to an OGrEE-API and lets the user visualize and create reports of the complete hierarchy of objects (sites, buildings, rooms, racks, devices, etc.) and all their attributes. The app can also connect to an ogree_app_backend, entering SuperAdmin mode, where it can be used to launch new docker deployments of OGrEE. -Flutter allows us to compile the application to multiple target platforms. This one has been tested Web, Windows and Linux app. Check out the [flutter docs](https://docs.flutter.dev/get-started/install) to understand how it works and install it. +Flutter allows us to compile the application to multiple target platforms. This one has been tested Web, Windows and Linux app. Check out the [flutter docs](https://docs.flutter.dev/get-started/install) to understand how it works and install it. For development, you should install the Flutter SDK and all its dependencies (depending on your OS). We recommed you use VSCode with the Flutter and Dart extensions. ## Build and run the application -Before running it, create a **.env** file in this directory with the URL of the target OGrEE-API: -```console -API_URL=[URL of OGrEE-API] -``` - -Executing `flutter run` in this directory will compile and run the app in debug mode on the platform locally available. If more than one available, it gives you a list of possible devices to choose from (web or windows, for example). +Executing `flutter run` in this directory will compile and run the app in debug mode on the platform locally available. If more than one available, it gives you a list of possible devices to choose from (web or windows, for example). Note that only Google Chrome can run a Flutter web app on debug mode To compile for production, use `flutter build` followed by the target platform (`web`, for example). The result will be under /build. -## Understanding the app and the code +## Understanding the app and the code (regular mode) In Flutter, everything is a widget (visual componentes, services, models, all of it!). It all starts with `main` that calls for the creation of a `MaterialApp` widget that has a theme definition applied for the whole app (for example: default font size for titles, default button colors) and calls the home widget `LoginPage`. diff --git a/APP/ogree_app/assets/custom/.env b/APP/ogree_app/assets/custom/.env new file mode 100644 index 000000000..9d97f910e --- /dev/null +++ b/APP/ogree_app/assets/custom/.env @@ -0,0 +1,3 @@ +API_URL=http://localhost:3001 +ALLOW_SET_BACK=true +BACK_URLS=http://localhost:8082,http://localhost:3001 diff --git a/APP/ogree_app/assets/custom/logo.png b/APP/ogree_app/assets/custom/logo.png new file mode 100644 index 000000000..2ef47d53a Binary files /dev/null and b/APP/ogree_app/assets/custom/logo.png differ diff --git a/APP/ogree_app/assets/edf_logo.png b/APP/ogree_app/assets/edf_logo.png deleted file mode 100644 index e812db24e..000000000 Binary files a/APP/ogree_app/assets/edf_logo.png and /dev/null differ diff --git a/APP/ogree_app/lib/common/api_backend.dart b/APP/ogree_app/lib/common/api_backend.dart index bc2731c68..15d04e5d7 100644 --- a/APP/ogree_app/lib/common/api_backend.dart +++ b/APP/ogree_app/lib/common/api_backend.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http/http.dart' as http; import 'package:ogree_app/models/domain.dart'; import 'package:ogree_app/models/project.dart'; @@ -11,10 +13,6 @@ part 'api_tenant.dart'; String apiUrl = ""; String tenantUrl = ""; -const String apiUrlEnvSet = String.fromEnvironment( - 'API_URL', - defaultValue: 'http://localhost:3001', -); var token = ""; var tenantToken = ""; getHeader(token) => { @@ -28,7 +26,7 @@ Future> loginAPI(String email, String password, if (userUrl != "") { apiUrl = userUrl; } else { - apiUrl = apiUrlEnvSet; + apiUrl = dotenv.get('API_URL', fallback: 'http://localhost:3001'); } print("API login ogree $apiUrl"); Uri url = Uri.parse('$apiUrl/api/login'); @@ -45,6 +43,75 @@ Future> loginAPI(String email, String password, } } +Future changeUserPassword(String currentPassword, newPassword) async { + print("API change password"); + Uri url = Uri.parse('$apiUrl/api/users/password/change'); + final response = await http.post(url, + body: json.encode({ + 'currentPassword': currentPassword, + 'newPassword': newPassword + }), + headers: getHeader(token)); + print(response); + if (response.statusCode == 200) { + Map data = json.decode(response.body); + token = data["token"]!; + print(token); + return ""; + } else { + Map data = json.decode(response.body); + return "Error: ${data["message"]}"; + } +} + +Future userForgotPassword(String email, {String userUrl = ""}) async { + print("API forgot password"); + if (userUrl != "") { + apiUrl = userUrl; + } else { + apiUrl = dotenv.get('API_URL', fallback: 'http://localhost:3001'); + } + Uri url = Uri.parse('$apiUrl/api/users/password/forgot'); + final response = await http.post( + url, + body: json.encode({'email': email}), + ); + print(response.body); + if (response.statusCode == 200) { + Map data = json.decode(response.body); + print(data); + return ""; + } else { + Map data = json.decode(response.body); + return "Error: ${data["message"]}"; + } +} + +Future userResetPassword(String password, String resetToken, + {String userUrl = ""}) async { + print("API reset password"); + if (userUrl != "") { + apiUrl = userUrl; + } else { + apiUrl = dotenv.get('API_URL', fallback: 'http://localhost:3001'); + } + Uri url = Uri.parse('$apiUrl/api/users/password/reset'); + final response = await http.post( + url, + body: json.encode({'newPassword': password}), + headers: getHeader(resetToken), + ); + print(response.body); + if (response.statusCode == 200) { + Map data = json.decode(response.body); + print(data); + return ""; + } else { + Map data = json.decode(response.body); + return "Error: ${data["message"]}"; + } +} + Future>>> fetchObjectsTree( {onlyDomain = false}) async { print("API get tree"); @@ -211,7 +278,21 @@ Future createTenant(Tenant tenant) async { } } -Future createBackendServer(Map newBackend) async { +Future uploadImage(PlatformFile image, String tenant) async { + print("API upload Tenant logo"); + Uri url = Uri.parse('$apiUrl/api/tenants/$tenant/logo'); + var request = http.MultipartRequest("POST", url); + request.headers.addAll(getHeader(token)); + request.files.add( + http.MultipartFile.fromBytes("file", image.bytes!, filename: image.name)); + + var response = await request.send(); + print(response.statusCode); + var body = await response.stream.bytesToString(); + return body; +} + +Future createBackendServer(Map newBackend) async { print("API create Back Server"); Uri url = Uri.parse('$apiUrl/api/servers'); final response = await http.post(url, diff --git a/APP/ogree_app/lib/common/appbar.dart b/APP/ogree_app/lib/common/appbar.dart index b46083c4a..f8bd93da6 100644 --- a/APP/ogree_app/lib/common/appbar.dart +++ b/APP/ogree_app/lib/common/appbar.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:ogree_app/common/api_backend.dart'; import 'package:ogree_app/common/popup_dialog.dart'; import 'package:ogree_app/pages/login_page.dart'; import 'package:ogree_app/pages/projects_page.dart'; +import 'package:ogree_app/widgets/change_password_popup.dart'; import 'package:ogree_app/widgets/language_toggle.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -49,20 +49,26 @@ AppBar myAppBar(context, userEmail, {isTenantMode = false}) { onSelected: (value) { if (value == "logout") { logout(); - } else { + } else if (value == "new") { showCustomPopup( context, CreateServerPopup(parentCallback: () {})); + } else { + showCustomPopup(context, ChangePasswordPopup()); } }, itemBuilder: (_) => >[ - PopupMenuItem( - value: "logout", - child: Text("Logout"), - ), PopupMenuItem( value: "new", child: Text(AppLocalizations.of(context)!.addServer), ), + PopupMenuItem( + value: "change", + child: Text(AppLocalizations.of(context)!.changePassword), + ), + PopupMenuItem( + value: "logout", + child: Text("Logout"), + ), ], child: Row( children: [ diff --git a/APP/ogree_app/lib/l10n/app_en.arb b/APP/ogree_app/lib/l10n/app_en.arb index 70810092f..9db1b7e07 100644 --- a/APP/ogree_app/lib/l10n/app_en.arb +++ b/APP/ogree_app/lib/l10n/app_en.arb @@ -92,5 +92,22 @@ "selectServer": "Choose server", "createServer": "Create new Backend Server", "serverPath": "Install Path on server", - "portServer": "Backend Port on server" + "portServer": "Backend Port on server", + + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New Password", + "confirmPassword": "Confirm New Password", + "passwordNoMatch": "New password fields do not match", + + "tenantName": "Tenant Name", + "tenantPassword": "Tenant Admin Password", + "apiUrl": "New API URL", + "webUrl": "New Web URL", + "docUrl": "New Swagger UI URL", + "selectLogo": "Select Logo Image", + "notLoaded": "not loaded by user", + "failedToUpload": "Custom logo not set:", + "wrongFormatUrl": "Wrong format for URL: expected host:port", + "wrongFormatPort": "Wrong format for URL: port should only have digits" } \ No newline at end of file diff --git a/APP/ogree_app/lib/l10n/app_fr.arb b/APP/ogree_app/lib/l10n/app_fr.arb index 1fcb2298c..2449756c2 100644 --- a/APP/ogree_app/lib/l10n/app_fr.arb +++ b/APP/ogree_app/lib/l10n/app_fr.arb @@ -121,5 +121,22 @@ "selectServer": "Choisir serveur", "createServer": "Créer backend dans serveur", "serverPath": "Chemin d'installation (serveur)", - "portServer": "Porte pour le backend (serveur)" + "portServer": "Porte pour le backend (serveur)", + + "changePassword": "Modifier mon mot de passe", + "currentPassword": "Ancien mot de passe", + "newPassword": "Nouveau mot de passe", + "confirmPassword": "Confirmation du nouveau mot de passe", + "passwordNoMatch": "Confirmation du nouveau mot de passe n'est pas identique", + + "tenantName": "Nom du tenant", + "tenantPassword": "Mot de passe (admin)", + "apiUrl": "Nouvel URL API", + "webUrl": "Nouvel URL Web", + "docUrl": "Nouvelle Swagger UI URL", + "selectLogo": "Choisir Image Logo", + "notLoaded": "pas chargé par l'utilisateur", + "failedToUpload": "Logo customisé pas défini :", + "wrongFormatUrl": "Mauvais format de l'URL : host:port attendu", + "wrongFormatPort": "Mauvais format de l'URL : que des chiffres pour la porte" } \ No newline at end of file diff --git a/APP/ogree_app/lib/main.dart b/APP/ogree_app/lib/main.dart index 08ec932c7..31e0b8e95 100644 --- a/APP/ogree_app/lib/main.dart +++ b/APP/ogree_app/lib/main.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:ogree_app/pages/login_page.dart'; +import 'package:ogree_app/pages/reset_page.dart'; Future main() async { + await dotenv.load(fileName: "assets/custom/.env"); runApp(const MyApp()); } @@ -32,35 +35,56 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { return MaterialApp( - title: 'OGrEE App', - locale: _locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData( - useMaterial3: true, - colorSchemeSeed: Colors.blue, - fontFamily: GoogleFonts.inter().fontFamily, - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue.shade600, - foregroundColor: Colors.white, - )), - cardTheme: const CardTheme( - elevation: 3, - surfaceTintColor: Colors.white, - color: Colors.white), - textTheme: TextTheme( - headlineLarge: GoogleFonts.inter( - fontSize: 22, - color: Colors.black, - fontWeight: FontWeight.w700, - ), - headlineMedium: GoogleFonts.inter( - fontSize: 17, - color: Colors.black, - ), - )), - home: const LoginPage(), + title: 'OGrEE App', + locale: _locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + useMaterial3: true, + colorSchemeSeed: Colors.blue, + fontFamily: GoogleFonts.inter().fontFamily, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + foregroundColor: Colors.white, + )), + cardTheme: const CardTheme( + elevation: 3, + surfaceTintColor: Colors.white, + color: Colors.white), + textTheme: TextTheme( + headlineLarge: GoogleFonts.inter( + fontSize: 22, + color: Colors.black, + fontWeight: FontWeight.w700, + ), + headlineMedium: GoogleFonts.inter( + fontSize: 17, + color: Colors.black, + ), + )), + home: const LoginPage(), + onGenerateRoute: RouteGenerator.generateRoute); + } +} + +class RouteGenerator { + static Route generateRoute(RouteSettings settings) { + String? route; + Map? queryParameters; + if (settings.name != null) { + var uriData = Uri.parse(settings.name!); + route = uriData.path; + queryParameters = uriData.queryParameters; + } + var message = + 'generateRoute: Route $route, QueryParameters $queryParameters'; + print(message); + return MaterialPageRoute( + builder: (context) { + return ResetPage(token: queryParameters!["token"].toString()); + }, + settings: settings, ); } } diff --git a/APP/ogree_app/lib/models/domain.dart b/APP/ogree_app/lib/models/domain.dart index 5e3b0fc8c..f0784f8d3 100644 --- a/APP/ogree_app/lib/models/domain.dart +++ b/APP/ogree_app/lib/models/domain.dart @@ -20,10 +20,17 @@ class Domain { } factory Domain.fromMap(Map map) { + String description = ""; + if (map['description'] != null) { + var list = List.from(map['description']); + if (list.isNotEmpty) { + description = list.first; + } + } return Domain( map['name'].toString(), map['attributes']['color'].toString(), - map['description'][0].toString(), + description, map['parentId'] == null ? "" : map['parentId'].toString(), ); } diff --git a/APP/ogree_app/lib/models/tenant.dart b/APP/ogree_app/lib/models/tenant.dart index 1002951b9..25733ab87 100644 --- a/APP/ogree_app/lib/models/tenant.dart +++ b/APP/ogree_app/lib/models/tenant.dart @@ -12,6 +12,7 @@ class Tenant { bool hasDoc; String docUrl; String docPort; + String imageTag; Tenant( this.name, @@ -23,7 +24,8 @@ class Tenant { this.hasWeb, this.hasDoc, this.docUrl, - this.docPort); + this.docPort, + this.imageTag); Map toMap() { return { @@ -37,6 +39,7 @@ class Tenant { 'hasDoc': hasDoc, 'docUrl': docUrl, 'docPort': docPort, + 'imageTag': imageTag, }; } @@ -51,7 +54,8 @@ class Tenant { map['hasWeb'], map['hasDoc'], map['docUrl'].toString(), - map['docPort'].toString()); + map['docPort'].toString(), + map['imageTag'].toString()); } String toJson() => json.encode(toMap()); diff --git a/APP/ogree_app/lib/pages/login_page.dart b/APP/ogree_app/lib/pages/login_page.dart index 3dd0c9526..83872d91c 100644 --- a/APP/ogree_app/lib/pages/login_page.dart +++ b/APP/ogree_app/lib/pages/login_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:ogree_app/common/api_backend.dart'; import 'package:ogree_app/common/snackbar.dart'; @@ -6,6 +7,8 @@ import 'package:ogree_app/pages/projects_page.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:ogree_app/widgets/language_toggle.dart'; +import 'reset_page.dart'; + class LoginPage extends StatefulWidget { static String tag = 'login-page'; @@ -27,6 +30,7 @@ class _LoginPageState extends State { String? _email; String? _password; String _apiUrl = ""; + bool forgot = false; @override Widget build(BuildContext context) { @@ -53,42 +57,64 @@ class _LoginPageState extends State { ), const SizedBox(height: 5), Card( - // surfaceTintColor: Colors.white, - // elevation: 0, child: Form( key: _formKey, child: Container( constraints: - const BoxConstraints(maxWidth: 550, maxHeight: 500), + const BoxConstraints(maxWidth: 550, maxHeight: 510), padding: const EdgeInsets.only( right: 100, left: 100, top: 50, bottom: 30), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Center( - child: Text(localeMsg.welcome, - style: Theme.of(context) - .textTheme - .headlineLarge)), + forgot + ? Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => + LoginPage())), + icon: Icon( + Icons.arrow_back, + color: Colors.blue.shade900, + )), + const SizedBox(width: 5), + Text( + "Request password reset", + style: Theme.of(context) + .textTheme + .headlineLarge, + ), + ], + ) + : Center( + child: Text(localeMsg.welcome, + style: Theme.of(context) + .textTheme + .headlineLarge)), const SizedBox(height: 8), - Center( - child: Text( - localeMsg.welcomeConnect, - style: - Theme.of(context).textTheme.headlineMedium, - ), - ), - const SizedBox(height: 25), - allowBackChoice + forgot + ? SizedBox(height: 10) + : Center( + child: Text( + localeMsg.welcomeConnect, + style: Theme.of(context) + .textTheme + .headlineMedium, + ), + ), + forgot ? Container() : const SizedBox(height: 20), + dotenv.env['ALLOW_SET_BACK'] == "true" ? backendInput() : Center( child: Image.asset( - "assets/edf_logo.png", - height: 30, + "assets/custom/logo.png", + height: 40, ), ), - const SizedBox(height: 32), + const SizedBox(height: 30), TextFormField( onSaved: (newValue) => _email = newValue, validator: (text) { @@ -108,67 +134,97 @@ class _LoginPageState extends State { ), ), const SizedBox(height: 20), - TextFormField( - obscureText: true, - onSaved: (newValue) => _password = newValue, - onEditingComplete: () => tryLogin(), - validator: (text) { - if (text == null || text.isEmpty) { - return localeMsg.mandatoryField; - } - return null; - }, - decoration: InputDecoration( - labelText: localeMsg.password, - hintText: '********', - labelStyle: GoogleFonts.inter( - fontSize: 11, - color: Colors.black, - ), - border: inputStyle, - ), - ), - const SizedBox(height: 25), - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - SizedBox( - height: 24, - width: 24, - child: Checkbox( - value: _isChecked, - onChanged: (bool? value) => - setState(() => _isChecked = value!), + forgot + ? Container() + : TextFormField( + obscureText: true, + onSaved: (newValue) => _password = newValue, + onEditingComplete: () => tryLogin(), + validator: (text) { + if (!forgot && + (text == null || text.isEmpty)) { + return localeMsg.mandatoryField; + } + return null; + }, + decoration: InputDecoration( + labelText: localeMsg.password, + hintText: '********', + labelStyle: GoogleFonts.inter( + fontSize: 11, + color: Colors.black, + ), + border: inputStyle, + ), + ), + !forgot ? const SizedBox(height: 25) : Container(), + forgot + ? TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ResetPage( + token: '', + ), ), ), - const SizedBox(width: 8), - Text( - localeMsg.stayLogged, + child: Text( + "I have a reset token", style: TextStyle( fontSize: 14, - color: Colors.black, + color: const Color.fromARGB( + 255, 0, 84, 152), ), ), - ], - ), - Text( - localeMsg.forgotPassword, - style: TextStyle( - fontSize: 14, - color: - const Color.fromARGB(255, 0, 84, 152), + ) + : Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: + WrapCrossAlignment.center, + children: [ + Wrap( + crossAxisAlignment: + WrapCrossAlignment.center, + children: [ + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _isChecked, + onChanged: (bool? value) => + setState(() => + _isChecked = value!), + ), + ), + const SizedBox(width: 8), + Text( + localeMsg.stayLogged, + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), + ), + ], + ), + TextButton( + onPressed: () => setState(() { + forgot = !forgot; + }), + child: Text( + localeMsg.forgotPassword, + style: TextStyle( + fontSize: 14, + color: const Color.fromARGB( + 255, 0, 84, 152), + ), + ), + ), + ], ), - ), - ], - ), - const SizedBox(height: 30), + SizedBox(height: forgot ? 20 : 30), Align( child: ElevatedButton( - onPressed: () => tryLogin(), + onPressed: () => + forgot ? resetPassword() : tryLogin(), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( vertical: 20, @@ -176,7 +232,7 @@ class _LoginPageState extends State { ), ), child: Text( - localeMsg.login, + forgot ? "Request Reset" : localeMsg.login, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -222,8 +278,27 @@ class _LoginPageState extends State { } } + resetPassword() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + userForgotPassword(_email!, userUrl: _apiUrl) + .then((value) => value == "" + ? showSnackBar(context, "Reset request sent", isSuccess: true) + : showSnackBar( + context, AppLocalizations.of(context)!.invalidLogin, + isError: true)) + .onError((error, stackTrace) { + print(error); + showSnackBar(context, error.toString().trim(), isError: true); + }); + } + } + backendInput() { - final options = backendUrl.split(","); + List options = []; + if (dotenv.env['BACK_URLS'] != null) { + options = dotenv.env['BACK_URLS']!.split(","); + } final localeMsg = AppLocalizations.of(context)!; return RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { @@ -286,13 +361,3 @@ class _LoginPageState extends State { ); } } - -String backendUrl = const String.fromEnvironment( - 'BACK_URLS', - defaultValue: 'http://localhost:8082,https://b.api.ogree.ditrit.io', -); - -bool allowBackChoice = const bool.fromEnvironment( - 'ALLOW_SET_BACK', - defaultValue: true, -); diff --git a/APP/ogree_app/lib/pages/reset_page.dart b/APP/ogree_app/lib/pages/reset_page.dart new file mode 100644 index 000000000..32c6da20c --- /dev/null +++ b/APP/ogree_app/lib/pages/reset_page.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:ogree_app/common/api_backend.dart'; +import 'package:ogree_app/common/snackbar.dart'; +import 'package:ogree_app/pages/login_page.dart'; +import 'package:ogree_app/pages/projects_page.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:ogree_app/widgets/language_toggle.dart'; + +class ResetPage extends StatefulWidget { + String token; + + ResetPage({super.key, required this.token}); + @override + State createState() => _ResetPageState(); +} + +class _ResetPageState extends State { + final _formKey = GlobalKey(); + bool _isChecked = false; + static const inputStyle = OutlineInputBorder( + borderSide: BorderSide( + color: Colors.grey, + width: 1, + ), + ); + + String? _token; + String? _password; + String? _confirmPassword; + bool forgot = false; + String _apiUrl = ""; + + @override + Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; + token = widget.token; + return Scaffold( + body: Container( + // height: MediaQuery.of(context).size.height, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage("assets/server_background.png"), + fit: BoxFit.cover, + ), + ), + child: CustomScrollView(slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Align( + alignment: Alignment.topCenter, + child: LanguageToggle(), + ), + const SizedBox(height: 5), + Card( + child: Form( + key: _formKey, + child: Container( + constraints: + const BoxConstraints(maxWidth: 550, maxHeight: 550), + padding: const EdgeInsets.only( + right: 100, left: 100, top: 50, bottom: 30), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoginPage())), + icon: Icon( + Icons.arrow_back, + color: Colors.blue.shade900, + )), + const SizedBox(width: 5), + Text( + "Reset password", + style: + Theme.of(context).textTheme.headlineLarge, + ), + ], + ), + const SizedBox(height: 25), + dotenv.env['ALLOW_SET_BACK'] == "true" + ? backendInput() + : Center( + child: Image.asset( + "assets/edf_logo.png", + height: 30, + ), + ), + const SizedBox(height: 32), + TextFormField( + initialValue: widget.token, + enabled: widget.token == "", + onSaved: (newValue) => _token = newValue, + validator: (text) { + if (text == null || text.isEmpty) { + return localeMsg.mandatoryField; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Reset Token', + labelStyle: GoogleFonts.inter( + fontSize: 11, + color: Colors.black, + ), + border: inputStyle, + ), + ), + const SizedBox(height: 20), + TextFormField( + obscureText: true, + onSaved: (newValue) => _password = newValue, + validator: (text) { + if (text == null || text.isEmpty) { + return localeMsg.mandatoryField; + } + return null; + }, + decoration: InputDecoration( + labelText: 'New password', + hintText: '********', + labelStyle: GoogleFonts.inter( + fontSize: 11, + color: Colors.black, + ), + border: inputStyle, + ), + ), + const SizedBox(height: 20), + TextFormField( + obscureText: true, + onSaved: (newValue) => + _confirmPassword = newValue, + onEditingComplete: () => resetPassword(), + validator: (text) { + if (text == null || text.isEmpty) { + return localeMsg.mandatoryField; + } + return null; + }, + decoration: InputDecoration( + labelText: "Confirm new password", + hintText: '********', + labelStyle: GoogleFonts.inter( + fontSize: 11, + color: Colors.black, + ), + border: inputStyle, + ), + ), + const SizedBox(height: 25), + Align( + child: ElevatedButton( + onPressed: () => resetPassword(), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 20, + ), + ), + child: Text( + "Reset", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 15), + ], + ), + ), + ), + ), + ), + ], + ), + ) + ]), + ), + ); + } + + resetPassword() { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + if (_password != _confirmPassword) { + showSnackBar(context, "Password fields do no match", isError: true); + return; + } + userResetPassword(_password!, _token!, userUrl: _apiUrl) + .then((value) => value == "" + ? resetSucces() + : showSnackBar(context, value, isError: true)) + .onError((error, stackTrace) { + print(error); + showSnackBar(context, error.toString().trim(), isError: true); + }); + } + } + + resetSucces() { + showSnackBar(context, "Password successfully changed", isSuccess: true); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoginPage(), + ), + ); + } + + backendInput() { + List options = []; + if (dotenv.env['BACK_URLS'] != null) { + options = dotenv.env['BACK_URLS']!.split(","); + } + final localeMsg = AppLocalizations.of(context)!; + return RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return options.where((String option) { + return option.contains(textEditingValue.text); + }); + }, + fieldViewBuilder: (BuildContext context, + TextEditingController textEditingController, + FocusNode focusNode, + VoidCallback onFieldSubmitted) { + textEditingController.text = options.first; + return TextFormField( + controller: textEditingController, + focusNode: focusNode, + onSaved: (newValue) => _apiUrl = newValue!, + validator: (text) { + if (text == null || text.trim().isEmpty) { + return localeMsg.mandatoryField; + } + return null; + }, + decoration: InputDecoration( + isDense: true, + labelText: localeMsg.selectServer, + labelStyle: TextStyle(fontSize: 14)), + onTap: () { + textEditingController.clear(); + }, + ); + }, + optionsViewBuilder: (BuildContext context, + AutocompleteOnSelected onSelected, Iterable options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4.0, + child: SizedBox( + height: options.length > 2 ? 171.0 : 57.0 * options.length, + width: 350, + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final String option = options.elementAt(index); + return GestureDetector( + onTap: () { + onSelected(option); + }, + child: ListTile( + title: Text(option, style: const TextStyle(fontSize: 14)), + ), + ); + }, + ), + ), + ), + ); + }, + ); + } +} diff --git a/APP/ogree_app/lib/widgets/change_password_popup.dart b/APP/ogree_app/lib/widgets/change_password_popup.dart new file mode 100644 index 000000000..2d8f57939 --- /dev/null +++ b/APP/ogree_app/lib/widgets/change_password_popup.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:ogree_app/common/api_backend.dart'; + +import '../common/snackbar.dart'; + +class ChangePasswordPopup extends StatefulWidget { + @override + State createState() => _ChangePasswordPopupState(); +} + +class _ChangePasswordPopupState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + String? _oldPassword; + String? _newPassword; + String? _confirmPass; + + @override + Widget build(BuildContext context) { + final localeMsg = AppLocalizations.of(context)!; + return Center( + child: Container( + // height: 240, + width: 500, + margin: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.fromLTRB(40, 20, 40, 15), + child: Material( + color: Colors.white, + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + localeMsg.changePassword, + style: GoogleFonts.inter( + fontSize: 22, + color: Colors.black, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 20), + getFormField( + save: (newValue) => _oldPassword = newValue, + label: localeMsg.currentPassword), + getFormField( + save: (newValue) => _newPassword = newValue, + label: localeMsg.newPassword), + getFormField( + save: (newValue) => _confirmPass = newValue, + label: localeMsg.confirmPassword), + const SizedBox(height: 30), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue.shade900), + onPressed: () => Navigator.pop(context), + label: Text(localeMsg.cancel), + icon: const Icon( + Icons.cancel_outlined, + size: 16, + ), + ), + const SizedBox(width: 15), + ElevatedButton.icon( + onPressed: () async { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + if (_newPassword != _confirmPass) { + showSnackBar(context, localeMsg.passwordNoMatch, + isError: true); + return; + } + try { + setState(() { + _isLoading = true; + }); + var response; + response = await changeUserPassword( + _oldPassword!, _newPassword!); + if (response == "") { + showSnackBar(context, localeMsg.modifyOK, + isSuccess: true); + Navigator.of(context).pop(); + } else { + setState(() { + _isLoading = false; + }); + showSnackBar(context, response, + isError: true); + } + } catch (e) { + showSnackBar(context, e.toString(), + isError: true); + return; + } + } + }, + label: Text(localeMsg.modify), + icon: _isLoading + ? Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(2.0), + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon(Icons.check_circle, size: 16)) + ], + ) + ], + ), + ), + ), + ), + ), + ); + } + + getFormField( + {required Function(String?) save, + required String label, + String? prefix, + String? suffix, + List? formatters, + String? initial, + bool isReadOnly = false, + bool obscure = true}) { + return Padding( + padding: const EdgeInsets.only(left: 2, right: 10), + child: TextFormField( + obscureText: obscure, + initialValue: initial, + readOnly: isReadOnly, + onSaved: (newValue) => save(newValue), + validator: (text) { + if (text == null || text.isEmpty) { + return AppLocalizations.of(context)!.mandatoryField; + } + return null; + }, + inputFormatters: formatters, + decoration: InputDecoration( + labelText: label, + prefixText: prefix, + suffixText: suffix, + ), + ), + ); + } +} diff --git a/APP/ogree_app/lib/widgets/select_objects/app_controller.dart b/APP/ogree_app/lib/widgets/select_objects/app_controller.dart index 2b25fae70..0fa63b139 100644 --- a/APP/ogree_app/lib/widgets/select_objects/app_controller.dart +++ b/APP/ogree_app/lib/widgets/select_objects/app_controller.dart @@ -20,11 +20,8 @@ class AppController with ChangeNotifier { {bool isTest = false, bool onlyDomain = false, bool reload = false}) async { - print("INIIIIIIIIIT"); if (_isInitialized && !reload) return; - print("FOR REAL INIIIIIIIIIT"); final rootNode = TreeNode(id: kRootId); - print("Get API data"); if (onlyDomain) { fetchedData = (await fetchObjectsTree(onlyDomain: true)).first; print(fetchedData); diff --git a/APP/ogree_app/lib/widgets/tenants/popups/create_server_popup.dart b/APP/ogree_app/lib/widgets/tenants/popups/create_server_popup.dart index 6108d0b67..38a5d97bf 100644 --- a/APP/ogree_app/lib/widgets/tenants/popups/create_server_popup.dart +++ b/APP/ogree_app/lib/widgets/tenants/popups/create_server_popup.dart @@ -27,6 +27,7 @@ class _CreateServerPopupState extends State { String? _port; bool _isLoading = false; AuthOption? _authOption = AuthOption.pKey; + bool _shouldStartup = false; @override Widget build(BuildContext context) { @@ -131,7 +132,30 @@ class _CreateServerPopupState extends State { label: localeMsg.portServer, icon: Icons.onetwothree, formatters: [FilteringTextInputFormatter.digitsOnly]), - const SizedBox(height: 40), + const SizedBox(height: 13), + Row( + children: [ + const SizedBox(width: 40), + SizedBox( + height: 24, + width: 24, + child: Checkbox( + value: _shouldStartup, + onChanged: (bool? value) => + setState(() => _shouldStartup = value!), + ), + ), + const SizedBox(width: 8), + Text( + "Run at startup", + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), + ), + ], + ), + const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -153,23 +177,24 @@ class _CreateServerPopupState extends State { setState(() { _isLoading = true; }); - - var response = _authOption == AuthOption.pKey - ? await createBackendServer({ - 'host': _sshHost!, - 'user': _sshUser!, - 'pkey': _sshKey!, - 'pkeypass': _sshKeyPass.toString(), - 'dstpath': _installPath!, - 'runport': _port!, - }) - : await createBackendServer({ - 'host': _sshHost!, - 'user': _sshUser!, - 'password': _sshPassword!, - 'dstpath': _installPath!, - 'runport': _port!, - }); + Map serverInfo = + { + 'host': _sshHost!, + 'user': _sshUser!, + 'dstpath': _installPath!, + 'runport': _port!, + 'startup': _shouldStartup, + }; + if (_authOption == AuthOption.pKey) { + serverInfo.addAll({ + 'pkey': _sshKey!, + 'pkeypass': _sshKeyPass.toString(), + }); + } else { + serverInfo['password'] = _sshPassword!; + } + var response = + await createBackendServer(serverInfo); if (response == "") { widget.parentCallback(); showSnackBar(context, localeMsg.createOK, diff --git a/APP/ogree_app/lib/widgets/tenants/popups/create_tenant_popup.dart b/APP/ogree_app/lib/widgets/tenants/popups/create_tenant_popup.dart index f80abcadb..dfab36a8b 100644 --- a/APP/ogree_app/lib/widgets/tenants/popups/create_tenant_popup.dart +++ b/APP/ogree_app/lib/widgets/tenants/popups/create_tenant_popup.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -27,6 +28,8 @@ class _CreateTenantPopupState extends State { bool _hasWeb = true; bool _hasDoc = false; bool _isLoading = false; + PlatformFile? _loadedImage; + String _imageTag = "latest"; @override Widget build(BuildContext context) { @@ -64,11 +67,11 @@ class _CreateTenantPopupState extends State { const Divider(height: 45), getFormField( save: (newValue) => _tenantName = newValue, - label: "Tenant Name", + label: localeMsg.tenantName, icon: Icons.business_center), getFormField( save: (newValue) => _tenantPassword = newValue, - label: "Tenant Admin Password", + label: localeMsg.tenantPassword, icon: Icons.lock), const SizedBox(height: 8), Wrap( @@ -94,13 +97,18 @@ class _CreateTenantPopupState extends State { })), ], ), + getFormField( + save: (newValue) => _imageTag = newValue!, + label: "Version du déploiement (tag)", + icon: Icons.access_time, + initial: _imageTag), getFormField( save: (newValue) { var splitted = newValue!.split(":"); _apiUrl = splitted[0]; _apiPort = splitted[1]; }, - label: "New API URL (hostname:port)", + label: "${localeMsg.apiUrl} (hostname:port)", icon: Icons.cloud, prefix: "http://", isUrl: true, @@ -112,12 +120,49 @@ class _CreateTenantPopupState extends State { _webUrl = splitted[0]; _webPort = splitted[1]; }, - label: "New Web URL (hostname:port)", + label: "${localeMsg.webUrl} (hostname:port)", icon: Icons.monitor, prefix: "http://", isUrl: true, ) : Container(), + _hasWeb + ? Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 20), + child: _loadedImage == null + ? Image.asset( + "assets/custom/logo.png", + height: 40, + ) + : Image.memory( + _loadedImage!.bytes!, + height: 40, + ), + ), + ElevatedButton.icon( + onPressed: () async { + FilePickerResult? result = + await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ["png"], + withData: true); + if (result != null) { + setState(() { + _loadedImage = result.files.single; + }); + } + }, + icon: const Icon(Icons.download), + label: Text(localeMsg.selectLogo)), + ], + ), + ) + : Container(), _hasDoc ? getFormField( save: (newValue) { @@ -125,13 +170,13 @@ class _CreateTenantPopupState extends State { _docUrl = splitted[0]; _docPort = splitted[1]; }, - label: "New Swagger UI URL (hostname:port)", + label: "${localeMsg.docUrl} (hostname:port)", icon: Icons.book, prefix: "http://", isUrl: true, ) : Container(), - const SizedBox(height: 40), + const SizedBox(height: 30), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -153,7 +198,19 @@ class _CreateTenantPopupState extends State { setState(() { _isLoading = true; }); - var response = await createTenant(Tenant( + // Load logo first, if provided + String response = localeMsg.notLoaded; + if (_loadedImage != null) { + response = await uploadImage( + _loadedImage!, _tenantName!); + print(response); + if (response != "") { + showSnackBar(context, + "${localeMsg.failedToUpload} $response"); + } + } + // Create tenant + response = await createTenant(Tenant( _tenantName!, _tenantPassword!, _apiUrl!, @@ -163,7 +220,8 @@ class _CreateTenantPopupState extends State { _hasWeb, _hasDoc, _docUrl, - _docPort)); + _docPort, + _imageTag)); if (response == "") { widget.parentCallback(); showSnackBar( @@ -224,10 +282,12 @@ class _CreateTenantPopupState extends State { String? prefix, String? suffix, List? formatters, + String? initial, bool isUrl = false}) { return Padding( padding: const EdgeInsets.only(left: 2, right: 10), child: TextFormField( + initialValue: initial, onSaved: (newValue) => save(newValue), validator: (text) { if (text == null || text.isEmpty) { @@ -236,10 +296,10 @@ class _CreateTenantPopupState extends State { if (isUrl) { var splitted = text.split(":"); if (splitted.length != 2) { - return "Wrong format for URL: expected host:port"; + return AppLocalizations.of(context)!.wrongFormatUrl; } if (int.tryParse(splitted[1]) == null) { - return "Wrong format for URL: port should only have digits"; + return AppLocalizations.of(context)!.wrongFormatPort; } } return null; diff --git a/APP/ogree_app/pubspec.lock b/APP/ogree_app/pubspec.lock index 9d558d072..15a3353d9 100644 --- a/APP/ogree_app/pubspec.lock +++ b/APP/ogree_app/pubspec.lock @@ -246,6 +246,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d9283d92059a22e9834bc0a31336658ffba77089fb6f3cc36751f1fc7c6661a3 + url: "https://pub.dev" + source: hosted + version: "5.0.2" flutter_fancy_tree_view: dependency: "direct main" description: diff --git a/APP/ogree_app/pubspec.yaml b/APP/ogree_app/pubspec.yaml index 8359bf9a6..b35c36994 100644 --- a/APP/ogree_app/pubspec.yaml +++ b/APP/ogree_app/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: path_provider: ^2.0.14 universal_html: ^2.2.1 file_picker: ^5.2.10 + flutter_dotenv: ^5.0.2 dev_dependencies: mockito: ^5.3.2 @@ -76,6 +77,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - assets/edf_logo.png + - assets/custom/logo.png - assets/server_background.png - lib/l10n/app_fr.arb + - assets/custom/.env diff --git a/APP/ogree_app/test/language_toogle_test.dart b/APP/ogree_app/test/language_toogle_test.dart index 0f5a82de7..5d45720e8 100644 --- a/APP/ogree_app/test/language_toogle_test.dart +++ b/APP/ogree_app/test/language_toogle_test.dart @@ -1,8 +1,10 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ogree_app/main.dart'; void main() { testWidgets('MyApp can languague toogle FR/EN', (tester) async { + await dotenv.load(fileName: "assets/custom/.env"); // Create the widget by telling the tester to build it. await tester.pumpWidget(const MyApp()); diff --git a/APP/ogree_app/test/login_page_test.dart b/APP/ogree_app/test/login_page_test.dart index b39b88c69..b7a47e67b 100644 --- a/APP/ogree_app/test/login_page_test.dart +++ b/APP/ogree_app/test/login_page_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ogree_app/main.dart'; @@ -6,6 +7,7 @@ import 'common.dart'; void main() { testWidgets('MyApp loads Login Page', (tester) async { + await dotenv.load(fileName: "assets/custom/.env"); await tester.pumpWidget(const MyApp()); final msgs = await getFrenchMessages(); expect(find.textContaining(msgs["welcome"]!), findsOneWidget); diff --git a/APP/ogree_app_backend/api.go b/APP/ogree_app_backend/api.go index af127a5b0..5e6b84e48 100644 --- a/APP/ogree_app_backend/api.go +++ b/APP/ogree_app_backend/api.go @@ -15,6 +15,8 @@ import ( ) var tmplt *template.Template +var apptmplt *template.Template +var servertmplt *template.Template var DEPLOY_DIR string var DOCKER_DIR string @@ -27,11 +29,13 @@ func init() { if DEPLOY_DIR == "" { DEPLOY_DIR = "../../deploy/" } - DOCKER_DIR = DOCKER_DIR + "docker/" + DOCKER_DIR = DEPLOY_DIR + "docker/" // hashedPassword, _ := bcrypt.GenerateFromPassword( // []byte("password"), bcrypt.DefaultCost) // println(string(hashedPassword)) - tmplt = template.Must(template.ParseFiles("docker-env-template.txt")) + tmplt = template.Must(template.ParseFiles("backend-assets/docker-env-template.txt")) + apptmplt = template.Must(template.ParseFiles("flutter-assets/flutter-env-template.txt")) + servertmplt = template.Must(template.ParseFiles("backend-assets/template.service")) } func main() { @@ -50,6 +54,7 @@ func main() { router.GET("/api/tenants/:name", getTenantDockerInfo) router.DELETE("/api/tenants/:name", removeTenant) router.POST("/api/tenants", addTenant) + router.POST("/api/tenants/:name/logo", addTenantLogo) router.GET("/api/containers/:name", getContainerLogs) router.POST("/api/servers", createNewBackend) diff --git a/APP/ogree_app_backend/docker-env-template.txt b/APP/ogree_app_backend/backend-assets/docker-env-template.txt similarity index 58% rename from APP/ogree_app_backend/docker-env-template.txt rename to APP/ogree_app_backend/backend-assets/docker-env-template.txt index e5c313ff0..aba2f4077 100644 --- a/APP/ogree_app_backend/docker-env-template.txt +++ b/APP/ogree_app_backend/backend-assets/docker-env-template.txt @@ -1,10 +1,11 @@ -CORE_DIR=https://github.com/ditrit/OGrEE-Core.git#dbForTenants +CORE_DIR=../.. API_BUILD_DIR=API -CLI_BUILD_DIR=CLI -APP_BUILD_DIR=https://github.com/ditrit/OGrEE-APP.git#main +APP_BUILD_DIR=APP API_DOC_UI_PORT={{.DocPort}} API_PORT={{.ApiPort}} WEB_PORT={{.WebPort}} CUSTOMER_API_PASSWORD={{.CustomerPassword}} API_EXTERNALURL={{.ApiUrl}} COMPOSE_PROJECT_NAME={{.Name}} +APP_ASSETS_DIR={{.AssetsDir}} +IMAGE_TAG={{.ImageTag}} diff --git a/APP/ogree_app_backend/backend-assets/template.service b/APP/ogree_app_backend/backend-assets/template.service new file mode 100644 index 000000000..95578a97b --- /dev/null +++ b/APP/ogree_app_backend/backend-assets/template.service @@ -0,0 +1,19 @@ +[Unit] +Description=ogree_app_backend +After=network-online.target +Wants=network-online.target systemd-networkd-wait-online.service + +[Service] +Restart=always +StartLimitInterval=10 +StartLimitBurst=3 + +User=root +Group=root + +WorkingDirectory={{.DstPath}} +ExecStart={{.DstPath}}/ogree_app_backend -port {{.RunPort}} +ExecReload=/bin/kill -USR1 $MAINPID + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/APP/ogree_app_backend/createdb.js b/APP/ogree_app_backend/createdb.js deleted file mode 100644 index bccaae1be..000000000 --- a/APP/ogree_app_backend/createdb.js +++ /dev/null @@ -1,138 +0,0 @@ -///// -// NOTE -// -// An 'admin' DB will be created with an admin, super and backup user -// MongoDB docker image will execute scripts in alphabetical order -// -// Finally a secured customer DB will be created with an API user -// credential -// -// It is recommended that you use a secure method for supplying -// passwords -////// - -// How to Authenticate -// -// As Admin: -// mongosh "mongodb://ADMIN_USER:ADMIN_PASS@localhost/test?authSource=test" -// -// As Super: -// mongosh "mongodb://SUPER_USER:SUPER_PASS@localhost/test?authSource=test" -// -// As API: -// mongosh "mongodb://"ogree"+DB_NAME+"Admin":CUSTOMER_API_PASSWORD@localhost/"ogree"+DB_NAME?authSource="ogree"+DB_NAME" - -// CONSTANT DECLARATIONS -DB_NAME; -CUSTOMER_API_PASSWORD; - -ADMIN_DB; -SUPER_USER; -SUPER_PASS; - -ADMIN_USER; -ADMIN_PASS; - -GUARD_USER; -GUARD_PASS; - -var m = new Mongo() -var authDB = m.getDB(ADMIN_DB) - -// Get all existing users -var users = authDB.getUsers()["users"]; -var found = false; - -// Check if a specific user exists -// we loop here for future proofing purposes -// this is meant for docker-compose -for (var i = 0; i < users.length; i++) { - if (users[i].hasOwnProperty('user') && users[i]['user'] === ADMIN_USER) { - console.log("User already exists, skip user creation") - found = true; - } -} - -//Create users if not found -if (!found) { - authDB.createUser({ user: ADMIN_USER, pwd: ADMIN_PASS, - roles: [{role: "userAdminAnyDatabase", db: ADMIN_DB}, - { role: "readWriteAnyDatabase", db: ADMIN_DB}] - }); - - //Create the Root user named Super - authDB.createUser({ user: SUPER_USER, pwd: SUPER_PASS, - roles: [{role: "root", db: ADMIN_DB}] - }); - - //Create the Backup user named guard - authDB.createUser({ user: GUARD_USER, pwd: GUARD_PASS, - roles: [{role: "backup", db: ADMIN_DB}, {role: "restore", db: ADMIN_DB}] - }); -} - -//Authenticate first -var m = new Mongo() -var authDB = m.getDB(ADMIN_DB) -authDB.auth(ADMIN_USER, ADMIN_PASS); - -// Create a new Database -var db = m.getDB("ogree"+DB_NAME) -db.createCollection('account'); -db.createCollection('domain'); -db.createCollection('site'); -db.createCollection('building'); -db.createCollection('room'); -db.createCollection('rack'); -db.createCollection('device'); - -//Template Collections -db.createCollection('room_template'); -db.createCollection('obj_template'); -db.createCollection('bldg_template'); - -//Group Collections -db.createCollection('group'); - -//Nonhierarchal objects -db.createCollection('ac'); -db.createCollection('panel'); -db.createCollection('cabinet'); -db.createCollection('corridor'); - -//Stray Objects -db.createCollection('stray_device'); - -//Enforce unique children -db.domain.createIndex( {parentId:1, name:1}, { unique: true } ); -db.site.createIndex({name:1}, { unique: true }); -db.building.createIndex({parentId:1, name:1}, { unique: true }); -db.room.createIndex({parentId:1, name:1}, { unique: true }); -db.rack.createIndex({parentId:1, name:1}, { unique: true }); -db.device.createIndex({parentId:1, name:1}, { unique: true }); - -//Make slugs unique identifiers for templates -db.room_template.createIndex({slug:1}, { unique: true }); -db.obj_template.createIndex({slug:1}, { unique: true }); -db.bldg_template.createIndex({slug:1}, { unique: true }); - -//Unique children restriction for nonhierarchal objects and sensors -db.ac.createIndex({parentId:1, name:1}, { unique: true }); -db.panel.createIndex({parentId:1, name:1}, { unique: true }); -db.cabinet.createIndex({parentId:1, name:1}, { unique: true }); -db.corridor.createIndex({parentId:1, name:1}, { unique: true }); - -//Enforce unique Group names -db.group.createIndex({parentId:1, name:1}, { unique: true }); - -//Enforce unique stray objects -db.stray_device.createIndex({parentId:1,name:1}, { unique: true }); - -//Create a default domain -db.domain.insertOne({name: DB_NAME, hierarchyName: DB_NAME, category: "domain", - attributes:{color:"ffffff"}, description:[], createdData: new Date(), lastUpdated: new Date()}) - -// Create API User -db.createUser({ user: "ogree"+DB_NAME+"Admin", pwd: CUSTOMER_API_PASSWORD, - roles: [{role: "readWrite", db: "ogree"+DB_NAME}] - }) diff --git a/APP/ogree_app_backend/docker/docker-compose.yml b/APP/ogree_app_backend/docker/docker-compose.yml deleted file mode 100644 index c23b432c9..000000000 --- a/APP/ogree_app_backend/docker/docker-compose.yml +++ /dev/null @@ -1,89 +0,0 @@ -version: '3.9' -services: - ogree_api: - build: - context: ${CORE_DIR} - dockerfile: ${API_BUILD_DIR}/Dockerfile - image: ogree/api:latest - container_name: ${COMPOSE_PROJECT_NAME}_api - environment: - - api_port=3551 - - db_host=${COMPOSE_PROJECT_NAME}_db - - db_port=27017 - - db_user=${COMPOSE_PROJECT_NAME} - - db_pass=${CUSTOMER_API_PASSWORD} - - db=${COMPOSE_PROJECT_NAME} - - token_password=yourSecretPasswordGoesHere - ports: - - ${API_PORT}:3551 - depends_on: - - ogree_db - restart: on-failure:10 - - #Specifying the environment variables here is the superior option compared to inserting - #the .env file and building an image. Here you can specify all the parameters of the - #the API - - ogree_db: - image: mongo:latest - container_name: ${COMPOSE_PROJECT_NAME}_db - environment: - - DB_NAME=${COMPOSE_PROJECT_NAME} - - CUSTOMER_API_PASSWORD=${CUSTOMER_API_PASSWORD} - - ADMIN_DB=admin - - SUPER_USER=super - - SUPER_PASS=superpassword - - MONGO_INITDB_ROOT_USERNAME=admin - - MONGO_INITDB_ROOT_PASSWORD=adminpassword - - GUARD_USER=guard - - GUARD_PASS=adminpassword - volumes: - - ./init.sh:/docker-entrypoint-initdb.d/init.sh - - ../createdb.js:/home/createdb.js - - # Deploying the CLI in an orchestrated fashion does - # not work since containers exit when a program - # is done executing. Instead make container 'hang' - # to allow for on demand access. - # The 'tty: true' entry allows the container to hang - # by using the container's shell - # The cli binary is found @ /home/cli - ogree_cli: - build: - context: ${CORE_DIR} - dockerfile: ${CLI_BUILD_DIR}/Dockerfile - image: ogree/cli:latest - profiles: ["cli"] - container_name: ${COMPOSE_PROJECT_NAME}_cli - tty: true - volumes: - - ../../config.toml:/config.toml - depends_on: - - ogree_api - - # You will have to retrieve the swagger.json file from - # the api root dir and supply it here - api_docs_ui: - image: swaggerapi/swagger-ui:latest - container_name: ${COMPOSE_PROJECT_NAME}_doc - profiles: ["doc"] - volumes: - - ../../${API_BUILD_DIR}/swagger.json:/home/swagger.json - ports: - - ${API_DOC_UI_PORT}:8080 - environment: - SWAGGER_JSON: /home/swagger.json - - ogree_webapp: - build: - context: ${APP_BUILD_DIR} - args: - - API_URL=http://${API_EXTERNALURL}:${API_PORT} - image: ogree/webapp:latest - profiles: ["web"] - container_name: ${COMPOSE_PROJECT_NAME}_webapp - ports: - - ${WEB_PORT}:80 - depends_on: - - ogree_api - restart: on-failure:10 diff --git a/APP/ogree_app_backend/docker/init.sh b/APP/ogree_app_backend/docker/init.sh deleted file mode 100644 index a94c04858..000000000 --- a/APP/ogree_app_backend/docker/init.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -# -# Helper script catches environment variables passed from docker to init MongoDB. - -mongosh localhost:27017 /home/createdb.js --eval ' -var DB_NAME ="'$DB_NAME'", -ADMIN_DB="'$ADMIN_DB'", -CUSTOMER_API_PASSWORD="'$CUSTOMER_API_PASSWORD'", -SUPER_USER="'$SUPER_USER'", -SUPER_PASS="'$SUPER_PASS'", -ADMIN_USER="'$MONGO_INITDB_ROOT_USERNAME'", -ADMIN_PASS="'$MONGO_INITDB_ROOT_PASSWORD'", -GUARD_USER="'$GUARD_USER'", -GUARD_PASS="'$GUARD_PASS'"' diff --git a/APP/ogree_app_backend/flutter-assets/flutter-env-template.txt b/APP/ogree_app_backend/flutter-assets/flutter-env-template.txt new file mode 100644 index 000000000..d86833cb4 --- /dev/null +++ b/APP/ogree_app_backend/flutter-assets/flutter-env-template.txt @@ -0,0 +1,2 @@ +API_URL=http://{{.ApiUrl}}:{{.ApiPort}} +ALLOW_SET_BACK=false \ No newline at end of file diff --git a/APP/ogree_app_backend/flutter-assets/logo.png b/APP/ogree_app_backend/flutter-assets/logo.png new file mode 100644 index 000000000..2ef47d53a Binary files /dev/null and b/APP/ogree_app_backend/flutter-assets/logo.png differ diff --git a/APP/ogree_app_backend/ogree_app_backend.service b/APP/ogree_app_backend/ogree_app_backend.service new file mode 100644 index 000000000..156c1164c --- /dev/null +++ b/APP/ogree_app_backend/ogree_app_backend.service @@ -0,0 +1,19 @@ +[Unit] +Description=ogree_app_backend +After=network-online.target +Wants=network-online.target systemd-networkd-wait-online.service + +[Service] +Restart=always +StartLimitInterval=10 +StartLimitBurst=3 + +User=root +Group=root + +WorkingDirectory=/root/helder +ExecStart=/root/helder/ogree_app_backend -port 8082 +ExecReload=/bin/kill -USR1 $MAINPID + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/APP/ogree_app_backend/ogree_app_backend_linux b/APP/ogree_app_backend/ogree_app_backend_linux index c06a08258..bab8734c4 100755 Binary files a/APP/ogree_app_backend/ogree_app_backend_linux and b/APP/ogree_app_backend/ogree_app_backend_linux differ diff --git a/APP/ogree_app_backend/server.go b/APP/ogree_app_backend/server.go index f94d87724..4b6401cc2 100644 --- a/APP/ogree_app_backend/server.go +++ b/APP/ogree_app_backend/server.go @@ -16,13 +16,14 @@ import ( ) type backendServer struct { - Host string `json:"host" binding:"required"` - User string `json:"user" binding:"required"` - Password string `json:"password"` - Pkey string `json:"pkey"` - PkeyPass string `json:"pkeypass"` - DstPath string `json:"dstpath" binding:"required"` - RunPort string `json:"runport" binding:"required"` + Host string `json:"host" binding:"required"` + User string `json:"user" binding:"required"` + Password string `json:"password"` + Pkey string `json:"pkey"` + PkeyPass string `json:"pkeypass"` + DstPath string `json:"dstpath" binding:"required"` + RunPort string `json:"runport" binding:"required"` + AtStartup bool `json:"startup"` } // Add a binary of this same backend in another server @@ -100,13 +101,29 @@ func createNewBackend(c *gin.Context) { } SSHRunCmd("mkdir -p "+newServer.DstPath+"/docker", conn, true) + SSHRunCmd("mkdir -p "+newServer.DstPath+"/backend-assets", conn, true) + SSHRunCmd("mkdir -p "+newServer.DstPath+"/flutter-assets", conn, true) SSHCopyFile("ogree_app_backend_linux", newServer.DstPath+"/ogree_app_backend", conn) - SSHCopyFile("docker-env-template.txt", newServer.DstPath+"/docker-env-template.txt", conn) + SSHCopyFile("backend-assets/docker-env-template.txt", newServer.DstPath+"/backend-assets/docker-env-template.txt", conn) + SSHCopyFile("backend-assets/template.service", newServer.DstPath+"/backend-assets/template.service", conn) + SSHCopyFile("flutter-assets/flutter-env-template.txt", newServer.DstPath+"/flutter-assets/flutter-env-template.txt", conn) + SSHCopyFile("flutter-assets/logo.png", newServer.DstPath+"/flutter-assets/logo.png", conn) SSHCopyFile(".envcopy", newServer.DstPath+"/.env", conn) SSHCopyFile(DOCKER_DIR+"docker-compose.yml", newServer.DstPath+"/docker/docker-compose.yml", conn) SSHCopyFile(DEPLOY_DIR+"createdb.js", newServer.DstPath+"/createdb.js", conn) SSHCopyFile(DOCKER_DIR+"init.sh", newServer.DstPath+"/docker/init.sh", conn) + if newServer.AtStartup { + // Create service file and send it to server + file, _ := os.Create("ogree_app_backend.service") + err = servertmplt.Execute(file, newServer) + if err != nil { + fmt.Println("Error creating service file: " + err.Error()) + } + file.Close() + SSHCopyFile("ogree_app_backend.service", "/etc/systemd/system/ogree_app_backend.service", conn) + SSHRunCmd("systemctl enable ogree_app_backend.service", conn, true) + } SSHRunCmd("chmod +x "+newServer.DstPath+"/ogree_app_backend", conn, true) SSHRunCmd("cd "+newServer.DstPath+" && nohup "+newServer.DstPath+"/ogree_app_backend -port "+newServer.RunPort+" > "+newServer.DstPath+"/ogree_backend.out", conn, false) @@ -118,6 +135,7 @@ func SSHCopyFile(srcPath, dstPath string, client *ssh.Client) error { // open an SFTP session over an existing ssh connection. sftp, err := sftp.NewClient(client) if err != nil { + println(err.Error()) return err } defer sftp.Close() @@ -125,6 +143,7 @@ func SSHCopyFile(srcPath, dstPath string, client *ssh.Client) error { // Open the source file srcFile, err := os.Open(srcPath) if err != nil { + println(err.Error()) return err } defer srcFile.Close() @@ -132,12 +151,14 @@ func SSHCopyFile(srcPath, dstPath string, client *ssh.Client) error { // Create the destination file dstFile, err := sftp.Create(dstPath) if err != nil { + println(err.Error()) return err } defer dstFile.Close() // write to file if _, err := dstFile.ReadFrom(srcFile); err != nil { + println(err.Error()) return err } return nil diff --git a/APP/ogree_app_backend/tenant.go b/APP/ogree_app_backend/tenant.go index 202fe1ede..8addc794c 100644 --- a/APP/ogree_app_backend/tenant.go +++ b/APP/ogree_app_backend/tenant.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "os" @@ -25,6 +26,8 @@ type tenant struct { DocPort string `json:"docPort"` HasWeb bool `json:"hasWeb"` HasDoc bool `json:"hasDoc"` + AssetsDir string `json:"assetsDir"` + ImageTag string `json:"imageTag"` } type container struct { @@ -141,6 +144,33 @@ func addTenant(c *gin.Context) { c.IndentedJSON(http.StatusBadRequest, err.Error()) return } else { + tenantLower := strings.ToLower(newTenant.Name) + + // Image tagging + if newTenant.ImageTag == "" { + newTenant.ImageTag = "latest" + } + + // Docker compose prepare + args := []string{"compose", "-p", tenantLower} + if newTenant.HasWeb { + args = append(args, "--profile") + args = append(args, "web") + // Create flutter assets folder + newTenant.AssetsDir = DOCKER_DIR + "app-deploy/" + tenantLower + addAppAssets(newTenant) + } else { + // docker does not accept it empty, even if it wont be created + newTenant.AssetsDir = DOCKER_DIR + } + if newTenant.HasDoc { + args = append(args, "--profile") + args = append(args, "doc") + } + args = append(args, "up") + args = append(args, "--build") + args = append(args, "-d") + // Create .env file file, _ := os.Create(DOCKER_DIR + ".env") err = tmplt.Execute(file, newTenant) @@ -148,8 +178,8 @@ func addTenant(c *gin.Context) { panic(err) } file.Close() - // Create .env copy - file, _ = os.Create(DOCKER_DIR + strings.ToLower(newTenant.Name) + ".env") + // Create tenantName.env as a copy + file, _ = os.Create(DOCKER_DIR + tenantLower + ".env") err = tmplt.Execute(file, newTenant) if err != nil { fmt.Println("Error creating .env copy: " + err.Error()) @@ -158,19 +188,8 @@ func addTenant(c *gin.Context) { println("Run docker (may take a long time...)") - // Docker compose up - args := []string{"-p", strings.ToLower(newTenant.Name)} - if newTenant.HasWeb { - args = append(args, "--profile") - args = append(args, "web") - } - if newTenant.HasDoc { - args = append(args, "--profile") - args = append(args, "doc") - } - args = append(args, "up") - args = append(args, "-d") - cmd := exec.Command("docker-compose", args...) + // Run docker + cmd := exec.Command("docker", args...) cmd.Dir = DOCKER_DIR var stderr bytes.Buffer cmd.Stderr = &stderr @@ -184,15 +203,77 @@ func addTenant(c *gin.Context) { // Add to local json and respond listTenants = append(listTenants, newTenant) data, _ := json.MarshalIndent(listTenants, "", " ") - _ = ioutil.WriteFile("tenants.json", data, 0644) + _ = ioutil.WriteFile("tenants.json", data, 0755) c.IndentedJSON(http.StatusOK, "all good") } } +func addAppAssets(newTenant tenant) { + // Create flutter assets folder with .env + err := os.MkdirAll(newTenant.AssetsDir, 0755) + if err != nil && !strings.Contains(err.Error(), "already") { + println(err.Error()) + } + file, err := os.Create(newTenant.AssetsDir + "/.env") + if err != nil { + println(err.Error()) + } + err = apptmplt.Execute(file, newTenant) + if err != nil { + println(err.Error()) + } + file.Close() + + // Add default logo if none already present + userLogo := newTenant.AssetsDir + "/logo.png" + defaultLogo := "flutter-assets/logo.png" + if _, err := os.Stat(userLogo); err == nil { + println("Logo already exists") + } else { + println("Setting logo by default") + source, err := os.Open(defaultLogo) + if err != nil { + println("Error opening default logo") + } + defer source.Close() + destination, err := os.Create(userLogo) + if err != nil { + println("Error creating tenant logo file") + } + defer destination.Close() + _, err = io.Copy(destination, source) + if err != nil { + println("Error creating tenant logo") + } + } +} + +func addTenantLogo(c *gin.Context) { + tenantName := strings.ToLower(c.Param("name")) + // Load image + formFile, err := c.FormFile("file") + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + } + // Make sure destination dir is created + assetsDir := DOCKER_DIR + "app-deploy/" + tenantName + err = os.MkdirAll(assetsDir, 0755) + if err != nil && !strings.Contains(err.Error(), "already") { + c.String(http.StatusInternalServerError, err.Error()) + } + // Save image + err = c.SaveUploadedFile(formFile, assetsDir+"/logo.png") + if err != nil { + c.String(http.StatusInternalServerError, err.Error()) + } + c.String(http.StatusOK, "") +} + func removeTenant(c *gin.Context) { - tenantName := c.Param("name") + tenantName := strings.ToLower(c.Param("name")) + // Stop and remove containers for _, str := range []string{"_webapp", "_api", "_db", "_doc"} { cmd := exec.Command("docker", "rm", "--force", strings.ToLower(tenantName)+str) cmd.Dir = DOCKER_DIR @@ -205,6 +286,10 @@ func removeTenant(c *gin.Context) { } } + // Remove assets + os.RemoveAll(DOCKER_DIR + "app-deploy/" + tenantName) + os.Remove(DOCKER_DIR + tenantName + ".env") + // Update local file data, e := ioutil.ReadFile("tenants.json") if e != nil { @@ -218,6 +303,6 @@ func removeTenant(c *gin.Context) { } } data, _ = json.MarshalIndent(listTenants, "", " ") - _ = ioutil.WriteFile("tenants.json", data, 0644) + _ = ioutil.WriteFile("tenants.json", data, 0755) c.IndentedJSON(http.StatusOK, "all good") } diff --git a/CLI/ast.go b/CLI/ast.go index 865baedc4..cd3386566 100644 --- a/CLI/ast.go +++ b/CLI/ast.go @@ -1333,6 +1333,88 @@ func (n *createOrphanNode) execute() (interface{}, error) { return nil, nil } +type createUserNode struct { + email node + role node + domain node +} + +func (n *createUserNode) execute() (interface{}, error) { + emailVal, err := n.email.execute() + if err != nil { + return nil, err + } + email, ok := emailVal.(string) + if !ok { + return nil, fmt.Errorf("email should be a string") + } + roleVal, err := n.role.execute() + if err != nil { + return nil, err + } + role, ok := roleVal.(string) + if !ok { + return nil, fmt.Errorf("role should be a string") + } + domainVal, err := n.domain.execute() + if err != nil { + return nil, err + } + domain, ok := domainVal.(string) + if !ok { + return nil, fmt.Errorf("domain should be a string") + } + err = cmd.CreateUser(email, role, domain) + if err != nil { + return nil, err + } + return nil, nil +} + +type addRoleNode struct { + email node + role node + domain node +} + +func (n *addRoleNode) execute() (interface{}, error) { + emailVal, err := n.email.execute() + if err != nil { + return nil, err + } + email, ok := emailVal.(string) + if !ok { + return nil, fmt.Errorf("email should be a string") + } + roleVal, err := n.role.execute() + if err != nil { + return nil, err + } + role, ok := roleVal.(string) + if !ok { + return nil, fmt.Errorf("role should be a string") + } + domainVal, err := n.domain.execute() + if err != nil { + return nil, err + } + domain, ok := domainVal.(string) + if !ok { + return nil, fmt.Errorf("domain should be a string") + } + err = cmd.AddRole(email, role, domain) + if err != nil { + return nil, err + } + return nil, nil +} + +type changePasswordNode struct{} + +func (n *changePasswordNode) execute() (interface{}, error) { + return nil, cmd.ChangePassword() +} + type uiDelayNode struct { time float64 } diff --git a/CLI/config/config.go b/CLI/config/config.go index 0a5c7f789..02a568561 100644 --- a/CLI/config/config.go +++ b/CLI/config/config.go @@ -34,7 +34,6 @@ type Config struct { DrawLimit int Updates []string User string - APIKEY string Variables []Vardef } @@ -45,7 +44,6 @@ type ArgStruct struct { Verbose string `json:",omitempty"` UnityURL string `json:",omitempty"` APIURL string `json:",omitempty"` - APIKEY string `json:",omitempty"` HistPath string `json:",omitempty"` Script string `json:",omitempty"` } @@ -64,7 +62,6 @@ func defaultConfig() Config { DrawLimit: 50, Updates: []string{"all"}, User: "", - APIKEY: "", Variables: []Vardef{}, } } @@ -84,7 +81,6 @@ func ReadConfig() *Config { "{NONE,ERROR,WARNING,INFO,DEBUG}.") flag.StringVarP(&args.UnityURL, "unity_url", "u", conf.UnityURL, "Unity URL") flag.StringVarP(&args.APIURL, "api_url", "a", conf.APIURL, "API URL") - flag.StringVarP(&args.APIKEY, "api_key", "k", conf.APIKEY, "Indicate the key of the API") flag.StringVarP(&args.HistPath, "history_path", "h", conf.HistPath, "Indicate the location of the Shell's history file") flag.StringVarP(&args.Script, "file", "f", conf.Script, "Launch the shell as an interpreter "+ diff --git a/CLI/controllers/commandController.go b/CLI/controllers/commandController.go index c9ab30321..b739f69ec 100755 --- a/CLI/controllers/commandController.go +++ b/CLI/controllers/commandController.go @@ -4,12 +4,14 @@ import ( "cli/logger" l "cli/logger" "cli/models" + "cli/readline" "cli/utils" u "cli/utils" "encoding/hex" "encoding/json" "fmt" "log" + "math/rand" "net/http" "os" "os/exec" @@ -428,7 +430,7 @@ func UpdateObj(Path, id, ent string, data map[string]interface{}, deleteAndPut b //we don't want to update the wrong object objJSON, GETURL = GetObject(Path, true) if objJSON == nil { - if State.DebugLvl >= 3 { + if State.DebugLvl > INFO { println("DEBUG VIEW PATH:", Path) println("DEBUG VIEW URL:", GETURL) } @@ -848,7 +850,7 @@ func LSOG() { fmt.Println("OGREE Shell Information") fmt.Println("********************************************") - fmt.Println("USER EMAIL:", State.UserEmail) + fmt.Println("USER EMAIL:", State.User.Email) fmt.Println("API URL:", State.APIURL+"/api/") fmt.Println("UNITY URL:", State.UnityClientURL) fmt.Println("BUILD DATE:", BuildTime) @@ -1387,7 +1389,6 @@ func GetHierarchy(x string, depth int, silence bool) []map[string]interface{} { func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error { var attr map[string]interface{} var parent map[string]interface{} - var domain string ogPath := Path Path = path.Dir(Path) @@ -1410,11 +1411,18 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error } } - // Set default domain - if parent != nil { - domain = parent["domain"].(string) - } else if ent != DOMAIN { - domain = State.Customer + if ent == DOMAIN { + if parent != nil { + data["domain"] = parent["name"] + } else { + data["domain"] = "" + } + } else { + if parent != nil { + data["domain"] = parent["domain"] + } else { + data["domain"] = State.Customer + } } var err error @@ -1422,15 +1430,12 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error case DOMAIN: if parent != nil { data["parentId"] = parent["id"] - data["domain"] = parent["name"] } else { data["parentId"] = "" - data["domain"] = "" } case SITE: //Default values - data["domain"] = domain //data["parentId"] = parent["id"] data["attributes"] = map[string]interface{}{} @@ -1496,7 +1501,6 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error attr["heightUnit"] = "m" //attr["height"] = 0 //Should be set from parser by default data["parentId"] = parent["id"] - data["domain"] = domain case ROOM: attr = data["attributes"].(map[string]interface{}) @@ -1552,7 +1556,6 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error } data["parentId"] = parent["id"] - data["domain"] = domain data["attributes"] = attr if State.DebugLvl >= 3 { println("DEBUG VIEW THE JSON") @@ -1620,7 +1623,6 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error } data["parentId"] = parent["id"] - data["domain"] = domain data["attributes"] = attr case DEVICE: @@ -1709,13 +1711,11 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error MergeMaps(attr, baseAttrs, false) - data["domain"] = domain data["parentId"] = parent["id"] data["attributes"] = attr case GROUP: //name, category, domain, pid - data["domain"] = domain data["parentId"] = parent["id"] attr := data["attributes"].(map[string]interface{}) @@ -1725,7 +1725,6 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error case CORRIDOR: //name, category, domain, pid attr = data["attributes"].(map[string]interface{}) - data["domain"] = domain data["parentId"] = parent["id"] case STRAYSENSOR: @@ -1739,7 +1738,6 @@ func GetOCLIAtrributes(Path string, ent int, data map[string]interface{}) error } case STRAY_DEV: - data["domain"] = State.Customer attr = data["attributes"].(map[string]interface{}) if _, ok := attr["template"]; ok { GetOCLIAtrributesTemplateHelper(attr, data, DEVICE) @@ -3545,3 +3543,97 @@ func fetchTemplate(name string, objType int) map[string]interface{} { return nil } + +func randPassword(n int) string { + const passChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + for i := range b { + b[i] = passChars[rand.Intn(len(passChars))] + } + return string(b) +} + +func CreateUser(email string, role string, domain string) error { + password := randPassword(14) + response, err := RequestAPI( + "POST", + "/api/users", + map[string]any{ + "email": email, + "password": password, + "roles": map[string]any{ + domain: role, + }, + }, + http.StatusCreated, + ) + if err != nil { + return err + } + println(response.message) + println("password:" + password) + return nil +} + +func AddRole(email string, role string, domain string) error { + response, err := RequestAPI("GET", "/api/users", nil, http.StatusOK) + if err != nil { + return err + } + userList, userListOk := response.body["data"].([]any) + if !userListOk { + return fmt.Errorf("response contains no user list") + } + userID := "" + for _, user := range userList { + userMap, ok := user.(map[string]any) + if !ok { + continue + } + userEmail, emailOk := userMap["email"].(string) + id, idOk := userMap["_id"].(string) + if emailOk && idOk && userEmail == email { + userID = id + break + } + } + if userID == "" { + return fmt.Errorf("user not found") + } + response, err = RequestAPI("PATCH", fmt.Sprintf("/api/users/%s", userID), + map[string]any{ + "roles": map[string]any{ + domain: role, + }, + }, + http.StatusOK, + ) + if err != nil { + return err + } + println(response.message) + return nil +} + +func ChangePassword() error { + currentPassword, err := readline.Password("Current password: ") + if err != nil { + return err + } + newPassword, err := readline.Password("New password: ") + if err != nil { + return err + } + response, err := RequestAPI("POST", "/api/users/password/change", + map[string]any{ + "currentPassword": string(currentPassword), + "newPassword": string(newPassword), + }, + http.StatusOK, + ) + if err != nil { + return err + } + println(response.message) + return nil +} diff --git a/CLI/controllers/controllerUtils.go b/CLI/controllers/controllerUtils.go index 47aa2e468..8c0a477c7 100644 --- a/CLI/controllers/controllerUtils.go +++ b/CLI/controllers/controllerUtils.go @@ -4,6 +4,7 @@ package controllers //controller package //And const definitions used throughout the controllers package import ( + "cli/models" "encoding/json" "fmt" ) @@ -272,3 +273,19 @@ func GetParentOfEntity(ent int) int { return -3 } } + +func RequestAPI(method string, endpoint string, body map[string]any, expectedStatus int) (Response, error) { + URL := State.APIURL + endpoint + httpResponse, err := models.Send(method, URL, GetKey(), body) + if err != nil { + return Response{}, err + } + response, err := ParseResponseClean(httpResponse) + if err != nil { + return Response{}, err + } + if response.status != expectedStatus { + return Response{}, fmt.Errorf(response.message) + } + return response, nil +} diff --git a/CLI/controllers/initController.go b/CLI/controllers/initController.go index d52304fe0..d7447779f 100755 --- a/CLI/controllers/initController.go +++ b/CLI/controllers/initController.go @@ -10,11 +10,10 @@ import ( "cli/utils" "encoding/json" "fmt" + "io" "net/url" "os" "path/filepath" - "regexp" - "strconv" "strings" "time" ) @@ -241,19 +240,8 @@ func InitTimeout(duration string) { } } -func InitEmail(email string) string { - if email != "" { - State.UserEmail = email - return State.UserEmail - } - fmt.Println("Error: No User Email Found") - if State.DebugLvl > 0 { - l.GetErrorLogger().Println( - "No User Email provided in env file nor as argument") - } +func InitUser(user User) { - State.UserEmail = "" - return "" } func InitKey(apiKey string) { @@ -350,92 +338,43 @@ func SetDrawableTemplate(entity string, DrawableJson map[string]string) map[stri return nil } -func CreateCredentials() (string, string) { - var tp map[string]interface{} - - user, _ := readline.Line("Please Enter desired user email: ") - pass, _ := readline.Password("Please Enter desired password: ") - data := map[string]interface{}{"email": user, "password": string(pass)} - - resp, e := models.Send("POST", State.APIURL+"/api", "", data) - tp = ParseResponse(resp, e, "Create credentials") - if tp == nil { - println(e.Error()) - os.Exit(-1) - } - - if tp["status"] != nil { - if !tp["status"].(bool) { - errMessage := "Error while creating credentials : " + tp["message"].(string) - if State.DebugLvl > 0 { - println(errMessage) - } - l.GetErrorLogger().Println(errMessage) - os.Exit(-1) +func Login(user string) (*User, string, error) { + var err error + if user == "" { + user, err = readline.Line("User: ") + if err != nil { + return nil, "", fmt.Errorf("readline error : %s", err.Error()) } - } else { - if State.DebugLvl > 0 { - println("An error occurred while creating credentials") - l.GetErrorLogger().Println("Could not read API status on create credential attempt") - } - os.Exit(-1) } - - token := (tp["account"].(map[string]interface{}))["token"].(string) - l.GetInfoLogger().Println("Credentials created") - return user, token -} - -func CheckEmailIsValid(email string) bool { - emailRegex := "(\\w)+@(\\w)+\\.(\\w)+" - return regexp.MustCompile(emailRegex).MatchString(email) -} - -func CheckKeyIsValid(key string) bool { - resp, err := models.Send("GET", State.APIURL+"/api/token/valid", key, nil) + pass, err := readline.Password("Password: ") if err != nil { - if State.DebugLvl > 0 { - l.GetErrorLogger().Println("Unable to connect to API: ", State.APIURL) - l.GetErrorLogger().Println(err.Error()) - println(err.Error()) - } - return false + return nil, "", err } - if resp.StatusCode != 200 { - println("HTTP Response Status code : " + strconv.Itoa(resp.StatusCode)) - if State.DebugLvl > NONE { - x := ParseResponse(resp, err, " Read API Response message") - if x != nil { - if x["message"] != nil && x["message"] != "" { - println("[API] " + x["message"].(string)) - } else { - println("Was not able to read API Response message") - } - } else { - println("Was not able to read API Response message") - } - } - return false + data := map[string]any{"email": user, "password": string(pass)} + rawResp, err := models.Send("POST", State.APIURL+"/api/login", "", data) + if err != nil { + return nil, "", fmt.Errorf("error sending login request : %s", err.Error()) } - return true -} - -func Login(user string, key string) (string, string) { - if key == "" || !CheckEmailIsValid(user) || !CheckKeyIsValid(key) { - l.GetInfoLogger().Println("Credentials not found or invalid, going to generate..") - if State.DebugLvl > NONE { - println("Credentials not found or invalid, going to generate..") - } - user, key = CreateCredentials() - fmt.Printf("Here is your api key, you can save it to your config.toml file :\n%s\n", key) + bodyBytes, err := io.ReadAll(rawResp.Body) + if err != nil { + return nil, "", fmt.Errorf("error reading answer from API : %s", err.Error()) } - if !CheckKeyIsValid(key) { - if State.DebugLvl > 0 { - println("Error while checking key. Now exiting") - } - l.GetErrorLogger().Println("Error while checking key. Now exiting") - os.Exit(-1) + var resp map[string]any + if err = json.Unmarshal(bodyBytes, &resp); err != nil { + return nil, "", fmt.Errorf("error parsing response : %s", err.Error()) + } + status, ok := resp["status"].(bool) + if !ok { + return nil, "", fmt.Errorf("invalid response from API") + } + if !status { + return nil, "", fmt.Errorf(resp["message"].(string)) + } + account, accountOk := (resp["account"].(map[string]interface{})) + token, tokenOk := account["token"].(string) + userID, userIDOk := account["_id"].(string) + if !accountOk || !tokenOk || !userIDOk { + return nil, "", fmt.Errorf("invalid response from API") } - l.GetInfoLogger().Println("Successfully Logged In") - return user, key + return &User{user, userID}, token, nil } diff --git a/CLI/controllers/responseSchemaController.go b/CLI/controllers/responseSchemaController.go index b6e63eebc..7ceea59c2 100644 --- a/CLI/controllers/responseSchemaController.go +++ b/CLI/controllers/responseSchemaController.go @@ -13,6 +13,30 @@ import ( "os" ) +type Response struct { + status int + message string + body map[string]any +} + +func ParseResponseClean(response *http.Response) (Response, error) { + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return Response{}, err + } + defer response.Body.Close() + responseBody := map[string]interface{}{} + err = json.Unmarshal(bodyBytes, &responseBody) + if err != nil { + return Response{}, err + } + message, messageOk := responseBody["message"].(string) + if responseBody == nil || !messageOk { + return Response{}, fmt.Errorf("invalid response") + } + return Response{response.StatusCode, message, responseBody}, nil +} + func ParseResponse(resp *http.Response, e error, purpose string) map[string]interface{} { ans := map[string]interface{}{} diff --git a/CLI/controllers/stateController.go b/CLI/controllers/stateController.go index dda2ca02e..433a2eef1 100755 --- a/CLI/controllers/stateController.go +++ b/CLI/controllers/stateController.go @@ -17,6 +17,11 @@ var BuildTree string var GitCommitDate string var State ShellState +type User struct { + Email string + ID string +} + type ShellState struct { Prompt string BlankPrompt string @@ -28,7 +33,7 @@ type ShellState struct { ConfigPath string //Holds file path of '.env' HistoryFilePath string //Holds file path of '.history' UnityClientURL string - UserEmail string + User User APIURL string APIKEY string UnityClientAvail bool //For deciding to message unity or not @@ -132,8 +137,7 @@ func getNextInPath(name string, root *Node) *Node { // storing objects in a tree and returns string arr func FetchNodesAtLevel(Path string) []string { names := []string{} - urls := []string{} - + var urls []string paths := strings.Split(path.Clean(Path), "/") /*if len(paths) == 1 || len(paths) == 0 { @@ -141,18 +145,18 @@ func FetchNodesAtLevel(Path string) []string { }*/ if len(paths) == 2 && paths[1] == "Physical" { - names = NodesAtLevel(&State.TreeHierarchy, *StrToStack(Path)) + names = NodesAtLevel(State.TreeHierarchy, *StrToStack(Path)) urls = []string{State.APIURL + "/api/sites"} } else { if len(paths) == 3 && paths[2] == "Stray" { - names = NodesAtLevel(&State.TreeHierarchy, *StrToStack(Path)) + names = NodesAtLevel(State.TreeHierarchy, *StrToStack(Path)) } if len(paths) < 3 { // /Physical or / or /Logical //println("Should be here") //println("LEN:", len(paths)) //println("YO DEBUG", path) - return NodesAtLevel(&State.TreeHierarchy, *StrToStack(Path)) + return NodesAtLevel(State.TreeHierarchy, *StrToStack(Path)) } // 2: since first idx is useless @@ -207,16 +211,16 @@ func FetchJsonNodesAtLevel(Path string) []map[string]interface{} { paths := strings.Split(path.Clean(Path), "/") if len(paths) == 2 && paths[1] == "Physical" { - x := NodesAtLevel(&State.TreeHierarchy, *StrToStack(Path)) + x := NodesAtLevel(State.TreeHierarchy, *StrToStack(Path)) objects = append(objects, strArrToMapStrInfArr(x)...) urls = []string{State.APIURL + "/api/sites"} } else { if len(paths) == 3 && paths[2] == "Stray" || len(paths) < 3 { - x := NodesAtLevel(&State.TreeHierarchy, *StrToStack(Path)) + x := NodesAtLevel(State.TreeHierarchy, *StrToStack(Path)) return strArrToMapStrInfArr(x) } - if len(paths) == 3 && paths[2] == "Domain" { + if len(paths) >= 3 && paths[2] == "Domain" { urls = []string{State.APIURL + "/api/domains"} } @@ -274,12 +278,31 @@ func FetchJsonNodesAtLevel(Path string) []map[string]interface{} { objects = append(objects, object) } } - } - } } } + if len(paths) >= 3 && paths[2] == "Domain" { + parentHierarchyName := Path[len("/Organisation/Domain"):] + if len(parentHierarchyName) > 0 && parentHierarchyName[0] == '/' { + parentHierarchyName = parentHierarchyName[1:] + } + parentHierarchyName = strings.Replace(parentHierarchyName, "/", ".", -1) + filteredObjects := []map[string]any{} + for _, obj := range objects { + hierarchyName := obj["hierarchyName"].(string) + if strings.HasPrefix(hierarchyName, parentHierarchyName) { + suffix := hierarchyName[len(parentHierarchyName):] + if len(suffix) > 0 && suffix[0] == '.' { + suffix = suffix[1:] + } + if !strings.Contains(suffix, ".") { + filteredObjects = append(filteredObjects, obj) + } + } + } + return filteredObjects + } return objects } @@ -388,10 +411,10 @@ func FindNearestNodeInTree(root **Node, path *Stack, silenced bool) **Node { } } -func NodesAtLevel(root **Node, x Stack) []string { +func NodesAtLevel(root *Node, x Stack) []string { if x.Len() > 0 { name := x.Peek() - node := getNextInPath(name.(string), *root) + node := getNextInPath(name.(string), root) if node == nil { if State.DebugLvl > 0 { println("Name doesn't exist! ", string(name.(string))) @@ -401,17 +424,16 @@ func NodesAtLevel(root **Node, x Stack) []string { return nil } x.Pop() - return NodesAtLevel(&node, x) + return NodesAtLevel(node, x) } else { var items = make([]string, 0) var nm string //println("This is what we got:") - for i := (*root).Nodes.Front(); i != nil; i = i.Next() { + for i := root.Nodes.Front(); i != nil; i = i.Next() { nm = string(i.Value.(*Node).Name) //println(nm) items = append(items, nm) } return items } - return nil } diff --git a/CLI/main.go b/CLI/main.go index 0ec25497a..0afe3148d 100644 --- a/CLI/main.go +++ b/CLI/main.go @@ -22,24 +22,32 @@ func main() { l.InitLogs() c.InitConfigFilePath(conf.ConfigPath) c.InitHistoryFilePath(conf.HistPath) - c.InitDebugLevel(conf.Verbose) //Set the Debug level - c.InitTimeout(conf.UnityTimeout) //Set the Unity Timeout - c.InitURLs(conf.APIURL, conf.UnityURL) //Set the URLs + c.InitDebugLevel(conf.Verbose) + c.InitTimeout(conf.UnityTimeout) + c.InitURLs(conf.APIURL, conf.UnityURL) - conf.User, conf.APIKEY = c.Login(conf.User, conf.APIKEY) - c.InitEmail(conf.User) //Set the User email - c.InitKey(conf.APIKEY) //Set the API Key + var err error + var apiKey string + user, apiKey, err := c.Login(conf.User) + if err != nil { + println(err.Error()) + return + } else { + println("Successfully connected") + } + c.State.User = *user + c.InitKey(apiKey) c.InitState(conf) - err := InitVars(conf.Variables) + err = InitVars(conf.Variables) if err != nil { println("Error while initializing variables :", err.Error()) return } - user := strings.Split(conf.User, "@")[0] + userShort := strings.Split(c.State.User.Email, "@")[0] rl, err := readline.NewEx(&readline.Config{ - Prompt: SetPrompt(user), + Prompt: SetPrompt(userShort), HistoryFile: c.State.HistoryFilePath, AutoComplete: GetPrefixCompleter(), InterruptPrompt: "^C", @@ -65,5 +73,5 @@ func main() { } c.InitUnityCom(rl, c.State.UnityClientURL) //Pass control to repl.go - Start(rl, user) + Start(rl, userShort) } diff --git a/CLI/models/com.go b/CLI/models/com.go index e8a7d8744..374ba6489 100755 --- a/CLI/models/com.go +++ b/CLI/models/com.go @@ -7,28 +7,16 @@ import ( ) // Function helps with API Requests -func Send(method, URL, key string, data map[string]interface{}) (*http.Response, - error) { - //Loop because sometimes a - //Stream Error occurs - //thus give max 400 attempts before returning error - sender := func(method, URL, key string, data map[string]interface{}) (*http.Response, error) { - client := &http.Client{} - dataJSON, _ := json.Marshal(data) - - req, _ := http.NewRequest(method, URL, bytes.NewBuffer(dataJSON)) - req.Header.Set("Authorization", "Bearer "+key) - return client.Do(req) +func Send(method, URL, key string, data map[string]any) (*http.Response, error) { + client := &http.Client{} + dataJSON, err := json.Marshal(data) + if err != nil { + return nil, err } - - for i := 0; ; i++ { - r, e := sender(method, URL, key, data) - if e == nil { - return r, e - } - - if i == 400 { - return r, e - } + req, err := http.NewRequest(method, URL, bytes.NewBuffer(dataJSON)) + if err != nil { + return nil, err } + req.Header.Set("Authorization", "Bearer "+key) + return client.Do(req) } diff --git a/CLI/parser.go b/CLI/parser.go index 06e2a0423..9d265cdfa 100644 --- a/CLI/parser.go +++ b/CLI/parser.go @@ -1021,6 +1021,26 @@ func (p *parser) parseCreateOrphan() node { return &createOrphanNode{path, template} } +func (p *parser) parseCreateUser() node { + defer un(trace(p, "create user")) + email := p.parseString("email") + p.expect("@") + role := p.parseString("role") + p.expect("@") + domain := p.parseString("domain") + return &createUserNode{email, role, domain} +} + +func (p *parser) parseAddRole() node { + defer un(trace(p, "add role")) + email := p.parseString("email") + p.expect("@") + role := p.parseString("role") + p.expect("@") + domain := p.parseString("domain") + return &addRoleNode{email, role, domain} +} + func (p *parser) parseUpdate() node { defer un(trace(p, "update")) path := p.parsePath("") @@ -1138,6 +1158,8 @@ func (p *parser) parseCommand(name string) node { "group": p.parseCreateGroup, "gr": p.parseCreateGroup, "orphan": p.parseCreateOrphan, + "user": p.parseCreateUser, + "role": p.parseAddRole, } noArgsCommands = map[string]node{ "selection": &selectNode{}, @@ -1147,6 +1169,7 @@ func (p *parser) parseCommand(name string) node { "lsenterprise": &lsenterpriseNode{}, "pwd": &pwdNode{}, "exit": &exitNode{}, + "changepw": &changePasswordNode{}, } commands := []node{} var command node diff --git a/CLI/repl.go b/CLI/repl.go index 1bb457286..52de31aed 100644 --- a/CLI/repl.go +++ b/CLI/repl.go @@ -14,6 +14,7 @@ import ( l "cli/logger" "cli/readline" "fmt" + "strings" ) func InterpretLine(str string) { @@ -32,7 +33,12 @@ func InterpretLine(str string) { if traceErr, ok := err.(*stackTraceError); ok { fmt.Println(traceErr.Error()) } else { - fmt.Println("\033[31m" + "Error : " + "\033[0m" + err.Error()) + errMsg := err.Error() + if strings.Contains(strings.ToLower(errMsg), "error") { + fmt.Println(errMsg) + } else { + fmt.Println("Error :", errMsg) + } } } } diff --git a/deploy/README.md b/deploy/README.md index 9a6661149..c6f2b4874 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -17,9 +17,11 @@ API_BUILD_DIR=API APP_BUILD_DIR=APP API_DOC_UI_PORT=8075 API_PORT=3001 -WEB_PORT=8082 +WEB_PORT=8083 CUSTOMER_API_PASSWORD=pass123 API_EXTERNALURL=localhost +APP_ASSETS_DIR=../../APP/ogree_app/assets/custom +IMAGE_TAG=latest ``` ## Manually diff --git a/deploy/createdb.js b/deploy/createdb.js index bccaae1be..fbf582316 100644 --- a/deploy/createdb.js +++ b/deploy/createdb.js @@ -128,9 +128,10 @@ db.group.createIndex({parentId:1, name:1}, { unique: true }); //Enforce unique stray objects db.stray_device.createIndex({parentId:1,name:1}, { unique: true }); -//Create a default domain +//Create a default domain and user db.domain.insertOne({name: DB_NAME, hierarchyName: DB_NAME, category: "domain", attributes:{color:"ffffff"}, description:[], createdData: new Date(), lastUpdated: new Date()}) +db.account.insertOne({email: "admin", password: "admin", roles: {"*": "manager"}}) // Create API User db.createUser({ user: "ogree"+DB_NAME+"Admin", pwd: CUSTOMER_API_PASSWORD, diff --git a/deploy/docker/.env b/deploy/docker/.env index adcd3d0e3..594ebce24 100644 --- a/deploy/docker/.env +++ b/deploy/docker/.env @@ -1,9 +1,11 @@ CORE_DIR=../.. +#CORE_DIR=https://github.com/ditrit/OGrEE-Core.git#main API_BUILD_DIR=API -CLI_BUILD_DIR=CLI -APP_BUILD_DIR=https://github.com/ditrit/OGrEE-APP.git#main +APP_BUILD_DIR=APP API_DOC_UI_PORT=8075 API_PORT=3001 -WEB_PORT=8082 +WEB_PORT=8083 CUSTOMER_API_PASSWORD=pass123 API_EXTERNALURL=localhost +APP_ASSETS_DIR=../../APP/ogree_app/assets/custom +IMAGE_TAG=latest diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 222094bea..2ecf89339 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -4,10 +4,10 @@ services: build: context: ${CORE_DIR} dockerfile: ${API_BUILD_DIR}/Dockerfile - image: ogree/api:latest + image: ogree/api:${IMAGE_TAG} container_name: ${COMPOSE_PROJECT_NAME}_api environment: - - api_port=3551 + - api_port=3001 - db_host=${COMPOSE_PROJECT_NAME}_db - db_port=27017 - db_user=${COMPOSE_PROJECT_NAME} @@ -15,7 +15,7 @@ services: - db=${COMPOSE_PROJECT_NAME} - token_password=yourSecretPasswordGoesHere ports: - - ${API_PORT}:3551 + - ${API_PORT}:3001 depends_on: - ogree_db restart: on-failure:10 @@ -40,6 +40,8 @@ services: volumes: - ./init.sh:/docker-entrypoint-initdb.d/init.sh - ../createdb.js:/home/createdb.js + - db:/data/db + restart: on-failure:10 # You will have to retrieve the swagger.json file from # the api root dir and supply it here @@ -58,13 +60,16 @@ services: build: context: ${CORE_DIR} dockerfile: ${APP_BUILD_DIR}/Dockerfile - args: - - API_URL=http://${API_EXTERNALURL}:${API_PORT} - image: ogree/webapp:latest + image: ogree/webapp:${IMAGE_TAG} profiles: ["web"] container_name: ${COMPOSE_PROJECT_NAME}_webapp + volumes: + - ${APP_ASSETS_DIR}:/usr/share/nginx/html/assets/assets/custom ports: - ${WEB_PORT}:80 depends_on: - ogree_api restart: on-failure:10 + +volumes: + db: \ No newline at end of file