package main import ( "bufio" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "io" "log" "os" "path/filepath" "runtime" "strings" "syscall" "time" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "golang.org/x/crypto/ssh/terminal" ) const ( loginButton = "#app-navbar-collapse > ul.nav.navbar-nav.navbar-right > li:nth-child(1) > a" usernameInput = "body > div > div > div > div.modal-body > form > div:nth-child(2) > input" passwordInput = "body > div > div > div > div.modal-body > form > div:nth-child(3) > input" rememberMe = `body > div > div > div > div.modal-body > form > div.checkbox > label > input[type="checkbox"]` submitButton = "body > div > div > div > div.modal-body > form > button" errorDiv = "body > div > div > div > div.modal-body > form > div.alert.alert-danger" menuPresencas = "#app-navbar-collapse > ul:nth-child(1) > li:nth-child(4) > a" btnMarcarPresenca = ".btn.btn-primary" maxRetries = 5 credFile = ".credentials" encryptSalt = "IPLeiriaAutoAttendance" // Salt for encryption, ideally should be unique per installation ) type Credentials struct { Username string Password string } func deriveKey(passphrase string) []byte { hash := sha256.Sum256([]byte(passphrase)) return hash[:] } func encrypt(plaintext, passphrase string) (string, error) { key := deriveKey(passphrase) block, err := aes.NewCipher(key) if err != nil { return "", err } aesGCM, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, aesGCM.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) return hex.EncodeToString(ciphertext), nil } func decrypt(encryptedHex, passphrase string) (string, error) { key := deriveKey(passphrase) ciphertext, err := hex.DecodeString(encryptedHex) if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } aesGCM, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := aesGCM.NonceSize() if len(ciphertext) < nonceSize { return "", fmt.Errorf("ciphertext too short") } nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) if err != nil { return "", err } return string(plaintext), nil } func parseFlags() (int, bool, bool, string, int, bool, error) { //* Set the flags wait := flag.Int("w", 10, "\tTime to wait in between refreshes") flag.IntVar(wait, "wait", 10, "\tTime to wait in between refreshes") // Verbose mode isVerbose := flag.Bool("v", false, "\tEnable verbose mode") flag.BoolVar(isVerbose, "verbose", false, "\tEnable verbose mode") // By default, run in headless mode headless := flag.Bool("h", true, "\tRun in headless mode") flag.BoolVar(headless, "headless", true, "\tRun in headless mode") // Log to file logFile := flag.String("l", "", "\tLog output to file") flag.StringVar(logFile, "log", "", "\tLog output to file") // Maximum time to run the program (minutes) maxTime := flag.Int("m", -1, "\tMaximum time to run the program (minutes)") flag.IntVar(maxTime, "maxtime", -1, "\tMaximum time to run the program (minutes)") replacePass := flag.Bool("r", false, "\tReplace saved credentials") flag.BoolVar(replacePass, "replace", false, "\tReplace saved credentials") flag.Parse() if *wait < 0 { return 0, false, false, "", -1, false, fmt.Errorf("invalid wait time: %d", *wait) } return *wait, *isVerbose, *headless, *logFile, *maxTime, *replacePass, nil } func welcomeMessage() { fmt.Println("******************************************************************************************") fmt.Println("* Welcome to IPLeiria AutoAttendance! *") fmt.Println("* This program will automatically mark your attendance in the IPLeiria's GISEM platform. *") fmt.Println("* Please make sure you have your credentials ready. *") fmt.Println("* Enjoy! *") fmt.Println("******************************************************************************************") } func getCredsFilePath() (string, error) { var configDir string var err error switch runtime.GOOS { case "windows": configDir = os.Getenv("APPDATA") if configDir == "" { configDir, err = os.UserHomeDir() if err != nil { return "", err } } default: home, err := os.UserHomeDir() if err != nil { return "", err } configDir = filepath.Join(home, ".config") } appConfigDir := filepath.Join(configDir, "gisem-presencas") // Create directory if it doesn't exist if err := os.MkdirAll(appConfigDir, 0700); err != nil { return "", fmt.Errorf("failed to create config directory: %v", err) } return filepath.Join(appConfigDir, credFile), nil } func getCreds() (Credentials, error) { credPath, err := getCredsFilePath() if err != nil { return Credentials{}, fmt.Errorf("could not determine credentials path: %v", err) } // Check if credentials file exists if _, err := os.Stat(credPath); os.IsNotExist(err) { // File doesn't exist, prompt user for credentials return promptAndSaveCreds(credPath) } file, err := os.Open(credPath) if err != nil { return Credentials{}, fmt.Errorf("failed to open credentials file: %v", err) } defer file.Close() var creds Credentials if err := json.NewDecoder(file).Decode(&creds); err != nil { return Credentials{}, fmt.Errorf("failed to read credentials: %v", err) } if creds.Username == "" || creds.Password == "" { return promptAndSaveCreds(credPath) } return creds, nil } func promptAndSaveCreds(credPath string) (Credentials, error) { reader := bufio.NewReader(os.Stdin) fmt.Print("Enter your GISEM username: ") username, err := reader.ReadString('\n') if err != nil { return Credentials{}, fmt.Errorf("failed to read username: %v", err) } username = strings.TrimSpace(username) fmt.Print("Enter your GISEM password: ") passwordBytes, err := terminal.ReadPassword(int(syscall.Stdin)) if err != nil { // Fallback to regular input if terminal.ReadPassword fails fmt.Println("\nSecure password input not available, using regular input:") fmt.Print("Enter your GISEM password: ") passwordStr, err := reader.ReadString('\n') if err != nil { return Credentials{}, fmt.Errorf("could not read password: %v", err) } password := strings.TrimSpace(passwordStr) return saveCreds(credPath, username, password) } fmt.Println() password := strings.TrimSpace(string(passwordBytes)) return saveCreds(credPath, username, password) } func saveCreds(filePath, username, password string) (Credentials, error) { creds := Credentials{ Username: username, Password: password, } pwd, err := encrypt(creds.Password, encryptSalt+username) if err != nil { return Credentials{}, fmt.Errorf("failed to encrypt password: %v", err) } creds.Password = pwd file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return Credentials{}, fmt.Errorf("could not open file: %v", err) } defer file.Close() encoder := json.NewEncoder(file) if err := encoder.Encode(creds); err != nil { return Credentials{}, fmt.Errorf("could not write credentials to file: %v", err) } log.Println("Credentials saved successfully!") return creds, nil } func login(ctx context.Context, creds Credentials) error { //* Fill the login form and save the cookies pwd, err := decrypt(creds.Password, encryptSalt+creds.Username) if err != nil { return fmt.Errorf("failed to decrypt password: %v", err) } if err := chromedp.Run(ctx, chromedp.WaitVisible(".modal-content"), chromedp.SendKeys(usernameInput, creds.Username), chromedp.SendKeys(passwordInput, pwd), chromedp.Click(submitButton), ); err != nil { log.Fatalf("Failed to fill the login form: %v", err) } return nil } func marcarPresenca(ctx context.Context) error { var attempt int startTime := time.Now() if isVerbose || maxTime != -1 { log.Printf("Starting attendance registration at %s\n", startTime.Format(time.RFC1123)) } for attempt = 1; attempt <= maxRetries; attempt++ { if err := chromedp.Run(ctx, chromedp.ActionFunc(func(ctx context.Context) error { for { waitCtx, cancel := context.WithTimeout(ctx, time.Duration(waitTime)*time.Second) defer cancel() err := chromedp.Run(waitCtx, chromedp.WaitVisible(menuPresencas)) if err == nil { if isVerbose && logFile != "" { log.Println("Navigation menu is visible!") } } chromedp.Run(waitCtx, chromedp.Click(menuPresencas)) var currentURL string if err := chromedp.Location(¤tURL).Do(ctx); err != nil { return err } if strings.Contains(currentURL, "obterAulasMarcarPresenca") { log.Println("Attendance page loaded :", time.Now().Format(time.RFC1123)) log.Println("Registering attendance... : ", time.Now().Format(time.RFC1123)) break } if err := chromedp.Reload().Do(ctx); err != nil { return err } time.Sleep(time.Duration(waitTime) * time.Second) if maxTime != -1 { elapsedMinutes := time.Since(startTime).Minutes() if int(elapsedMinutes) >= maxTime { log.Println("Maximum time reached, attendance not registered", time.Now().Format(time.RFC1123)) return errors.New("maximum time reached") } } } return nil }), chromedp.WaitVisible(btnMarcarPresenca), chromedp.Click(btnMarcarPresenca), // Wait and check alert message chromedp.ActionFunc(func(ctx context.Context) error { ch := make(chan string, 1) chromedp.ListenTarget(ctx, func(ev any) { if ev, ok := ev.(*page.EventJavascriptDialogOpening); ok { ch <- ev.Message } }) select { case alertMessage := <-ch: if strings.Contains(alertMessage, "sucesso") { log.Println("Attendance registered successfully! : ", time.Now().Format(time.RFC1123)) } else if strings.Contains(alertMessage, "anteriormente") { log.Println("Attendance previously registered! : ", time.Now().Format(time.RFC1123)) } else { log.Println("Error registering attendance: ", alertMessage) } case <-time.After(time.Duration(waitTime) * time.Second): return fmt.Errorf("timeout waiting for alert") } return nil }), ); err != nil { // Check if the error was due to maximum time reached if err.Error() == "maximum time reached" { return err } log.Printf("Attempt %d: Failed to register attendance: %v", attempt, err) time.Sleep(5 * time.Second) continue } return nil } return fmt.Errorf("failed to register attendance after %d attempts", maxRetries) }