commit 1142011a1958d6cf74d1bd39c3d089acceb17c21 Author: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat Mar 8 03:34:04 2025 +0000 Updated Credentials Storage diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..c526753 --- /dev/null +++ b/LICENCE @@ -0,0 +1,7 @@ +Copyright 2024 FernandoJVideira + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcef569 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# IPLeiria Auto Attendance Script + +This project automates the process of navigating to the IPLeiria GISEM platform and marking your attendence using the `chromedp` library. + +## Prerequisites + +- Go 1.23.2 or later +- A Chromium browser installed + +## Installation + +1. Clone the repository: + + ```sh + git clone https://github.com/FernandoJVideira/gisem-presencas.git + cd gisem-presencas + ``` + +2. Install dependencies: + + ```sh + go mod tidy + ``` + +3. Add your credentials to the `utils.go` file. + +## Usage + +To run the project, execute the following command: + +```sh +go run main.go +``` + +As another option you can build the project by running: + +```sh +go build . +``` + +And run it like: + +```sh +./gisem_presencas -w value +``` + +## Flags + +- `-w` or `-wait`: Time to wait in between refreshes (default is 30 seconds). + +## Project Structure + +- `main.go`: The main entry point of the application. +- `utils.go`: Contains utility functions such as flag parsing and the welcome message. +- `.env`: Environment variables file. +- `example.env`: Example environment variables file. +- `go.mod`: Go module dependencies. +- `go.sum`: Go module dependency checksums. +- `README.md`: Project documentation. + +## License + +This project is licensed under the MIT License. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1c89cec --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/FernandoJVideira/gisem-presencas + +go 1.23.2 + +require ( + github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb + github.com/chromedp/chromedp v0.11.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d914917 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb h1:noKVm2SsG4v0Yd0lHNtFYc9EUxIVvrr4kJ6hM8wvIYU= +github.com/chromedp/cdproto v0.0.0-20241022234722-4d5d5faf59fb/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= +github.com/chromedp/chromedp v0.11.1 h1:Spca8egFqUlv+JDW+yIs+ijlHlJDPufgrfXPwtq6NMs= +github.com/chromedp/chromedp v0.11.1/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1db10ac --- /dev/null +++ b/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/chromedp/chromedp" +) + +const ( + webUrl = "https://gisem.dei.estg.ipleiria.pt" +) + +var waitTime int + +func main() { + //* Parse the flags + wait, headless, err := parseFlags() + if err != nil { + log.Fatalf("Failed to parse flags: %v", err) + } + waitTime = wait + + fmt.Println("Waiting time:", waitTime) + + //* Welcome message + welcomeMessage() + + creds, err := getCreds() + if err != nil { + // If the credentials file doesn't exist, prompt for credentials + if err.Error() == "credentials file not found: stat "+credFile+": no such file or directory" { + // The file doesn't exist, create it by prompting for credentials + creds, err = promptAndSaveCreds(credFile) + if err != nil { + log.Fatalf("Failed to get credentials: %v", err) + } + } else { + log.Fatalf("Failed to get credentials: %v", err) + } + } + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", headless), // Disable headless mode + chromedp.Flag("disable-gpu", headless), // Enable GPU + chromedp.Flag("start-maximized", !headless), // Start maximized + ) + + allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer cancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + //* Navigate to the website + if err := chromedp.Run(ctx, chromedp.Navigate(webUrl)); err != nil { + log.Fatalf("Failed to navigate: %v", err) + } + + //* Check if there's a login button + var isVisible bool + if err := chromedp.Run(ctx, chromedp.Tasks{ + chromedp.WaitVisible(loginButton), + chromedp.Evaluate(fmt.Sprintf("document.querySelector('%s').offsetParent !== null", loginButton), &isVisible), + }); err != nil { + log.Fatalf("Failed to evaluate: %v", err) + } + + if isVisible { + log.Println("Logging in...") + if err := login(ctx, creds); err != nil { + log.Fatalf("Failed to login: %v", err) + } + time.Sleep(1 * time.Second) + log.Println("Waiting for activation...") + if err := marcarPresenca(ctx); err != nil { + log.Fatalf("Failed to mark presence: %v", err) + } + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..075ff2d --- /dev/null +++ b/utils.go @@ -0,0 +1,349 @@ +package main + +import ( + "bufio" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "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, error) { + //* Set the flags + wait := flag.Int("w", 10, "Time to wait in between refreshes") + flag.IntVar(wait, "wait", 10, "Time to wait in between refreshes") + + // By default, run in headless mode + headless := flag.Bool("h", true, "Run in headless mode") + flag.BoolVar(headless, "headless", true, "Run in headless mode") + + flag.Parse() + + if *wait < 0 { + return 0, false, fmt.Errorf("invalid wait time: %d", *wait) + } + + fmt.Println(*headless) + + return *wait, *headless, 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) + } + + fmt.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 + + 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() + + //Try to wait for the element + err := chromedp.Run(waitCtx, + chromedp.WaitVisible(menuPresencas)) + + if err == nil { + fmt.Println("Navigation menu is visible!") + } + + chromedp.Run(waitCtx, chromedp.Click(menuPresencas)) + fmt.Println("Navigation menu clicked!") + var currentURL string + if err := chromedp.Location(¤tURL).Do(ctx); err != nil { + return err + } + if strings.Contains(currentURL, "obterAulasMarcarPresenca") { + fmt.Println("Attendance page loaded!") + fmt.Println("Registering attendance...") + break + } + + if err := chromedp.Reload().Do(ctx); err != nil { + return err + } + time.Sleep(time.Duration(waitTime) * time.Second) + } + 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") { + fmt.Println("Attendance registered successfully!") + } else if strings.Contains(alertMessage, "anteriormente") { + fmt.Println("Attendance previously registered!") + } else { + fmt.Println("Error registering attendance: ", alertMessage) + } + case <-time.After(time.Duration(waitTime) * time.Second): + return fmt.Errorf("timeout waiting for alert") + } + return nil + }), + ); err != nil { + 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) +}