382 lines
11 KiB
Go
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(¤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)
|
|
}
|