package setup import ( "bufio" "fmt" "net/mail" "os" "regexp" "strconv" "strings" "golang.org/x/term" ) // CLI input validation functions (matching Web API validation) func cliValidateHostname(host string) bool { validHost := regexp.MustCompile(`^[a-zA-Z0-9.\-:]+$`) return validHost.MatchString(host) && len(host) <= 253 } func cliValidateDBName(name string) bool { validName := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`) return validName.MatchString(name) && len(name) <= 63 } func cliValidateUsername(name string) bool { validName := regexp.MustCompile(`^[a-zA-Z0-9_]+$`) return validName.MatchString(name) && len(name) <= 63 } func cliValidateEmail(email string) bool { _, err := mail.ParseAddress(email) return err == nil && len(email) <= 254 } func cliValidatePort(port int) bool { return port > 0 && port <= 65535 } func cliValidateSSLMode(mode string) bool { validModes := map[string]bool{ "disable": true, "require": true, "verify-ca": true, "verify-full": true, } return validModes[mode] } // RunCLI runs the CLI setup wizard func RunCLI() error { reader := bufio.NewReader(os.Stdin) fmt.Println() fmt.Println("╔═══════════════════════════════════════════╗") fmt.Println("║ Sub2API Installation Wizard ║") fmt.Println("╚═══════════════════════════════════════════╝") fmt.Println() cfg := &SetupConfig{ Server: ServerConfig{ Host: "0.0.0.0", Port: 8080, Mode: "release", }, JWT: JWTConfig{ ExpireHour: 24, }, } // Database configuration with validation fmt.Println("── Database Configuration ──") for { cfg.Database.Host = promptString(reader, "PostgreSQL Host", "localhost") if cliValidateHostname(cfg.Database.Host) { break } fmt.Println(" Invalid hostname format. Use alphanumeric, dots, hyphens only.") } for { cfg.Database.Port = promptInt(reader, "PostgreSQL Port", 5432) if cliValidatePort(cfg.Database.Port) { break } fmt.Println(" Invalid port. Must be between 1 and 65535.") } for { cfg.Database.User = promptString(reader, "PostgreSQL User", "postgres") if cliValidateUsername(cfg.Database.User) { break } fmt.Println(" Invalid username. Use alphanumeric and underscores only.") } cfg.Database.Password = promptPassword("PostgreSQL Password") for { cfg.Database.DBName = promptString(reader, "Database Name", "sub2api") if cliValidateDBName(cfg.Database.DBName) { break } fmt.Println(" Invalid database name. Start with letter, use alphanumeric and underscores.") } for { cfg.Database.SSLMode = promptString(reader, "SSL Mode", "disable") if cliValidateSSLMode(cfg.Database.SSLMode) { break } fmt.Println(" Invalid SSL mode. Use: disable, require, verify-ca, or verify-full.") } fmt.Println() fmt.Print("Testing database connection... ") if err := TestDatabaseConnection(&cfg.Database); err != nil { fmt.Println("FAILED") return fmt.Errorf("database connection failed: %w", err) } fmt.Println("OK") // Redis configuration with validation fmt.Println() fmt.Println("── Redis Configuration ──") for { cfg.Redis.Host = promptString(reader, "Redis Host", "localhost") if cliValidateHostname(cfg.Redis.Host) { break } fmt.Println(" Invalid hostname format. Use alphanumeric, dots, hyphens only.") } for { cfg.Redis.Port = promptInt(reader, "Redis Port", 6379) if cliValidatePort(cfg.Redis.Port) { break } fmt.Println(" Invalid port. Must be between 1 and 65535.") } cfg.Redis.Password = promptPassword("Redis Password (optional)") for { cfg.Redis.DB = promptInt(reader, "Redis DB", 0) if cfg.Redis.DB >= 0 && cfg.Redis.DB <= 15 { break } fmt.Println(" Invalid Redis DB. Must be between 0 and 15.") } fmt.Println() fmt.Print("Testing Redis connection... ") if err := TestRedisConnection(&cfg.Redis); err != nil { fmt.Println("FAILED") return fmt.Errorf("redis connection failed: %w", err) } fmt.Println("OK") // Admin configuration with validation fmt.Println() fmt.Println("── Admin Account ──") for { cfg.Admin.Email = promptString(reader, "Admin Email", "admin@example.com") if cliValidateEmail(cfg.Admin.Email) { break } fmt.Println(" Invalid email format.") } for { cfg.Admin.Password = promptPassword("Admin Password") // SECURITY: Match Web API requirement of 8 characters minimum if len(cfg.Admin.Password) < 8 { fmt.Println(" Password must be at least 8 characters") continue } if len(cfg.Admin.Password) > 128 { fmt.Println(" Password must be at most 128 characters") continue } confirm := promptPassword("Confirm Password") if cfg.Admin.Password != confirm { fmt.Println(" Passwords do not match") continue } break } // Server configuration with validation fmt.Println() fmt.Println("── Server Configuration ──") for { cfg.Server.Port = promptInt(reader, "Server Port", 8080) if cliValidatePort(cfg.Server.Port) { break } fmt.Println(" Invalid port. Must be between 1 and 65535.") } // Confirm and install fmt.Println() fmt.Println("── Configuration Summary ──") fmt.Printf("Database: %s@%s:%d/%s\n", cfg.Database.User, cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName) fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) fmt.Printf("Admin: %s\n", cfg.Admin.Email) fmt.Printf("Server: :%d\n", cfg.Server.Port) fmt.Println() if !promptConfirm(reader, "Proceed with installation?") { fmt.Println("Installation cancelled") return nil } fmt.Println() fmt.Print("Installing... ") if err := Install(cfg); err != nil { fmt.Println("FAILED") return err } fmt.Println("OK") fmt.Println() fmt.Println("╔═══════════════════════════════════════════╗") fmt.Println("║ Installation Complete! ║") fmt.Println("╚═══════════════════════════════════════════╝") fmt.Println() fmt.Println("Start the server with:") fmt.Println(" ./sub2api") fmt.Println() fmt.Printf("Admin panel: http://localhost:%d\n", cfg.Server.Port) fmt.Println() return nil } func promptString(reader *bufio.Reader, prompt, defaultVal string) string { if defaultVal != "" { fmt.Printf(" %s [%s]: ", prompt, defaultVal) } else { fmt.Printf(" %s: ", prompt) } input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) if input == "" { return defaultVal } return input } func promptInt(reader *bufio.Reader, prompt string, defaultVal int) int { fmt.Printf(" %s [%d]: ", prompt, defaultVal) input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) if input == "" { return defaultVal } val, err := strconv.Atoi(input) if err != nil { return defaultVal } return val } func promptPassword(prompt string) string { fmt.Printf(" %s: ", prompt) // Try to read password without echo if term.IsTerminal(int(os.Stdin.Fd())) { password, err := term.ReadPassword(int(os.Stdin.Fd())) fmt.Println() if err == nil { return string(password) } } // Fallback to regular input reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') return strings.TrimSpace(input) } func promptConfirm(reader *bufio.Reader, prompt string) bool { fmt.Printf("%s [y/N]: ", prompt) input, _ := reader.ReadString('\n') input = strings.TrimSpace(strings.ToLower(input)) return input == "y" || input == "yes" }