(Solved) Client Authentication failed.

Forum for users and developers of Bullhorn's new REST API service.

Moderators: StaffingSupport, s.emmons, BullhornSupport

mavieo
User
Posts: 11
Joined: Mon Apr 10, 2017 2:01 pm

(Solved) Client Authentication failed.

Postby mavieo » Mon Apr 10, 2017 2:11 pm

Support via ticket system is stuuuuupid slow. I'm hoping the community or the support peeps who view these forums are able to help out.

The code attached is pretty much untested because I cannot retrieve an authorization code (BullhornAPI::oAuth) - My client would be very grateful if we could get the code working today.

If you scroll down the code to the BullhornAPI::oAuth method, you'll see how the only response I'm able to get when attempting to retreive an authorization code is "array('error' => 'invalid_client', 'error_description' => 'Client authentication failed.')".

How to fix? :cry: Many thanks.

Code: Select all

<?php

class BullhornAPI {
   const API_USERNAME = '[created via support ticket]';
   const API_PASSWORD = '[set in admin]';
   const CLIENT_ID = '[created via support ticket]';
   const CLIENT_SECRET = '[created via support ticket]';
   const ENDPOINT_AUTH_CODE = 'https://auth.bullhornstaffing.com/oauth/authorize';
   const ENDPOINT_ACCESS_TOKEN = 'https://rest-west.bullhornstaffing.com/oauth/token?%s';
   const ENDPOINT_REST_TOKEN = 'https://rest.bullhornstaffing.com/rest-services/login?version=*&access_token=%s';
   const PUBLIC_ERROR = 'We\'re unable to process your application at this time. <a href="{{ pages:url id="2" }}">Contact a site administrator to learn more</a>.';
   const RESUME_FILE_TYPE = 'Resume'; # Must exist in BH account
   const REWIND_CREATE_ON_RESUME_FAIL = TRUE;
   const SHOW_LOG = FALSE;

   private $access_token;
   private $auth_code;
   private $comm_response;
   private $comm_response_info;
   private $errors = array();
   private $is_dev = FALSE;
   private $refresh_token;
   private $rest_token;
   private $rest_endpoint_url;

   public function __construct() {
      if(function_exists('is_dev')) :
         $this->is_dev = (bool) is_dev();
      endif;
   }

