diff --git a/.env.example b/.env.example index 83a29ff..422d2e5 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,6 @@ TEST_DB_SSL= COOKIE_STRING= ALLOW_REGISTRATION= HEADLESS= -LOGGER_WEBHOOK_URL= \ No newline at end of file +LOGGER_WEBHOOK_URL= +FORCE_START_BOOKING= +TWOCAPTCHA_API_KEY= \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 2697526..15c0834 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -13,6 +13,7 @@ pipeline { COOKIE_STRING = credentials("COOKIE_STRING") ALLOW_REGISTRATION = credentials("ALLOW_REGISTRATION") LOGGER_WEBHOOK_URL = credentials("LOGGER_WEBHOOK_URL") + TWOCAPTCHA_API_KEY = credentials("TWOCAPTCHA_API_KEY") } stages { @@ -29,6 +30,8 @@ pipeline { sh 'echo COOKIE_STRING=$COOKIE_STRING >> .env' sh 'echo ALLOW_REGISTRATION=$ALLOW_REGISTRATION >> .env' sh 'echo LOGGER_WEBHOOK_URL=$LOGGER_WEBHOOK_URL >> .env' + sh 'echo FORCE_START_BOOKING=false >> .env' + sh 'echo TWOCAPTCHA_API_KEY=$TWOCAPTCHA_API_KEY >> .env' echo 'Clearing Git directory' sh 'rm -rf ./.git' } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9e98fd7..5f644a5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -39,6 +39,7 @@ func main() { log.Fatal("Error loading .env file") } environment := os.Getenv("ENVIRONMENT") + _ = environment db := common.InitDB() db.AutoMigrate(&user.User{}) @@ -48,9 +49,7 @@ func main() { r := chi.NewRouter() - if environment == "dev" { - r.Mount("/docs", httpSwagger.WrapHandler) - } + r.Mount("/docs", httpSwagger.WrapHandler) r.Mount("/api/v1/user", user.UserRoutes(db)) r.Mount("/api/v1/ktmtrainbot", ktmtrainbot.KTMTrainBotRoutes(db)) diff --git a/backend/internal/captchasolver/main.go b/backend/internal/captchasolver/main.go new file mode 100644 index 0000000..fad3d1d --- /dev/null +++ b/backend/internal/captchasolver/main.go @@ -0,0 +1 @@ +package captchasolver diff --git a/backend/internal/captchasolver/provider2captcha.go b/backend/internal/captchasolver/provider2captcha.go new file mode 100644 index 0000000..91c119a --- /dev/null +++ b/backend/internal/captchasolver/provider2captcha.go @@ -0,0 +1,103 @@ +package captchasolver + +import ( + "io" + "log" + "net/http" + "time" +) + +func Provider2CaptchaCreateCaptchaRequestV3(apiKey string, url string, googleKey string, googleAction string) string { + client := &http.Client{} + req, err := http.NewRequest("GET", "https://2captcha.com/in.php", nil) + if err != nil { + log.Print(err) + return "" + } + + q := req.URL.Query() + q.Add("key", apiKey) + q.Add("method", "userrecaptcha") + q.Add("version", "3") + q.Add("action", googleAction) + q.Add("min_score", "0.3") + q.Add("googlekey", googleKey) + q.Add("pageurl", url) + req.URL.RawQuery = q.Encode() + + res, err := client.Do(req) + if err != nil { + log.Print(err) + return "" + } + respBody, _ := io.ReadAll(res.Body) + respBodyStr := string(respBody) + + return respBodyStr[3:] +} + +func Provider2CaptchaCreateCaptchaRequestV2(apiKey string, url string, googleKey string) string { + client := &http.Client{} + req, err := http.NewRequest("GET", "https://2captcha.com/in.php", nil) + if err != nil { + log.Print(err) + return "" + } + + q := req.URL.Query() + q.Add("key", apiKey) + q.Add("method", "userrecaptcha") + q.Add("googlekey", googleKey) + q.Add("pageurl", url) + req.URL.RawQuery = q.Encode() + + res, err := client.Do(req) + if err != nil { + log.Print(err) + return "" + } + respBody, _ := io.ReadAll(res.Body) + respBodyStr := string(respBody) + + return respBodyStr[3:] +} + +func Provider2CaptchaGetCaptchaResult(apiKey string, captchaID string) string { + client := &http.Client{} + + req, err := http.NewRequest("GET", "http://2captcha.com/res.php", nil) + if err != nil { + log.Print(err) + return "" + } + + q := req.URL.Query() + q.Add("key", apiKey) + q.Add("action", "get") + q.Add("id", captchaID) + req.URL.RawQuery = q.Encode() + + res, err := client.Do(req) + if err != nil { + log.Print(err) + return "" + } + respBody, _ := io.ReadAll(res.Body) + respBodyStr := string(respBody) + + return respBodyStr +} + +func Provider2CaptchaV2E2E(apiKey string, url string, dataSiteKey string) string { + captchaReqID := Provider2CaptchaCreateCaptchaRequestV2(apiKey, url, dataSiteKey) + log.Println(captchaReqID) + captchaAnswer := "CAPCHA_NOT_READY" + for captchaAnswer == "CAPCHA_NOT_READY" { + time.Sleep(500 * time.Millisecond) + captchaAnswer = Provider2CaptchaGetCaptchaResult(apiKey, captchaReqID) + } + log.Printf("Captcha ID: %s; Captcha Answer: %s\n", captchaReqID, captchaAnswer) + captchaAnswer = captchaAnswer[3:] + + return captchaAnswer +} diff --git a/backend/internal/captchasolver/providerdeathbycaptcha.go b/backend/internal/captchasolver/providerdeathbycaptcha.go new file mode 100644 index 0000000..c829a56 --- /dev/null +++ b/backend/internal/captchasolver/providerdeathbycaptcha.go @@ -0,0 +1,102 @@ +package captchasolver + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" +) + +func ProviderDeathByCaptchaCreateCaptchaRequestV2(deathByCaptchaUsername string, deathByCaptchaPassword string, captchaUrl string, googleKey string) string { + httpClient := http.Client{} + + formValues := url.Values{ + "username": {deathByCaptchaUsername}, + "password": {deathByCaptchaPassword}, + "type": {"4"}, + "token_params": {fmt.Sprintf(`{"proxy": "", "proxytype": "", "googlekey": "%s","pageurl": "%s"}`, googleKey, captchaUrl)}, + } + req, err := http.NewRequest("POST", "http://api.dbcapi.me/api/captcha", strings.NewReader(formValues.Encode())) + if err != nil { + log.Print(err) + return "" + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Accept", "application/json") + req.Header.Add("Expect", "") + + resp, err := httpClient.Do(req) + if err != nil { + log.Print(err) + return "" + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Print(err) + return "" + } + + var resBodyJson map[string]any + err = json.Unmarshal(respBody, &resBodyJson) + if err != nil { + log.Print(err) + return "" + } + if _, exists := resBodyJson["captcha"]; !exists { + return "" + } + + captchaIDFloat := resBodyJson["captcha"].(float64) + captchaID := int(captchaIDFloat) + captchaIDStr := fmt.Sprintf("%d", captchaID) + + return captchaIDStr +} + +func ProviderDeathByCaptchaGetCaptchaResult(captchaID string) string { + client := &http.Client{} + + req, err := http.NewRequest("GET", fmt.Sprintf("http://api.dbcapi.me/api/captcha/%s", captchaID), nil) + if err != nil { + log.Print(err) + return "" + } + + req.Header.Add("Accept", "application/json") + res, err := client.Do(req) + if err != nil { + log.Print(err) + return "" + } + respBody, _ := io.ReadAll(res.Body) + var resBodyJson map[string]any + err = json.Unmarshal(respBody, &resBodyJson) + if err != nil { + log.Print(err) + return "" + } + if _, exists := resBodyJson["text"]; !exists { + return "" + } + + return resBodyJson["text"].(string) +} + +func ProviderDeathByCaptchaV2E2E(deathByCaptchaUsername string, deathByCaptchaPassword string, url string, dataSiteKey string) string { + captchaReqID := ProviderDeathByCaptchaCreateCaptchaRequestV2(deathByCaptchaUsername, deathByCaptchaPassword, url, dataSiteKey) + log.Println(captchaReqID) + captchaAnswer := "" + for captchaAnswer == "" { + time.Sleep(500 * time.Millisecond) + captchaAnswer = ProviderDeathByCaptchaGetCaptchaResult(captchaReqID) + } + log.Printf("Captcha ID: %s; Captcha Answer: %s\n", captchaReqID, captchaAnswer) + + return captchaAnswer +} diff --git a/backend/internal/ktmtrainbot/backgroundbookingjob.go b/backend/internal/ktmtrainbot/backgroundbookingjob.go index f9b2c9a..f79fd93 100644 --- a/backend/internal/ktmtrainbot/backgroundbookingjob.go +++ b/backend/internal/ktmtrainbot/backgroundbookingjob.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "git.samuelpua.com/telboon/ktm-train-bot/backend/internal/captchasolver" "git.samuelpua.com/telboon/ktm-train-bot/backend/internal/user" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/devices" @@ -38,6 +39,12 @@ func (env *Env) BackgroundJobRunner() { }, ) + forceStartBookingString := os.Getenv("FORCE_START_BOOKING") + forceStartBooking := false + if strings.ToUpper(forceStartBookingString) == "TRUE" { + forceStartBooking = true + } + tx := env.DB.Session(&gorm.Session{Logger: newLogger}) for { @@ -80,7 +87,7 @@ func (env *Env) BackgroundJobRunner() { startTime := time.Date(timeNow.Year(), timeNow.Month(), timeNow.Day(), 00, 10, 0, 0, timeNow.Location()) endTime := startTime.Add(15 * time.Minute) - if timeNow.After(startTime) && timeNow.Before(endTime) { + if forceStartBooking || (timeNow.After(startTime) && timeNow.Before(endTime)) { err := env.DB.Where(&user.Profile{UserID: jobToDo.UserID}).First(&jobToDo.User.Profile).Error if err != nil { log.Println(err) @@ -425,6 +432,8 @@ func getBookingSlots(browser *rod.Browser, onwardDate string, reverse bool) *rod func selectBookingSlot(ctx context.Context, page *rod.Page, timeCode string) *rod.Page { time.Sleep(5 * time.Second) + twoCaptchaAPIKey := os.Getenv("TWOCAPTCHA_API_KEY") + // Initial closing of maintenance modal bodyText := page.MustElement("body").MustText() if strings.Contains(bodyText, "System maintenance scheduled at 23:00 to 00:15 (UTC+8)") { @@ -491,6 +500,22 @@ func selectBookingSlot(ctx context.Context, page *rod.Page, timeCode string) *ro time.Sleep(1000 * time.Millisecond) } + // Check if there is captcha + if strings.Contains(bodyText, "Please complete the reCAPTCHA") { + // Reset Body text + time.Sleep(500 * time.Millisecond) + + log.Println("Captcha detected") + currURL := "https://shuttleonline.ktmb.com.my/ShuttleTrip" + // Regex research for Google Captcha V2 API Key + gcaptchaElement := page.MustElement(".g-recaptcha") + googleRecaptchaKey := gcaptchaElement.MustAttribute("data-sitekey") + log.Printf("Google CAPTCHA V2 Key: %s", *googleRecaptchaKey) + captchaAnswer := captchasolver.Provider2CaptchaV2E2E(twoCaptchaAPIKey, currURL, *googleRecaptchaKey) + page.MustElement("#g-recaptcha-response").Eval(`this.innerHTML = "` + captchaAnswer + `"`) + page.Eval(`RecaptchaCallback()`) + } + // Repeat if there's no seats -- seats might be released later if strings.Contains(bodyText, "Not enough seat for onward trip") { completed = false