package main import ( "bytes" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "regexp" "slices" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" log "github.com/sirupsen/logrus" ) var ( client *http.Client requestCounter uint lastReload time.Time parsePattern = regexp.MustCompile(`\s*(.+)\n\s+(.+)\n\s+(\d+)\s*`) vehicleIdPattern = regexp.MustCompile(`data-vehicle-id="(\d+)"`) timestampPattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2} [AP]M`) cachedLocations []Location cachedLocationsMap map[uint]Location cacheExpiry time.Time ) func init() { lastReload = time.Now().Add(-time.Hour * 24 * 365) // Set the last reload time to a year ago cacheExpiry = time.Now().Add(-time.Second) // Set the cache as expired initially cookies, err := cookiejar.New(nil) if err != nil { log.Fatal(err) } client = &http.Client{Jar: cookies} } func onRequest(req *http.Request) { log.WithFields(log.Fields{ "method": req.Method, "url": req.URL.String(), }).Debugf("%s %s", req.Method, req.URL.String()) requestCounter++ } func onResponse(res *http.Response) { log.WithFields(log.Fields{ "status": res.Status, "length": res.ContentLength, "type": res.Header["Content-Type"], }).Debugf("%s %d %s", res.Status, res.ContentLength, res.Header["Content-Type"]) } // Reloads the current session and completes the initial connection process that procedes bot operations. // This should be done regularly, such as after 10 requests or 15 minutes. func reload() { // Ring the doorbell req := BuildRequest("GET", "/", nil) doRequest(req) // Ring the second doorbell (seems to be a way of validating whether a client is a 'browser' or not) req = BuildRequest("GET", "/index.php", map[string]string{ "width": "1920", "height": "1080", }) doRequest(req) // Verify that a PHPSESSID cookie is present site_cookies := client.Jar.Cookies(req.URL) has_php_session := slices.ContainsFunc(site_cookies, func(cookie *http.Cookie) bool { return cookie.Name == "PHPSESSID" }) if !has_php_session { log.WithFields(log.Fields{"cookies": site_cookies}).Panic("PHPSESSID cookie not found") } else { log.Debugf("PHPSESSID cookie found") } } // Attempts to reload the current session based on a given location's name. // This uses the current request counter and last reload time to determine if a reload is necessary. func tryReload() { currentTime := time.Now() lastReloadDiff := currentTime.Sub(lastReload) if requestCounter >= 10 { log.WithFields(log.Fields{"requestCounter": requestCounter}).Info("Reloading session due to request count") log.Info("Reloading session due to request count") } else if lastReloadDiff >= 15*60 { log.WithFields(log.Fields{"lastReload": lastReload, "difference": lastReloadDiff}).Info("Reloading session due to time") } else { return } reload() lastReload = currentTime requestCounter = 0 } func GetLocations() []Location { if time.Now().Before(cacheExpiry) { return cachedLocations } tryReload() log.WithFields(log.Fields{"sinceRefresh": time.Now().Sub(cacheExpiry)}).Debug("Refetching locations") body := "propertyNameEntered=" // Empty, so we get all locations req := BuildRequestWithBody("POST", "/register-get-properties-from-name", nil, bytes.NewBufferString(body)) SetTypicalHeaders(req, nil, nil, true) res, err := doRequest(req) if err != nil { log.Fatal(err) } doc, err := goquery.NewDocumentFromReader(res.Body) if err != nil { log.Print(err) return nil } cachedLocations = make([]Location, 0, 150) doc.Find("input.property").Each(func(i int, s *goquery.Selection) { matches := parsePattern.FindStringSubmatch(s.Parent().Text()) key_attr, _ := s.Attr("data-locate-key") id_attr, _ := s.Attr("value") id, _ := strconv.ParseUint(id_attr, 10, 32) cachedLocations = append(cachedLocations, Location{ id: uint(id), key: key_attr, name: matches[1], address: matches[2] + " " + matches[3], }) }) // Build the map cachedLocationsMap = make(map[uint]Location, len(cachedLocations)) for _, location := range cachedLocations { cachedLocationsMap[location.id] = location } cacheExpiry = time.Now().Add(time.Hour * 8) // Cache for 8 hours return cachedLocations } type GetFormResult struct { propertyName string address string fields []Field // label & inputs in the form hiddenInputs []string // hidden inputs in the form requireGuestCode bool // whether a guest code is required residentProfileId string err error // any error that occurred } func GetForm(id uint) GetFormResult { body := fmt.Sprintf("propertyIdSelected=%d&propertySource=parking-snap", id) req := BuildRequestWithBody("POST", "/register-get-vehicle-form", nil, bytes.NewBufferString(body)) SetTypicalHeaders(req, nil, nil, false) res, _ := doRequest(req) // Read and parse the HTML response body html, _ := io.ReadAll(res.Body) htmlString := string(html) doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(htmlString)) if err != nil { return GetFormResult{err: err} } // Check if this form is a VIP property if CheckGuestCodeRequired(doc) { return GetFormResult{ requireGuestCode: true, } } // TODO: Validate that vehicleInformationVIP is actually real for non-VIP properties // Get the resident profile nextButton := doc.Find("#vehicleInformationVIP").First() residentProfileId, _ := nextButton.Attr("data-resident-profile-id") // Get the hidden inputs & form fields formFields, hiddenInputs := GetFields(doc) // Acquire the title/address titleElement := doc.Find("div > div > h4").First() title := titleElement.Text() address := strings.TrimSpace(titleElement.Next().Text()) return GetFormResult{ propertyName: title, address: address, fields: formFields, hiddenInputs: hiddenInputs, residentProfileId: residentProfileId, } } func GetVipForm(id uint, guestCode string) GetFormResult { body := fmt.Sprintf("propertyIdSelected=%d&propertySource=parking-snap&guestCode=%s", id, guestCode) req := BuildRequestWithBody("POST", "/register-get-vip-vehicle-form", nil, bytes.NewBufferString(body)) SetTypicalHeaders(req, nil, nil, false) res, _ := doRequest(req) html, _ := io.ReadAll(res.Body) htmlString := string(html) if htmlString == "guest-code" { return GetFormResult{ requireGuestCode: true, err: fmt.Errorf("guest code is invalid"), } } doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(htmlString)) if err != nil { return GetFormResult{err: err} } // Get the hidden inputs & form fields formFields, hiddenInputs := GetFields(doc) // Acquire the title/address titleElement := doc.Find("div > div > h4").First() title := titleElement.Text() address := strings.TrimSpace(titleElement.Next().Text()) // Acquire the resident profile ID nextButton := doc.Find("#vehicleInformationVIP").First() residentProfileId, exists := nextButton.Attr("data-resident-profile-id") if !exists { return GetFormResult{err: errors.New("resident profile ID not found")} } return GetFormResult{ propertyName: title, address: address, fields: formFields, hiddenInputs: hiddenInputs, residentProfileId: residentProfileId, } } type RegistrationResult struct { success bool timestamp time.Time confirmationCode string vehicleId string } func RegisterVehicle(formParams map[string]string, propertyId uint, residentProfileId uint, hiddenParams []string) (*RegistrationResult, error) { body := url.Values{} body.Set("propertySource", "parking-snap") body.Set("propertyIdSelected", strconv.FormatUint(uint64(propertyId), 10)) body.Set("residentProfileId", strconv.FormatUint(uint64(residentProfileId), 10)) // Some parameters in the form are hidden, so they're just set empty to mimic the browser's behavior for _, hiddenParam := range hiddenParams { body.Set(hiddenParam, "") } // These parameters are actively used in the form for key, value := range formParams { body.Set(key, value) } // Build the request req := BuildRequestWithBody("POST", "/register-vehicle-vip-process", nil, strings.NewReader(body.Encode())) SetTypicalHeaders(req, nil, nil, false) // Send the request res, err := doRequest(req) if err != nil { return nil, err } // Read the response body html, err := io.ReadAll(res.Body) if err != nil { return nil, err } htmlString := string(html) // Success can be measured with the presence of either "Approved" or "Denied" (if neither, it's an error) if strings.Contains(htmlString, "Approved") { result := &RegistrationResult{success: true} // Search for 'data-vehicle-id' with regex vehicleIdMatches := vehicleIdPattern.FindStringSubmatch(htmlString) if len(vehicleIdMatches) > 1 { result.vehicleId = vehicleIdMatches[1] } // Find the confirmation code doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(htmlString)) if err != nil { return nil, fmt.Errorf("failed to parse HTML of approved registration result partial: %w", err) } // Look for a 'p' tag that contains the text 'Confirmation Code:' var sibling *goquery.Selection doc.Find("div.circle-inner > p").Each(func(i int, s *goquery.Selection) { // If we've already found the element, stop searching if sibling != nil { return } else if strings.Contains(s.Text(), "Confirmation Code:") { sibling = s } }) // The confirmation code is the next sibling of the 'p' tag if sibling != nil { result.confirmationCode = sibling.Next().Text() } // Find timestamp: look for a 'strong' tag that contains 'Registration Date/Time:' inside a p, inside a div.circle-inner var parent *goquery.Selection doc.Find("div.circle-inner > p").Each(func(i int, s *goquery.Selection) { // If we've already found the element, stop searching if parent != nil { return } else if strings.Contains(s.Text(), "Registration Date/Time:") { parent = s.Parent() } }) // The timestamp is a untagged text node inside the p tag if parent != nil { // Get the raw text of the parent, then find the timestamp within it rawText := parent.Text() match := timestampPattern.FindString(rawText) // If we found a match, parse it into a time.Time if match != "" { timestamp, err := time.Parse("2006-01-02 03:04 PM", match) // Silently log the error if timestamp parsing fails if err != nil { log.Errorf("failed to parse timestamp: %v", err) result.timestamp = time.Time{} } else { result.timestamp = timestamp } } } return result, nil } // Confirmed denial, cleanly return if strings.Contains(htmlString, "Denied") { // TODO: Find and parse timestamp return &RegistrationResult{success: false}, nil } return nil, errors.New("unexpected response") } // RegisterEmailConfirmation sends a request to the server to send a confirmation email regarding a vehicle's registration. // Example Parameters: email=xevion@xevion.dev, vehicleId=63283, propertyId=63184 func RegisterEmailConfirmation(email string, vehicleId string, propertyId string) (bool, error) { params := map[string]string{ "email": email, "vehicleId": vehicleId, "propertyId": propertyId, "propertySource": "parking-snap", } req := BuildRequestWithBody("GET", "/register-vehicle-confirmation-process", params, nil) SetTypicalHeaders(req, nil, nil, false) res, _ := doRequest(req) if res.StatusCode != 200 { return false, fmt.Errorf("unexpected status code: %d", res.StatusCode) } html, _ := io.ReadAll(res.Body) htmlString := string(html) if htmlString == "ok" { return true, nil } return false, fmt.Errorf("unexpected response: %s", htmlString) }