   public function candidateAttachResume($candidate_id, $resume_path=NULL) {
      if(TRUE !== $this->oAuth()) :
         return FALSE;
      endif;

      if(in_array(self::RESUME_FILE_TYPE, array(NULL, ''))) :
         $this->errorsSet('Empty resume file type.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      if(NULL === $resume_path) :
         $this->errorsSet('Empty resume path.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      if(!is_readable($resume_path)) :
         $this->errorsSet('Unable to locate resume.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      $args = array(
         'path' => $resume_path,
      );

      return $this->comm(sprintf('file/Candidate/%d/raw?externalID=Portfolio&fileType=%s', $candidate_id, self::RESUME_FILE_TYPE), 'FILE', $args);
   }

   public function candidateCreate($args=array(), $resume_path=NULL) {
      if(TRUE !== $this->oAuth()) :
         return FALSE;
      endif;

      if(FALSE === ($args = self::candidatePrepData($args))) :
         return FALSE;
      endif;

      if(FALSE === $this->comm('entity/Candidate', 'PUT', $args)) :
         return FALSE;
      endif;

      if(NULL !== $resume_path) :
         $candidate_id = $this->comm_response['changedEntityId'];

         if(FALSE === $this->candidateAttachResume($candidate_id, $resume_path)) :
            if(TRUE === self::REWIND_CREATE_ON_RESUME_FAIL) :
               $this->candidateDelete($candidate_id);
            endif;

            return FALSE;
         endif;
      endif;

      die(__METHOD__.' finished!');
      return TRUE;
   }

   public function candidateDelete($candidate_id=NULL) {
      if(TRUE !== $this->oAuth()) :
         return FALSE;
      endif;

      if(empty($candidate_id) || !is_numeric($candidate_id)) :
         ob_start(); var_dump($candidate_id);

         $this->errorsSet(array(
            'Delete aborted: Invalid candidate id',
            'Candidate id: '.ob_get_clean(),
         ), __METHOD__, __LINE__);
         
         return FALSE;
      endif;

      $this->log('Deleting candidate '.$candidate_id);

      $args = array(
         'isDeleted' => '1',
      );

      if(FALSE === $this->comm('entity/Candidate/'.$candidate_id, 'POST', $args)) :
         return FALSE;
      endif;

      return TRUE;
   }

   public function candidateFind($args=array(), $return_ids=FALSE) {
      if(TRUE !== $this->oAuth()) :
         return FALSE;
      endif;

      $args = array_merge(array(
         'first_name' => '',
         'last_name' => '',
         'email' => '',
         'is_deleted' => '0',
      ), $args);

      $select_args = array(
         'and' => $args,
      );

      if(NULL === ($query = $this->searchBuildQuery($select_args, array('id', 'first_name', 'last_name', 'email')))) :
         $this->errorsSet(array(
            'Failed to build search query',
         ), __LINE__, __METHOD__);

         return FALSE;
      endif;

      if(FALSE === $this->comm(sprintf('search/Candidate?%s', $query), 'GET')) :
         return FALSE;
      endif;

      if(!is_array($this->comm_response) || !isset($this->comm_response['total']) || 0 === $this->comm_response['total'] || !isset($this->comm_response['data'][0])) :
         return NULL;
      endif;

      $perfect_matches = array();
      foreach($this->comm_response['data'] as $match) :
         if(1 !== (int) $match['_score']) :
            continue;
         endif;

         $perfect_matches[] = $match;
      endforeach;

      if(empty($perfect_matches)) :
         return NULL;
      endif;

      if(FALSE === $return_ids) :
         return $perfect_matches;
      endif;

      $return = array();
      foreach($perfect_matches as $match) :
         $return[] = $match['id'];
      endforeach;

      return $return;
   }

   private static function candidatePrepData($data=array()) {
      if(!is_array($data) || empty($data)) :
         $this->errorsSet('Invalid candidate data', __LINE__, __METHOD__);
         return FALSE;
      endif;

      if(isset($data['gender'])) :
         $data['gender'] = substr($data['gender'], 0, 1);
      endif;

      if(isset($data['veteran'])) :
         $data['veteran'] = substr($data['veteran'], 0, 1);
      endif;

      if(isset($data['occupation']) && 50 < strlen($data['occupation'])) :
         $data['occupation'] = substr($data['occupation'], 0, 47).'...';
      endif;

      # Deal with date available
      if(isset($data['dateAvailable']) && !empty($data['dateAvailable'])) :
         if(FALSE === ($date_available_ts = strtotime($data['dateAvailable']))) :
            unset($candidate_data['dateAvailable']);
         else :
            # Add 12 hours so BH admin doesn't use the incorrect day...
            $date_available_ts += 3600*12;
         
            # Convert it to milliseconds so BH API knows what to do with it
            $date_available_ts = $date_available_ts.'000';
         
            # Convert it to an int so BH API doesn't complain about invalid scalar
            $date_available_ts = (int) $date_available_ts;
         
            $data['dateAvailable'] = $date_available_ts;
         endif;
      endif;
      
      return $data;
   }

   public function candidateUpdate($candidate_id=NULL, $args=array(), $resume_path=NULL) {
      if(TRUE !== $this->oAuth()) :
         return FALSE;
      endif;

      if(empty($candidate_id) || !is_numeric($candidate_id)) :
         ob_start(); var_dump($candidate_id);

         $this->errorsSet(array(
            'Delete aborted: Invalid candidate id',
            'Candidate id: '.ob_get_clean(),
         ), __METHOD__, __LINE__);
         
         return FALSE;
      endif;

      if(FALSE === ($args = self::candidatePrepData($args))) :
         return FALSE;
      endif;

      if(FALSE === $this->comm('entity/Candidate/'.$candidate_id, 'POST', $args)) :
         return FALSE;
      endif;

      if(NULL !== $resume_path) :
         if(FALSE === $this->candidateAttachResume($candidate_id, $resume_path)) :
            $this->candidateDelete($candidate_id);
            return FALSE;
         endif;
      endif;
die(__METHOD__.' finished!');
      return TRUE;
   }

   private function comm($endpoint_script=NULL, $endpoint_method='POST', $endpoint_args=array(), $endpoint_url=NULL) {
      $this->comm_response = NULL;
      $this->comm_response_info = NULL;

      if(NULL === $endpoint_url) :
         $endpoint_url = $this->rest_endpoint_url.ltrim($endpoint_script, '/');
      endif;

      if(!empty($this->rest_token)) :
         if(strstr($endpoint_url, '?')) :
            $endpoint_url = str_replace('?', '?BhRestToken='.urlencode($this->rest_token).'&', $endpoint_url);
         else :
            $endpoint_url = $endpoint_url.'?BhRestToken='.urlencode($this->rest_token);
         endif;
      endif;

      $ch = curl_init();
      curl_setopt($ch, CURLOPT_URL, $endpoint_url);
      # curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
      curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);

      switch($endpoint_method) :
         case 'FILE' :
            if(!is_array($endpoint_args) || !isset($endpoint_args['path'])) :
               $this->errorsSet('Missing argument "path"', __LINE__, __METHOD__);
               return FALSE;
            endif;

            if(FALSE === ($fp = fopen($endpoint_args['path'], 'rb'))) :
               $this->errorsSet('Failed to open file for reading', __LINE__, __METHOD__);
               return FALSE;
            endif;

            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
            curl_setopt($ch, CURLOPT_HEADER, TRUE);
            curl_setopt($ch, CURLOPT_BINARYTRANSFER, TRUE);
            curl_setopt($ch, CURLOPT_INFILE, $fp);
            curl_setopt($ch, CURLOPT_INFILESIZE, filesize($endpoint_args['path']));

            define('DEBUG', TRUE);
         break;
         case 'GET' :
            if(is_array($endpoint_args) && !empty($endpoint_args)) :
               $url_endpoint_args = http_build_query($endpoint_args);
            else :
               $url_endpoint_args = '';
            endif;

            curl_setopt($ch, CURLOPT_URL, rtrim($endpoint_url, '?').'?'.$url_endpoint_args);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
         break;
         case 'POST' :
            curl_setopt($ch, CURLOPT_POST, TRUE);

            if(is_array($endpoint_args) && !empty($endpoint_args)) :
               $endpoint_args_string = json_encode($endpoint_args);

               curl_setopt($ch, CURLOPT_POSTFIELDS, $endpoint_args_string);
               curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', 'Content-Length: '.strlen($endpoint_args_string)));
            endif;
         break;
         case 'PUT' :
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');

            if(!empty($endpoint_args)) :
               $endpoint_args_string = json_encode($endpoint_args);

               curl_setopt($ch, CURLOPT_POSTFIELDS, $endpoint_args_string);
               curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', 'Content-Length: '.strlen($endpoint_args_string)));
            endif;
         break;
      endswitch;

      $this->comm_response = curl_exec($ch);
      $this->comm_response_info = curl_getinfo($ch);

      curl_close($ch);

      if(FALSE === $this->comm_response) :
         $this->errorsSet(array(
            'Comm error (cURL)',
            'Endpoint: '.$endpoint_url,
            'REST method: '.$endpoint_method,
            'REST args: '.((empty($endpoint_args)) ? 'NULL' : json_encode($endpoint_args)),
            'Error: '.curl_error($ch),
         ), __LINE__, __METHOD__);
      elseif(NULL === ($this->comm_response = json_decode($this->comm_response, TRUE))) :
         $this->errorsSet(array(
            'Comm error (Failed to decode response)',
            'Endpoint: '.$endpoint_url,
            'REST method: '.$endpoint_method,
            'REST args: '.((empty($endpoint_args)) ? 'NULL' : json_encode($endpoint_args)),
            'Response: '.$this->comm_response,
            'Response info: '.json_encode($this->comm_response_info),
         ), __LINE__, __METHOD__);
      elseif(!is_array($this->comm_response)) :
         $this->errorsSet(array(
            'Comm error (Response is not an array)',
            'Endpoint: '.$endpoint_url,
            'REST method: '.$endpoint_method,
            'REST args: '.((empty($endpoint_args)) ? 'NULL' : json_encode($endpoint_args)),
            'Response: '.$this->comm_response,
            'Response info: '.json_encode($this->comm_response_info),
         ), __LINE__, __METHOD__);
      elseif(isset($this->comm_response['error']) || isset($this->comm_response['errorCode'])) :
         $this->errorsSet(array(
            'Comm error (Endpoint error)',
            'Endpoint: '.$endpoint_url,
            'REST method: '.$endpoint_method,
            'REST args: '.((empty($endpoint_args)) ? 'NULL' : json_encode($endpoint_args)),
            'Response: '.json_encode($this->comm_response),
            'Response info: '.json_encode($this->comm_response_info),
         ), __LINE__, __METHOD__);
      endif;

      if(0 < sizeof($this->errors)) :
         return FALSE;
      endif;

      return $this->comm_response;
   }

   public function errorsGetAll() {
      return $this->errors;
   }

   public function errorsGetLast() {
      return empty($this->errors) ? NULL : end($this->errors);
   }

   private function errorsSet($message=NULL, $line=NULL, $method=NULL) {
      if(TRUE !== $this->is_dev) :
         $message = self::PUBLIC_ERROR;
      endif;

      $message = !is_array($message) ? array($message) : $message;
      $prefix = '';
      $suffix = '';

      if(TRUE !== $this->is_dev) :
         $prefix = empty($line) ? '' : '[Err'.$line.']';
      else :
         array_unshift($message, 'Method: '.$method, 'Line: '.$line);
      endif;

      $message = implode('<br>', $message);
      $message = trim($prefix.' '.$message.' '.$suffix);

      $this->errors[] = array('method' => $method, 'line' => $line, 'message' => $message);
      return TRUE;
   }

   private function log($msg) {
      if(FALSE === $this->is_dev || FALSE === self::SHOW_LOG) :
         return;
      endif;

      echo $msg.'<br>';
   }

   private function oAuth() {
      if('' === self::CLIENT_ID || '' === self::CLIENT_SECRET || '' === self::API_USERNAME || '' === self::API_PASSWORD) :
         $this->errorsSet(array(
            'Empty client id, client secret, api username, or api password.',
            'Follow instructions located at: http://developer.bullhorn.com/articles/getting_started',
            'Hint: Request API access via support ticket',
         ), __LINE__, __METHOD__);

         return FALSE;
      endif;

      if(TRUE !== $this->oAuthSetRestToken()) :
         return FALSE;
      endif;

      if(TRUE !== $this->oAuthLogin()) :
         return FALSE;
      endif;

      return TRUE;
   }

   private function oAuthLogin() {
      if(FALSE === $this->comm(NULL, 'POST', $args, sprintf(self::ENDPOINT_REST_TOKEN, urlencode($this->access_token)))) :
         return FALSE;            
      endif;

      $this->rest_endpoint_url = $this->comm_response['restUrl'];
      $this->rest_token = $this->comm_response['BhRestToken'];

      return TRUE;
   }

   private function oAuthSetAuthCode($force=FALSE) {
      if(FALSE === $force && !empty($this->auth_code)) :
         return TRUE;
      endif;

      $cmd_args = array(
         'client_id' => self::CLIENT_ID,
         'response_type' => 'code',
         'username' => self::API_USERNAME,
         'password' => self::API_PASSWORD,
         'action' => 'Login',
      );

      $ch = curl_init();
      curl_setopt($ch, CURLOPT_URL, self::ENDPOINT_AUTH_CODE.'?'.http_build_query($cmd_args));
      curl_setopt($ch, CURLOPT_HEADER, FALSE);
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
      curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE);
      $response = curl_exec($ch);
      $response_info = curl_getinfo($ch);

      if(!preg_match('@\?code=(.*)&@i', $response_info['url'], $auth_code)) :
         $this->errorsSet(array(
            'Failed to retreive auth code',
         ), __LINE__, __METHOD__);

         return FALSE;
      endif;

      $this->auth_code = urldecode($auth_code[1]);
      $this->log('Setting auth code: "'.$this->auth_code.'"');

      return TRUE;
   }

