Files
2025-03-13 22:53:43 +00:00

382 lines
11 KiB
Go

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(&currentURL).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)
}