From 3346dacc67677cd0317014e8cd9f624f57df6c63 Mon Sep 17 00:00:00 2001 From: RPJosh Date: Wed, 4 Oct 2023 14:48:58 +0200 Subject: [PATCH] Initial commit --- .gitignore | 6 ++ cmd/infoniqa/main.go | 92 +++++++++++++++++ config.yaml | 7 ++ go.mod | 9 ++ go.sum | 9 ++ internal/infoniqa/book.go | 56 +++++++++++ internal/infoniqa/cookies.go | 34 +++++++ internal/infoniqa/infoniqa.go | 180 ++++++++++++++++++++++++++++++++++ internal/models/config.go | 37 +++++++ pkg/utils/utils.go | 15 +++ scripts/build.sh | 8 ++ scripts/run.cmd | 35 +++++++ scripts/run.sh | 6 ++ 13 files changed, 494 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/infoniqa/main.go create mode 100644 config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/infoniqa/book.go create mode 100644 internal/infoniqa/cookies.go create mode 100644 internal/infoniqa/infoniqa.go create mode 100644 internal/models/config.go create mode 100644 pkg/utils/utils.go create mode 100644 scripts/build.sh create mode 100644 scripts/run.cmd create mode 100644 scripts/run.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c8608b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/scripts/temp_path +/config-dev.yaml + +# Build binarys +/infoniqa +/infoniqa.exe \ No newline at end of file diff --git a/cmd/infoniqa/main.go b/cmd/infoniqa/main.go new file mode 100644 index 0000000..20624c1 --- /dev/null +++ b/cmd/infoniqa/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "git.rpjosh.de/RPJosh/go-logger" + "gitea.hama.de/LFS/infoniqa-scripts/internal/infoniqa" + "gitea.hama.de/LFS/infoniqa-scripts/internal/models" +) + +func main() { + defer logger.CloseFile() + + // Configure logger + logger.SetGlobalLogger(logger.GetLoggerFromEnv(&logger.Logger{ + ColoredOutput: true, + Level: logger.LevelInfo, + PrintSource: true, + File: &logger.FileLogger{}, + })) + + // Check if the first argument is --help + if len(os.Args) == 1 || os.Args[1] == "--help" || os.Args[1] == "-h" || os.Args[1] == "?" { + printHelp() + } + + // Get the configuration of the app + config := models.GetConfig() + logger.Info("Program started") + + // Initialize infoniqa client + inf, err := infoniqa.NewInfoniqa(config.Url, config.Username, config.Password) + if err != nil { + logger.Fatal("Initialization of infoniqa client was not successfull: %s", err) + } + + switch strings.ToLower(os.Args[1]) { + case "kommen": + if err := inf.Kommen(); err != nil { + logger.Fatal("Failed to book 'kommen': %s", err) + } + case "gehen": + if err := inf.Gehen(); err != nil { + logger.Fatal("Failed to book 'gehen': %s", err) + } + case "abwesend": + if len(os.Args) <= 2 { + logger.Fatal("Missing required parameter for option 'abwesend'") + } + + // Parse the second argument to an int (amount of minutes) + minutes, err := strconv.Atoi(os.Args[2]) + if err != nil { + logger.Fatal("Failed to convert the argument %q to a number: %s", os.Args[2], err) + } + + // Buche kommen und dann gehen + if err := inf.Gehen(); err != nil { + logger.Fatal("Failed to book 'kommen': %s", err) + } + logger.Info("Waiting %d minutes....", minutes) + time.Sleep(time.Duration(minutes * int(time.Minute))) + if err := inf.Kommen(); err != nil { + logger.Fatal("Failed to book 'kommen': %s", err) + } + + default: + logger.Fatal("Invalid argument given: %q", os.Args[0]) + } + + logger.Info("Program executed successfull") +} + +// printHelp prints a help for the usage of this program and exists the program afterwards +func printHelp() { + fmt.Println(` +Command line arguments: + kommen Books "kommen" + gehen Books "gehen" + abwesend [minutes] Books "gehen" and waits the given amount of minutes for booking "kommen" + + --help Prints this help + +Environment variables: + INFONIQA_CONFIG File path of the configuration file to use (defaulting to ./config.yaml)`) + + os.Exit(0) +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..d82f7b2 --- /dev/null +++ b/config.yaml @@ -0,0 +1,7 @@ +# Username to login +username: "0103710" +# Password of the user +password: "mySecret" + +# Base URL of infoniqa +url: https://hama.infoniqa.co.at/TIMEWEB254834 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb53423 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module gitea.hama.de/LFS/infoniqa-scripts + +go 1.19 + +require ( + git.rpjosh.de/RPJosh/go-logger v1.3.0 // indirect + golang.org/x/sys v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ce1a238 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +git.rpjosh.de/RPJosh/go-logger v1.2.1 h1:yzx9+mFIC+2TXI93EGeuRvow++yte8bpQ0GtDvLnnIQ= +git.rpjosh.de/RPJosh/go-logger v1.2.1/go.mod h1:iD3KaRyOIkYMj7E+xFMn5uDVCzW1lSJQopz1Fl1+BSM= +git.rpjosh.de/RPJosh/go-logger v1.3.0 h1:oKjOEMC5RSge3qhyoXaegkvotNYOug67CADDnBKDXQU= +git.rpjosh.de/RPJosh/go-logger v1.3.0/go.mod h1:iD3KaRyOIkYMj7E+xFMn5uDVCzW1lSJQopz1Fl1+BSM= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/infoniqa/book.go b/internal/infoniqa/book.go new file mode 100644 index 0000000..69d4a6b --- /dev/null +++ b/internal/infoniqa/book.go @@ -0,0 +1,56 @@ +package infoniqa + +import ( + "fmt" + "net/url" + "strings" +) + +func (inf Infoniqa) Kommen() error { + if inf.lastBookingStatus == 1 { + return fmt.Errorf("last booking was already 'kommen'") + } + + return inf.book(1) +} + +func (inf *Infoniqa) Gehen() error { + if inf.lastBookingStatus == 2 { + return fmt.Errorf("last booking was already 'gehen'") + } + + return inf.book(2) +} + +// book books the given action that is identified behind the hotkey +func (inf *Infoniqa) book(hotkey int) error { + + // Build body with x-www-form-urlencoded content type (First without password and second with callback) + data := url.Values{} + data.Set("__WPPS", `u`) + data.Set("__EVENTARGUMENT", ``) + data.Set("__EVENTTARGET", ``) + data.Set("__VIEWSTATEGENERATOR", inf.viewStateGenerator) + data.Set("__VIEWSTATE", inf.viewstate) + data.Set("HotKey_SI_KTO_NR", fmt.Sprintf("%d", hotkey)) + + // Request with password + req := inf.getRequest("POST", "/includes/checkworkflow.aspx", strings.NewReader(data.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Origin", "https://hama.infoniqa.co.at") + + // Execute request + res, err := inf.client.Do(req) + if err != nil { + return err + } + + // Check status code + if res.StatusCode != 200 { + return fmt.Errorf("invalid status code (%d)", res.StatusCode) + } + + inf.lastBookingStatus = hotkey + + return nil +} diff --git a/internal/infoniqa/cookies.go b/internal/infoniqa/cookies.go new file mode 100644 index 0000000..8f35e80 --- /dev/null +++ b/internal/infoniqa/cookies.go @@ -0,0 +1,34 @@ +package infoniqa + +import ( + "net/http" + "net/url" + "sync" +) + +type Jar struct { + lk sync.Mutex + cookies map[string][]*http.Cookie +} + +func NewJar() *Jar { + jar := new(Jar) + jar.cookies = make(map[string][]*http.Cookie) + return jar +} + +// SetCookies handles the receipt of the cookies in a reply for the +// given URL. It may or may not choose to save the cookies, depending +// on the jar's policy and implementation. +func (jar *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) { + jar.lk.Lock() + jar.cookies[u.Host] = append(jar.cookies[u.Host], cookies...) + jar.lk.Unlock() +} + +// Cookies returns the cookies to send in a request for the given URL. +// It is up to the implementation to honor the standard cookie use +// restrictions such as in RFC 6265. +func (jar *Jar) Cookies(u *url.URL) []*http.Cookie { + return jar.cookies[u.Host] +} diff --git a/internal/infoniqa/infoniqa.go b/internal/infoniqa/infoniqa.go new file mode 100644 index 0000000..a200659 --- /dev/null +++ b/internal/infoniqa/infoniqa.go @@ -0,0 +1,180 @@ +package infoniqa + +import ( + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "git.rpjosh.de/RPJosh/go-logger" +) + +// Infoniqa is the base struct with all available functions that are +// implemented within this program +type Infoniqa struct { + // URL of the infoniqa instance + BaseUrl string + + // Username to log in + Username string + // Password for the user + Password string + + // The viewstate that was provided by infoniqa from the last request + viewstate string + // The last view state generator that was provided by infoniqa from the last request + viewStateGenerator string + // Client for executing the request. This does also store the cookies + client http.Client + + // Last booking status (0 = unknown, 1 = kommen, 2 = gehen) + lastBookingStatus int +} + +// NewInfoniqa creates a new infoniqa instance with the provided credentials. +// This function will execute an initialization sequence so that the other functions +// of this struct can be used. +// When this initialization sequence does fail, it will return an error +func NewInfoniqa(baseUrl string, username string, password string) (*Infoniqa, error) { + + // Create instance with parameters + inf := &Infoniqa{ + BaseUrl: baseUrl, + Username: username, + Password: password, + client: http.Client{Timeout: 5 * time.Second, Jar: NewJar()}, + } + + // Get the login page to login + _, res, err := inf.executeRequest(inf.getRequest("GET", "/Default.aspx", nil)) + if err != nil { + return nil, fmt.Errorf("fetching of login page failed: %s", err) + } + if res.StatusCode != 200 { + return nil, fmt.Errorf("unable to contact infoniqa site. Got status %d", res.StatusCode) + } + + // Execute the login + if err := inf.login(); err != nil { + return nil, fmt.Errorf("failed to login: %s", err) + } + + return inf, nil +} + +// getRequest returns a new request to the infoniqa API +func (inf *Infoniqa) getRequest(method string, path string, body io.Reader) *http.Request { + req, err := http.NewRequest(method, inf.BaseUrl+path, body) + if err != nil { + logger.Error("Failed to get infoniqa request: %s", err) + } + + // Set common headers + req.Header.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + + return req +} + +// executeRequest executes the given requests and updates aspx specific variables like the viewstate +// accordingly +func (inf *Infoniqa) executeRequest(req *http.Request) (body string, resp *http.Response, err error) { + + // Execute the request + res, err := inf.client.Do(req) + if err != nil { + return "", nil, err + } + + // Read the body + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", res, fmt.Errorf("reading of body failed: %s", err) + } + strBody := string(resBody) + + // Get the viewstate from the body + if st, err := inf.findHiddenValue("__VIEWSTATE", strBody); err != nil { + return "", res, fmt.Errorf("no viewstate found in response") + } else { + inf.viewstate = st + } + if st, err := inf.findHiddenValue("__VIEWSTATEGENERATOR", strBody); err != nil { + return "", res, fmt.Errorf("no viewstateGenerator found in response") + } else { + inf.viewStateGenerator = st + } + + return string(resBody), res, nil +} + +// findHiddenValue searches the given aspx value within the +// response body as a hidden input type. +func (inf *Infoniqa) findHiddenValue(name string, body string) (value string, err error) { + // Get the viewstate from the body + regex, err := regexp.Compile(``) + if err != nil { + return "", fmt.Errorf("failed to compile regex: %s", err) + } + matches := regex.FindStringSubmatch(body) + index := regex.SubexpIndex("Viewstate") + if index >= len(matches) { + return "", fmt.Errorf("no viewstate found in response") + } + + return matches[index], nil +} + +// login calls the login endpoint of infoniqa and sets the cookie and viewstate +// for all further requests correctly +func (inf *Infoniqa) login() error { + + // Build body with x-www-form-urlencoded content type (First without password and second with callback) + data := url.Values{} + data.Set("__EVENTTARGET", `ctl00$ContentPlaceHolder1$PanelLogin$PageControl$Login1$btnApgLogin`) + data.Set("__EVENTARGUMENT", `Click`) + data.Set("__VIEWSTATE", inf.viewstate) + data.Set("__VIEWSTATEGENERATOR", inf.viewStateGenerator) + data.Set("ctl00$Logininfo1$CheckPopupControlState", `{"windowsState":"0:0:-1:0:0:0:-10000:-10000:1:0:0:0"}`) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl", `{"activeTabIndex":0}`) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$Login1$UserName$State", `{"validationState":""}`) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$Login1$UserName", inf.Username) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$Login1$Password$State", `{"validationState":""}`) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$Login1$Password", inf.Password) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$PasswordRecovery$UserNameContainerID$UserName$State", `{"validationState":""}`) + data.Set("ctl00$ContentPlaceHolder1$PanelLogin$PageControl$PasswordRecovery$UserNameContainerID$UserName", "") + + // Request with password + req := inf.getRequest("POST", "/Default.aspx", strings.NewReader(data.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Origin", "https://hama.infoniqa.co.at") + + // Execute request + body, res, err := inf.executeRequest(req) + if err != nil { + return err + } + + // Check status code + if res.StatusCode != 200 { + return fmt.Errorf("login failed (%d)", res.StatusCode) + } + + // Get the last booking status + regex := regexp.MustCompile(`.*)', CAPTION.*\).*id="Zeitleiste".*<\/td>`) + matches := regex.FindStringSubmatch(body) + index := regex.SubexpIndex("State") + if index >= len(matches) { + logger.Debug("Couldn't find the last booking state") + } else if strings.HasPrefix(matches[index], "KO") { + inf.lastBookingStatus = 1 + } else { + inf.lastBookingStatus = 2 + logger.Debug("Found last booking state state %q", matches[index]) + } + + return nil +} diff --git a/internal/models/config.go b/internal/models/config.go new file mode 100644 index 0000000..871d757 --- /dev/null +++ b/internal/models/config.go @@ -0,0 +1,37 @@ +package models + +import ( + "os" + + "git.rpjosh.de/RPJosh/go-logger" + "gitea.hama.de/LFS/infoniqa-scripts/pkg/utils" + "gopkg.in/yaml.v3" +) + +type Config struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + Url string `yaml:"url"` +} + +// GetConfig reads the configuration from the file and returns this config struct. +// The program will be left if no valid configuration file was found +func GetConfig() *Config { + rtc := Config{} + + // Get the configuration path + configPath := utils.GetEnvString("INFONIQA_CONFIG", "./config") + + // Parse the file + dat, err := os.ReadFile(configPath) + if err != nil { + logger.Fatal("Failed to read configuration file %q: %s", configPath, err) + } + + // Unmarshal + if err := yaml.Unmarshal(dat, &rtc); err != nil { + logger.Fatal("Failed to unmarshal configuration file: %s", err) + } + + return &rtc +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..c1b273f --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,15 @@ +package utils + +import "os" + +// GetEnvString tries to get an environment variable from the system +// as a string value. If the env was not found the given default value +// will be returned +func GetEnvString(name string, defaultValue string) string { + val := defaultValue + if strVal, isSet := os.LookupEnv(name); isSet { + val = strVal + } + + return val +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..9b99e3b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +set -e + +GOOS=linux GOARCH=amd64 go build -o "infoniqa" ./cmd/infoniqa +GOOS=windows GOARCH=amd64 go build -o "infoniqa.exe" ./cmd/infoniqa + +echo "Build finished" \ No newline at end of file diff --git a/scripts/run.cmd b/scripts/run.cmd new file mode 100644 index 0000000..8eba0a9 --- /dev/null +++ b/scripts/run.cmd @@ -0,0 +1,35 @@ +@ECHO OFF + +:: Bypass the "Terminate Batch Job" prompt +if "%~1"=="-FIXED_CTRL_C" ( + :: Remove the -FIXED_CTRL_C parameter + SHIFT +) ELSE ( + :: Run the batch with