   private function oAuthSetRestToken() {
      if(!empty($this->refresh_token)) :
         $args = array(
            'grant_type' => 'refresh_token',
            'refresh_token' => $this->refresh_token,
            'client_id' => self::CLIENT_ID,
            'client_secret' => self::CLIENT_SECRET,
         );

         if(FALSE === $this->comm(NULL, 'POST', NULL, sprintf(self::ENDPOINT_ACCESS_TOKEN, http_build_query($args)))) :
            return FALSE;
         endif;

         $this->log('Setting access token (via refresh): "'.$this->comm_response['access_token'].'"');
         $this->log('Setting refresh token (via refresh): "'.$this->comm_response['refresh_token'].'"');

         $this->access_token = $this->comm_response['access_token'];
         $this->refresh_token = $this->comm_response['refresh_token'];
         
         return TRUE;
      endif;

      if(FALSE === $this->oAuthSetAuthCode()) :
         return FALSE;
      endif;

      # Fetch access token
      $args = array(
         'grant_type' => 'authorization_code',
         'code' => $this->auth_code,
         'client_id' => self::CLIENT_ID,
         'client_secret' => self::CLIENT_SECRET,
      );

      if(FALSE === $this->comm(NULL, 'POST', NULL, sprintf(self::ENDPOINT_ACCESS_TOKEN, http_build_query($args)))) :
         return FALSE;
      endif;

      $this->log('Setting access token: "'.$this->comm_response['access_token'].'"');
      $this->log('Setting refresh token: "'.$this->comm_response['refresh_token'].'"');
         
      $this->access_token = $this->comm_response['access_token'];
      $this->refresh_token = $this->comm_response['refresh_token'];

      return TRUE;
   }

