From 5361bcb24fdf3f5fc8f22e0e9cec043b6583259f Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 22 Feb 2025 23:32:47 +0530 Subject: [PATCH] feat: dockerize libredesk - feat: new flag for idempotent installation `--idempotent-install` - feat: new flag to skip yes/no prompt `--yes` - feat: new flag for upgrades `--upgrade` - refactor: update doc strings and sample toml file. - chore: update .gitignore. --- .gitignore | 6 ++- Dockerfile | 17 +++++++ cmd/init.go | 3 ++ cmd/install.go | 44 +++++++++++++---- cmd/main.go | 10 +++- config.sample.toml | 6 ++- docker-compose.yml | 62 ++++++++++++++++++++++++ internal/auth/auth.go | 1 + internal/colorlog/colorlog.go | 2 +- internal/conversation/message.go | 2 +- internal/user/user.go | 82 +++++++++++++++++++------------- 11 files changed, 185 insertions(+), 50 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 7c72020..5483042 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules config.toml +config.toml.* libredesk.bin -uploads/* -.env \ No newline at end of file +uploads +.env +dist/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a1dd85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use the latest version of Alpine Linux as the base image +FROM alpine:latest + +# Install necessary packages +RUN apk --no-cache add ca-certificates + +# Set the working directory to /libredesk +WORKDIR /libredesk + +# Copy the libredesk binary to the working directory +COPY libredesk.bin . + +# Expose port 9000 for the application +EXPOSE 9000 + +# Set the default command to run the libredesk binary +CMD ["./libredesk.bin"] \ No newline at end of file diff --git a/cmd/init.go b/cmd/init.go index 37f5644..b60ece0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -98,6 +98,9 @@ func initFlags() { "path to one or more config files (will be merged in order)") f.Bool("version", false, "show current version of the build") f.Bool("install", false, "setup database") + f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup") + f.Bool("yes", false, "skip confirmation prompt") + f.Bool("upgrade", false, "upgrade the database schema") f.Bool("set-system-user-password", false, "set password for the system user") if err := f.Parse(os.Args[1:]); err != nil { diff --git a/cmd/install.go b/cmd/install.go index e0689e0..9c2aacb 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -4,23 +4,38 @@ import ( "context" "fmt" "log" + "os" "strings" + "time" + "github.com/abhinavxd/libredesk/internal/colorlog" "github.com/abhinavxd/libredesk/internal/user" "github.com/jmoiron/sqlx" "github.com/knadh/stuffbin" "github.com/lib/pq" ) -// install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. -func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error { - installed, err := checkSchema(db) +// Install checks if the schema is already installed, prompts for confirmation, and installs the schema if needed. +// idempotent install skips the installation if the database schema is already installed. +func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempotentInstall, prompt bool) error { + schemaInstalled, err := checkSchema(db) if err != nil { - log.Fatalf("error checking db schema: %v", err) + log.Fatalf("error checking existing db schema: %v", err) } - if installed { - fmt.Printf("\033[31m** WARNING: This will wipe your entire database - '%s' **\033[0m\n", ko.String("db.database")) - fmt.Print("Continue (y/n)? ") + + // Make sure the system user password is strong enough. + password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")) + if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled { + log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint) + } + + if !idempotentInstall { + log.Println("running first time setup...") + colorlog.Red(fmt.Sprintf("WARNING: This will wipe your entire database - '%s'", ko.String("db.database"))) + } + + if prompt { + log.Print("Continue (y/n)? ") var ok string fmt.Scanf("%s", &ok) if !strings.EqualFold(ok, "y") { @@ -28,15 +43,26 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem) error { } } + if idempotentInstall { + if schemaInstalled { + log.Println("skipping installation as schema is already installed") + os.Exit(0) + } + } else { + time.Sleep(5 * time.Second) + } + + log.Println("installing database schema...") + // Install schema. if err := installSchema(db, fs); err != nil { log.Fatalf("error installing schema: %v", err) } - log.Println("Schema installed successfully") + log.Println("database schema installed successfully") // Create system user. - if err := user.CreateSystemUser(ctx, db); err != nil { + if err := user.CreateSystemUser(ctx, password, db); err != nil { log.Fatalf("error creating system user: %v", err) } return nil diff --git a/cmd/main.go b/cmd/main.go index 10e5c6a..54b153a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -114,7 +114,7 @@ func main() { // Installer. if ko.Bool("install") { - install(ctx, db, fs) + install(ctx, db, fs, ko.Bool("idempotent-install"), !ko.Bool("yes")) os.Exit(0) } @@ -130,7 +130,13 @@ func main() { log.Fatalf("error checking db schema: %v", err) } if !installed { - log.Println("Database tables are missing. Use the `--install` flag to set up the database schema.") + log.Println("database tables are missing. Use the `--install` flag to set up the database schema.") + os.Exit(0) + } + + // Upgrade. + if ko.Bool("upgrade") { + log.Println("no upgrades available") os.Exit(0) } diff --git a/config.sample.toml b/config.sample.toml index cdc266e..3da7e1c 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -12,13 +12,13 @@ write_timeout = "5s" max_body_size = 500000000 keepalive_timeout = "10s" -# File upload provider. +# File upload provider to use. [upload] provider = "fs" # Filesytem provider. [upload.fs] -upload_path = '/home/ubuntu/uploads' +upload_path = 'uploads' # S3 provider. [upload.s3] @@ -32,6 +32,7 @@ expiry = "6h" # Postgres. [db] +# If using docker compose, use the service name as the host. host = "127.0.0.1" port = 5432 user = "postgres" @@ -44,6 +45,7 @@ max_lifetime = "300s" # Redis. [redis] +# If using docker compose, use the service name as the host. address = "127.0.0.1:6379" password = "" db = 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae248bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + # Libredesk app + app: + image: libredesk:latest + container_name: libredesk_app + restart: unless-stopped + ports: + - "9000:9000" + environment: + # If the password is set during first docker-compose up, the system user password will be set to this value. + # You can always set system user password later by running `docker exec -it libredesk_app ./libredesk.bin --set-system-user-password`. + LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-} + networks: + - libredesk + depends_on: + - db + - redis + volumes: + - ./uploads:/libredesk/uploads:rw + - ./config.toml:/libredesk/config.toml + command: [sh, -c, "./libredesk.bin --install --idempotent-install --yes --config /libredesk/config.toml && ./libredesk.bin --upgrade --yes --config /libredesk/config.toml && ./libredesk.bin --config /libredesk/config.toml"] + + # PostgreSQL database + db: + image: postgres:17-alpine + container_name: libredesk_db + restart: unless-stopped + networks: + - libredesk + ports: + - "5432:5432" + environment: + # Set these environment variables to configure the database, defaults to libredesk. + POSTGRES_USER: ${POSTGRES_USER:-libredesk} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk} + POSTGRES_DB: ${POSTGRES_DB:-libredesk} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U libredesk"] + interval: 10s + timeout: 5s + retries: 6 + volumes: + - postgres-data:/var/lib/postgresql/data + + # Redis + redis: + image: redis:7-alpine + container_name: libredesk_redis + restart: unless-stopped + ports: + - "6379:6379" + networks: + - libredesk + volumes: + - redis-data:/data + +networks: + libredesk: + +volumes: + postgres-data: + redis-data: \ No newline at end of file diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0aafd46..e39917b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -289,6 +289,7 @@ func (a *Auth) SetCSRFCookie(r *fastglue.Request) error { return nil } +// ValidateSession validates the session and returns the user. func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) { a.mu.RLock() defer a.mu.RUnlock() diff --git a/internal/colorlog/colorlog.go b/internal/colorlog/colorlog.go index cd4d99c..65c2978 100644 --- a/internal/colorlog/colorlog.go +++ b/internal/colorlog/colorlog.go @@ -1,4 +1,4 @@ -// package colorlog provides logging in color. +// package colorlog provides ANSI color logging for the terminal. package colorlog import "log" diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 8bbafb4..2060f97 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -54,7 +54,7 @@ const ( ) // Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for -// pending outgoing messages at the specified read interval and pushes them to the outgoing queue. +// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent. func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) { dbScanner := time.NewTicker(scanInterval) defer dbScanner.Stop() diff --git a/internal/user/user.go b/internal/user/user.go index 00bf8f8..f16264b 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -11,6 +11,8 @@ import ( "regexp" "strings" + "log" + "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" rmodels "github.com/abhinavxd/libredesk/internal/role/models" @@ -24,6 +26,14 @@ import ( "golang.org/x/crypto/bcrypt" ) +const ( + systemUserEmail = "System" + minSystemUserPassword = 8 + maxSystemUserPassword = 50 + UserTypeAgent = "agent" + UserTypeContact = "contact" +) + var ( //go:embed queries.sql efs embed.FS @@ -31,14 +41,8 @@ var ( // ErrPasswordTooLong is returned when the password passed to // GenerateFromPassword is too long (i.e. > 72 bytes). ErrPasswordTooLong = errors.New("password length exceeds 72 bytes") -) -const ( - SystemUserEmail = "System" - MinSystemUserPasswordLen = 8 - MaxSystemUserPasswordLen = 50 - UserTypeAgent = "agent" - UserTypeContact = "contact" + SystemUserPasswordHint = fmt.Sprintf("Password must be %d-%d characters long and contain at least one uppercase letter and one number", minSystemUserPassword, maxSystemUserPassword) ) // Manager handles user-related operations. @@ -179,7 +183,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) { // GetSystemUser retrieves the system user. func (u *Manager) GetSystemUser() (models.User, error) { - return u.GetByEmail(SystemUserEmail) + return u.GetByEmail(systemUserEmail) } // UpdateAvatar updates the user avatar. @@ -200,7 +204,7 @@ func (u *Manager) Update(id int, user models.User) error { if user.NewPassword != "" { if !u.isStrongPassword(user.NewPassword) { - return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil) + return envelope.NewError(envelope.InputError, SystemUserPasswordHint, nil) } hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost) if err != nil { @@ -265,7 +269,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) { // ResetPassword sets a new password for a user. func (u *Manager) ResetPassword(token, password string) error { if !u.isStrongPassword(password) { - return envelope.NewError(envelope.InputError, "Entered password is not strong please make sure the password has min 8, max 50 characters, at least 1 uppercase letter, 1 number", nil) + return envelope.NewError(envelope.InputError, "Password is not strong enough, " + SystemUserPasswordHint, nil) } // Hash password. passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -311,7 +315,7 @@ func (u *Manager) generatePassword() ([]byte, error) { // isStrongPassword checks if the password meets the required strength. func (u *Manager) isStrongPassword(password string) bool { - if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen { + if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword { return false } hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password) @@ -331,16 +335,29 @@ func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error { if err := updateSystemUserPassword(db, hashedPassword); err != nil { return fmt.Errorf("error updating system user password: %v", err) } - fmt.Println("System user password updated successfully.") + fmt.Println("password updated successfully.") return nil } -// CreateSystemUser inserts a default system user into the users table with the prompted password. -func CreateSystemUser(ctx context.Context, db *sqlx.DB) error { - hashedPassword, err := promptAndHashPassword(ctx) - if err != nil { - return err +// 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) @@ -351,14 +368,24 @@ func CreateSystemUser(ctx context.Context, db *sqlx.DB) error { SELECT sys_user.id, roles.id FROM sys_user, roles WHERE roles.name = $6`, - SystemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin) + systemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin) if err != nil { return fmt.Errorf("failed to create system user: %v", err) } - fmt.Println("System user created successfully") + log.Print("system user created successfully") return nil } +// IsStrongSystemUserPassword checks if the password meets the required strength for system user. +func IsStrongSystemUserPassword(password string) bool { + if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword { + return false + } + hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password) + hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) + return hasUppercase && hasNumber +} + // promptAndHashPassword handles password input and validation, and returns the hashed password. func promptAndHashPassword(ctx context.Context) ([]byte, error) { for { @@ -366,15 +393,14 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) { case <-ctx.Done(): return nil, ctx.Err() default: - fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ") + fmt.Printf("Please set System user password (%s): ", SystemUserPasswordHint) 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 isStrongSystemUserPassword(password) { + if IsStrongSystemUserPassword(password) { // Hash the password using bcrypt. hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { @@ -389,19 +415,9 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) { // 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, SystemUserEmail) + _, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, systemUserEmail) if err != nil { return fmt.Errorf("failed to update system user password: %v", err) } return nil } - -// isStrongSystemUserPassword checks if the password meets the required strength for system user. -func isStrongSystemUserPassword(password string) bool { - if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen { - return false - } - hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password) - hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) - return hasUppercase && hasNumber -}