// Package user managers all users in libredesk - agents and contacts. package user import ( "context" "database/sql" "embed" "encoding/json" "errors" "fmt" "os" "regexp" "strings" "sync" "log" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" rmodels "github.com/abhinavxd/libredesk/internal/role/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/abhinavxd/libredesk/internal/user/models" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" "github.com/lib/pq" "github.com/volatiletech/null/v9" "github.com/zerodha/logf" "golang.org/x/crypto/bcrypt" ) var ( //go:embed queries.sql efs embed.FS minPassword = 10 maxPassword = 72 maxListPageSize = 500 // ErrPasswordTooLong is returned when the password passed to // GenerateFromPassword is too long (i.e. > 72 bytes). ErrPasswordTooLong = errors.New("password length exceeds 72 bytes") PasswordHint = fmt.Sprintf("Password must be %d-%d characters long should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", minPassword, maxPassword) ) // Manager handles user-related operations. type Manager struct { lo *logf.Logger i18n *i18n.I18n q queries db *sqlx.DB agentCache map[int]models.User agentCacheMu sync.RWMutex } // Opts contains options for initializing the Manager. type Opts struct { DB *sqlx.DB Lo *logf.Logger } // queries contains prepared SQL queries. type queries struct { GetUser *sqlx.Stmt `query:"get-user"` GetNotes *sqlx.Stmt `query:"get-notes"` GetNote *sqlx.Stmt `query:"get-note"` GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"` GetUsersCompact string `query:"get-users-compact"` UpdateContact *sqlx.Stmt `query:"update-contact"` UpdateContactBasicInfo *sqlx.Stmt `query:"update-contact-basic-info"` UpdateAgent *sqlx.Stmt `query:"update-agent"` UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"` UpsertCustomAttributes *sqlx.Stmt `query:"upsert-custom-attributes"` UpdateAvatar *sqlx.Stmt `query:"update-avatar"` UpdateAvailability *sqlx.Stmt `query:"update-availability"` UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"` UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"` GetAvailabilityStatus *sqlx.Stmt `query:"get-availability-status"` UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"` SoftDeleteAgent *sqlx.Stmt `query:"soft-delete-agent"` SetUserPassword *sqlx.Stmt `query:"set-user-password"` SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"` SetPassword *sqlx.Stmt `query:"set-password"` DeleteNote *sqlx.Stmt `query:"delete-note"` InsertAgent *sqlx.Stmt `query:"insert-agent"` InsertContactWithExtID *sqlx.Stmt `query:"insert-contact-with-external-id"` InsertContactNoExtID *sqlx.Stmt `query:"insert-contact-without-external-id"` GetContactByEmail *sqlx.Stmt `query:"get-contact-by-email"` SetExternalUserID *sqlx.Stmt `query:"set-external-user-id"` InsertNote *sqlx.Stmt `query:"insert-note"` InsertVisitor *sqlx.Stmt `query:"insert-visitor"` GetVisitorByEmail *sqlx.Stmt `query:"get-visitor-by-email"` UpgradeVisitorToContact *sqlx.Stmt `query:"upgrade-visitor-to-contact"` ToggleEnable *sqlx.Stmt `query:"toggle-enable"` // API key queries GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"` SetAPIKey *sqlx.Stmt `query:"set-api-key"` RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"` UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"` MergeVisitorToContact *sqlx.Stmt `query:"merge-visitor-to-contact"` } // New creates and returns a new instance of the Manager. func New(i18n *i18n.I18n, opts Opts) (*Manager, error) { var q queries if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { return nil, fmt.Errorf("error scanning SQL file: %w", err) } return &Manager{ q: q, lo: opts.Lo, i18n: i18n, db: opts.DB, agentCache: make(map[int]models.User), }, nil } // VerifyPassword authenticates an user by email and password, returning the user if successful. func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) { var user models.User if err := u.q.GetUser.Get(&user, 0, email, pq.Array([]string{models.UserTypeAgent})); err != nil { if errors.Is(err, sql.ErrNoRows) { return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) } u.lo.Error("error fetching user from db", "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } if err := u.verifyPassword(password, user.Password.String); err != nil { return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil) } return user, nil } // GetAllUsers returns a list of all users. func (u *Manager) GetAllUsers(page, pageSize int, userTypes []string, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) { query, qArgs, err := u.makeUserListQuery(page, pageSize, userTypes, order, orderBy, filtersJSON) if err != nil { u.lo.Error("error creating user list query", "error", err) return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Start a read-only txn. tx, err := u.db.BeginTxx(context.Background(), &sql.TxOptions{ ReadOnly: true, }) if err != nil { u.lo.Error("error starting read-only transaction", "error", err) return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } defer tx.Rollback() // Execute query var users = make([]models.UserCompact, 0) if err := tx.Select(&users, query, qArgs...); err != nil { u.lo.Error("error fetching users", "error", err) return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return users, nil } // Get retrieves an user by ID or email or type. At least one of ID or email must be provided. func (u *Manager) Get(id int, email string, userType []string) (models.User, error) { if id == 0 && email == "" { return models.User{}, envelope.NewError(envelope.InputError, u.i18n.T("validation.invalidUser"), nil) } var user models.User if err := u.q.GetUser.Get(&user, id, email, pq.Array(userType)); err != nil { if errors.Is(err, sql.ErrNoRows) { return user, envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil) } u.lo.Error("error fetching user from db", "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return user, nil } // GetContactOrVisitor retrieves a user by ID or email that is either a contact or visitor. func (u *Manager) GetContactOrVisitor(id int, email string) (models.User, error) { return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor}) } // GetSystemUser retrieves the system user. func (u *Manager) GetSystemUser() (models.User, error) { return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent}) } // GetByExternalID retrieves a user by external user ID. func (u *Manager) GetByExternalID(externalUserID string) (models.User, error) { var user models.User if err := u.q.GetUserByExternalID.Get(&user, externalUserID); err != nil { if err == sql.ErrNoRows { return user, envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil) } u.lo.Error("error fetching user by external ID", "external_user_id", externalUserID, "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return user, nil } // GetContactByEmail retrieves a contact by email address regardless of external_user_id. func (u *Manager) GetContactByEmail(email string) (models.User, error) { var user models.User if err := u.q.GetContactByEmail.Get(&user, email); err != nil { if err == sql.ErrNoRows { return user, envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil) } u.lo.Error("error fetching contact by email", "email", email, "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return user, nil } // GetVisitorByEmail retrieves a visitor by email address. func (u *Manager) GetVisitorByEmail(email string) (models.User, error) { var user models.User if err := u.q.GetVisitorByEmail.Get(&user, email); err != nil { if err == sql.ErrNoRows { return user, envelope.NewError(envelope.NotFoundError, u.i18n.T("validation.notFoundUser"), nil) } u.lo.Error("error fetching visitor by email", "email", email, "error", err) return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return user, nil } // UpgradeVisitorToContact changes a visitor's type to contact. func (u *Manager) UpgradeVisitorToContact(visitorID int) error { if _, err := u.q.UpgradeVisitorToContact.Exec(visitorID); err != nil { u.lo.Error("error upgrading visitor to contact", "visitor_id", visitorID, "error", err) return fmt.Errorf("upgrading visitor to contact: %w", err) } return nil } // SetExternalUserID sets the external_user_id on an existing contact. func (u *Manager) SetExternalUserID(id int, externalUserID string) error { if _, err := u.q.SetExternalUserID.Exec(id, externalUserID); err != nil { u.lo.Error("error setting external user ID", "id", id, "external_user_id", externalUserID, "error", err) return fmt.Errorf("setting external user ID: %w", err) } return nil } // UpdateAvatar updates the user avatar. func (u *Manager) UpdateAvatar(id int, path string) error { if _, err := u.q.UpdateAvatar.Exec(id, null.NewString(path, path != "")); err != nil { u.lo.Error("error updating user avatar", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // UpdateLastLoginAt updates the last login timestamp of an user. func (u *Manager) UpdateLastLoginAt(id int) error { if _, err := u.q.UpdateLastLoginAt.Exec(id); err != nil { u.lo.Error("error updating user last login at", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // SetResetPasswordToken sets a reset password token for an user and returns the token. func (u *Manager) SetResetPasswordToken(id int) (string, error) { // TODO: column `reset_password_token`, does not have a UNIQUE constraint. Add it in a future migration. token, err := stringutil.RandomAlphanumeric(32) if err != nil { u.lo.Error("error generating reset password token", "error", err) return "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } if _, err := u.q.SetResetPasswordToken.Exec(id, token); err != nil { u.lo.Error("error setting reset password token", "error", err) return "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return token, nil } // ResetPassword sets a password for a given user's reset password token. func (u *Manager) ResetPassword(token, password string) error { if !IsStrongPassword(password) { return envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil) } // Hash password. passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { u.lo.Error("error generating bcrypt password", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } rows, err := u.q.SetPassword.Exec(passwordHash, token) if err != nil { u.lo.Error("error setting new password", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } if count, _ := rows.RowsAffected(); count == 0 { return envelope.NewError(envelope.InputError, u.i18n.T("user.resetPasswordTokenExpired"), nil) } return nil } // UpdateAvailability updates the availability status of an user. func (u *Manager) UpdateAvailability(id int, status string) error { if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil { u.lo.Error("error updating user availability", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // UpdateLastActive updates the last active timestamp of an user. func (u *Manager) UpdateLastActive(id int) error { if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil { u.lo.Error("error updating user last active at", "error", err) return fmt.Errorf("updating user last active at: %w", err) } return nil } // IsOffline returns true if the user's availability status is offline. func (u *Manager) IsOffline(id int) bool { var status string if err := u.q.GetAvailabilityStatus.Get(&status, id); err != nil { return true } return status == "offline" } // SaveCustomAttributes sets or merges custom attributes for a user. // If replace is true, existing attributes are overwritten. Otherwise, attributes are merged. func (u *Manager) SaveCustomAttributes(id int, customAttributes map[string]any, replace bool) error { jsonb, err := json.Marshal(customAttributes) if err != nil { u.lo.Error("error marshalling custom attributes", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } var execErr error if replace { _, execErr = u.q.UpdateCustomAttributes.Exec(id, jsonb) } else { _, execErr = u.q.UpsertCustomAttributes.Exec(id, jsonb) } if execErr != nil { u.lo.Error("error saving custom attributes", "error", execErr) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // ToggleEnabled toggles the enabled status of an user. func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error { if _, err := u.q.ToggleEnable.Exec(id, typ, enabled); err != nil { u.lo.Error("error toggling user enabled status", "error", err) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // GenerateAPIKey generates a new API key and secret for a user func (u *Manager) GenerateAPIKey(userID int) (string, string, error) { // Generate API key (32 characters) apiKey, err := stringutil.RandomAlphanumeric(32) if err != nil { u.lo.Error("error generating API key", "error", err, "user_id", userID) return "", "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Generate API secret (64 characters) apiSecret, err := stringutil.RandomAlphanumeric(64) if err != nil { u.lo.Error("error generating API secret", "error", err, "user_id", userID) return "", "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Hash the API secret for storage secretHash, err := bcrypt.GenerateFromPassword([]byte(apiSecret), bcrypt.DefaultCost) if err != nil { u.lo.Error("error hashing API secret", "error", err, "user_id", userID) return "", "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Update user with API key. if _, err := u.q.SetAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil { u.lo.Error("error saving API key", "error", err, "user_id", userID) return "", "", envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return apiKey, apiSecret, nil } // ValidateAPIKey validates API key and secret and returns the user func (u *Manager) ValidateAPIKey(apiKey, apiSecret string) (models.User, error) { var user models.User // Find user by API key. if err := u.q.GetUserByAPIKey.Get(&user, apiKey); err != nil { if err == sql.ErrNoRows { return user, envelope.NewError(envelope.UnauthorizedError, u.i18n.T("validation.invalidCredential"), nil) } return user, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } // Verify API secret. if err := bcrypt.CompareHashAndPassword([]byte(user.APISecret.String), []byte(apiSecret)); err != nil { return user, envelope.NewError(envelope.UnauthorizedError, u.i18n.T("validation.invalidCredential"), nil) } // Update last used timestamp. if _, err := u.q.UpdateAPIKeyLastUsed.Exec(user.ID); err != nil { u.lo.Error("failed to update API key last used timestamp", "error", err, "user_id", user.ID) } return user, nil } // RevokeAPIKey deactivates the API key for a user func (u *Manager) RevokeAPIKey(userID int) error { if _, err := u.q.RevokeAPIKey.Exec(userID); err != nil { u.lo.Error("error revoking API key", "error", err, "user_id", userID) return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil) } return nil } // MergeVisitorToContact transfers conversations from visitor to contact and deletes the visitor. func (u *Manager) MergeVisitorToContact(visitorID, contactID int) error { if _, err := u.q.MergeVisitorToContact.Exec(visitorID, contactID); err != nil { u.lo.Error("error merging visitor to contact", "visitor_id", visitorID, "contact_id", contactID, "error", err) return fmt.Errorf("merging visitor to contact: %w", err) } return nil } // ChangeSystemUserPassword updates the system user's password with a newly prompted one. func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error { // Prompt for password and get hashed password hashedPassword, err := promptAndHashPassword(ctx) if err != nil { return err } // Update system user's password in the database. if err := updateSystemUserPassword(db, hashedPassword); err != nil { return fmt.Errorf("error updating system user password: %v", err) } fmt.Println("password updated successfully. Login with email 'System' and the new password.") return nil } // CreateSystemUser creates a system user with the provided password or a random one. func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error { var err error // Set random password if not provided. if password == "" { password, err = stringutil.RandomAlphanumeric(32) if err != nil { return fmt.Errorf("failed to generate system used password: %v", err) } } else { log.Print("using provided password for system user") } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("failed to hash system user password: %v", err) } _, err = db.Exec(` WITH sys_user AS ( INSERT INTO users (email, type, first_name, last_name, password) VALUES ($1, $2, $3, $4, $5) RETURNING id ) INSERT INTO user_roles (user_id, role_id) SELECT sys_user.id, roles.id FROM sys_user, roles WHERE roles.name = $6`, models.SystemUserEmail, models.UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin) if err != nil { return fmt.Errorf("failed to create system user: %v", err) } log.Print("system user created successfully. Use command 'libredesk --set-system-user-password' to set the password and login with email 'System'.") return nil } // IsStrongPassword checks if the password meets the required strength for system user. func IsStrongPassword(password string) bool { if len(password) < minPassword || len(password) > maxPassword { return false } hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password) hasLowercase := regexp.MustCompile(`[a-z]`).MatchString(password) hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) // Matches special characters hasSpecial := regexp.MustCompile(`[\W_]`).MatchString(password) return hasUppercase && hasLowercase && hasNumber && hasSpecial } // promptAndHashPassword handles password input and validation, and returns the hashed password. func promptAndHashPassword(ctx context.Context) ([]byte, error) { for { select { case <-ctx.Done(): return nil, ctx.Err() default: fmt.Printf("Please set System user password (%s): ", PasswordHint) buffer := make([]byte, 256) n, err := os.Stdin.Read(buffer) if err != nil { return nil, fmt.Errorf("error reading input: %v", err) } password := strings.TrimSpace(string(buffer[:n])) if IsStrongPassword(password) { // Hash the password using bcrypt. hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, fmt.Errorf("failed to hash password: %v", err) } return hashedPassword, nil } fmt.Println("Password does not meet the strength requirements.") } } } // updateSystemUserPassword updates the password of the system user in the database. func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error { _, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, models.SystemUserEmail) if err != nil { return fmt.Errorf("failed to update system user password: %v", err) } return nil } // makeUserListQuery generates a query to fetch users based on the provided filters. func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, order, orderBy, filtersJSON string) (string, []interface{}, error) { var qArgs []any qArgs = append(qArgs, pq.Array(userTypes)) return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{ Order: order, OrderBy: orderBy, Page: page, PageSize: pageSize, }, filtersJSON, dbutil.AllowedFields{ "users": {"email", "created_at", "updated_at"}, }) } // verifyPassword compares the provided password with the stored password hash. func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil { u.lo.Error("error verifying password", "error", err) return fmt.Errorf("error verifying password: %w", err) } return nil } // generatePassword generates a random password and returns its bcrypt hash. func (u *Manager) generatePassword() ([]byte, error) { password, _ := stringutil.RandomAlphanumeric(70) bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { u.lo.Error("error generating bcrypt password", "error", err) return nil, fmt.Errorf("generating bcrypt password: %w", err) } return bytes, nil }