   private function searchBuildQuery($query_fields=array(), $select_args=array()) {
      $operator_groups = array('and', 'or', 'custom');

      $possible_fields = array(
         'id' => 'id',
         'ID' => 'id',
         'first_name' => 'firstName',
         'firstName' => 'firstName',
         'last_name' => 'lastName',
         'email' => 'email',
         'is_deleted' => 'isDeleted',
         'isDeleted' => 'isDeleted',
      );

      if(empty($query_fields)) :
         $this->errorsSet('Query fields cannot be empty.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      $valid_operator = FALSE;
      foreach($operator_groups as $operator) :
         if(!isset($query_fields[$operator]) || empty($query_fields[$operator]) || !is_array($query_fields[$operator])) :
            continue;
         endif;

         $valid_operator = TRUE;
         break;
      endforeach;

      if(FALSE === $valid_operator) :
         $this->errorsSet('Invalid query fields.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      # Translate select_args into bh_fields
      foreach($select_args as $k => $v) :
         if(!isset($possible_fields[$v])) :
            unset($select_args[$operator][$k]);
            continue;
         endif;

         $select_args[$k] = $possible_fields[$v];
      endforeach;

      if(empty($select_args)) :
         $this->errorsSet('Select cannot be empty.', __LINE__, __METHOD__);
         return FALSE;
      endif;

      $return = array(
         'query' => '',
         'fields' => implode(',', $select_args),
      );

      foreach($query_fields as $operator_group => $operator_group_fields) :
         $return['query'] .= empty($return['query']) ? '' : ' AND ';

         if('custom' === $operator_group) :
            $return['query'] .= $operator_group_fields;
            continue;
         endif;
 
         $operator_fields = array();

         foreach($operator_group_fields as $operator_group_field => $operator_group_field_value) :
            if(!isset($possible_fields[$operator_group_field]) || in_array(trim($operator_group_field_value), array(NULL, ''))) :
               continue;
            endif;

            $operator_fields[] = $possible_fields[$operator_group_field].':'.$operator_group_field_value;
         endforeach;

         $return['query'] .= '('.implode(' '.strtoupper($operator_group).' ', $operator_fields).')';
      endforeach;

      if(empty($return['query'])) :
         $this->errorsSet('Invalid query fields', __LINE__, __METHOD__);
         return NULL;
      endif;

      return http_build_query($return);
   }
}

// End BullhornAPI class
Last edited by mavieo on Fri Apr 14, 2017 11:11 am, edited 10 times in total.

jimh88
User
Posts: 39
Joined: Mon Apr 11, 2016 12:34 pm

Re: Client Authentication failed. (Code Audit)

Postby jimh88 » Tue Apr 11, 2017 12:54 pm

Have you tried using something like the Postman app (for Chrome browser) to test the auth steps? That may help give you more clarity into everything especially if you're using untested code.

Support help here is sometimes like winning the lottery. It seems like they randomly pick one lucky person to respond to once a week. :(

mavieo
User
Posts: 11
Joined: Mon Apr 10, 2017 2:01 pm

Re: Client Authentication failed. (Code Audit)

Postby mavieo » Tue Apr 11, 2017 3:31 pm

Thank you for being kind enough to post.

I just installed postman and RESTED for Chrome and HTTP Resource Test for Firefox (yesterday). They all return invalid_client. Support emailed me with the following steps....

Code: Select all

This is Charles from Bullhorn Support. I was able to successfully access the API by manually going through the steps in the application postman.  I am including the exact steps I have taken below.

1) navigated to https://auth.bullhornstaffing.com/oauth/authorize?client_id=xxx&response_type=code&action=Login&username=XXXX&password=XXX&action=Login in the browser to get the auth code.

2)  I did a POST with the auth code in https://rest-west.bullhornstaffing.com/oauth/token?grant_type=authorization_code&code=XXXXYYYZZZ&client_id=xxx&client_secret=xxx

3) The I did a GET for the token with the code I got with step 2 https://rest.bullhornstaffing.com/rest-services/login?version=*&access_token=XXXXYYYZZZ

4) After this I was given a REST token for use.


Per usual, I wasn't able to make it past step 2 (still received invalid_client). Also, why so many different endpoints?! The guide (http://developer.bullhorn.com/articles/getting_started) only shows the auth. subdomain being used.

Anyway, support suggested a "remote session" so they can see where it's going wrong for me. That was a couple hours ago. We'll see how long it takes to actually set that up and remedy this.

mavieo
User
Posts: 11
Joined: Mon Apr 10, 2017 2:01 pm

Re: Client Authentication failed. (Code Audit)

Postby mavieo » Tue Apr 11, 2017 3:41 pm

Also, in the getting started guide (http://developer.bullhorn.com/articles/getting_started), step 3 of "Get an authorization code" says something about accepting TOS. I've yet to see that... but with so much of the guide being incorrect, I'm not certain that matters.

mavieo
User
Posts: 11
Joined: Mon Apr 10, 2017 2:01 pm

Re: Client Authentication failed. (Solved)

Postby mavieo » Wed Apr 12, 2017 11:48 am

OK, so I just had a nice quick call with someone named Charles at bullhorn support.

While the solution is most likely obvious to most, It wasn't to me (and I suspect others).

Issue: Get an access token

The documentation (http://developer.bullhorn.com/articles/getting_started) clearly states to make a POST request to this URL: https://auth.bullhornstaffing.com/oauth ... code&code={auth_code}&client_id={client_id}&client_secret={client_secret}&redirect_uri={optional redirect_uri}

I understood that to mean: "POST "this data" to this url"

This was a major misunderstanding on my part.

Apparently, one must make a POST request to that URL with that GET data.

My mistake, I feel foolish, and my only defense is.... why am I making a POST request with GET data?!$!@$&@# While I clearly understand that "this is a thing" - it just seems very odd to me that in any sort of structured code base (API...) this type of thing would be avoided for the sake of clarity and logic.

Anyway, once I POSTed to that URL with the data as GET variables (once again, as outlined in the docs, but confused me...), I was able to fetch the access token.

Thanks for the clarity, Charles. All's well that ends well.


Return to “REST API”

Who is online

Users browsing this forum: No registered users and 1 guest

cron