beestat / app

The actual beestat app.
https://beestat.io
GNU General Public License v3.0
211 stars 16 forks source link

Error while self-hosting: mysqli::real_escape_string(): Argument #1 ($string) must be of type string #390

Closed StarScream159 closed 1 year ago

StarScream159 commented 1 year ago

Describe the bug Getting an error while I sign in for the first time on a new self-hosted setup:

2023/03/30 21:01:35 [error] 8575#8575: *436 FastCGI sent in stderr: "PHP message: PHP Fatal error:  Uncaught TypeError: mysqli::real_escape_string(): Argument #1 ($string) must be of type string, array given in /opt/beestat/api/cora/database.php:248
Stack trace:
#0 /opt/beestat/api/cora/database.php(248): mysqli->real_escape_string()
#1 [internal function]: cora\database->escape()
#2 /opt/beestat/api/cora/database.php(745): array_map()
#3 /opt/beestat/api/cora/crud.php(41): cora\database->create()
#4 /opt/beestat/api/cora/request.php(312): cora\crud->create()
#5 /opt/beestat/api/cora/request.php(661): cora\request->log()
#6 [internal function]: cora\request->shutdown_handler()
#7 {main}
  thrown in /opt/beestat/api/cora/database.php on line 248" while reading response header from upstream, client: 192.168.2.103, server: bee.example.com, request: "POST /api/ HTTP/1.1", upstream: "fastcgi://unix:/var/opt/remi/php81/run/php-fpm/www.sock:", host: "bee.example.com", referrer: "https://bee.example.com/"

Version Information here: nginx version: nginx/1.20.1 mysql Ver 15.1 Distrib 10.5.16-MariaDB, for Linux (x86_64) using EditLine wrapper PHP Version 8.1.16

To Reproduce

  1. Setup a new local hosted instance of BeeStat. Configure database, website files and app in Ecobee Developer Portal.
  2. Follow steps to create api users in database
  3. Follow steps to edit the settings file
  4. Browse to local URL
  5. Redirected to the authorize app oAUTH page on ecobee's website
  6. Grant access
  7. Redirected back to self hosted page with a server 500 error message
  8. Check log for error (which is pasted above)

Other Information I var_dumped the line that is producing the error. I got a bunch of strings until it hit this array:

array(2) {
  ["batch"]=>
  string(1151) "[{"resource":"thermostat","method":"sync","arguments":"{}","alias":"thermostat_sync"},{"resource":"sensor","method":"sync","arguments":"{}","alias":"sensor_sync"},{"resource":"user","method":"read_id","arguments":"{}","alias":"user"},{"resource":"thermostat","method":"read_id","arguments":"{\"attributes\":{\"inactive\":0}}","alias":"thermostat"},{"resource":"sensor","method":"read_id","arguments":"{\"attributes\":{\"inactive\":0}}","alias":"sensor"},{"resource":"ecobee_thermostat","method":"read_id","arguments":"{\"attributes\":{\"inactive\":0}}","alias":"ecobee_thermostat"},{"resource":"ecobee_sensor","method":"read_id","arguments":"{\"attributes\":{\"inactive\":0}}","alias":"ecobee_sensor"},{"resource":"address","method":"read_id","arguments":"{}","alias":"address"},{"resource":"floor_plan","method":"read_id","arguments":"{}","alias":"floor_plan"},{"resource":"announcement","method":"read_id","arguments":"{}","alias":"announcement"},{"resource":"runtime_thermostat_summary","method":"read_id","arguments":"{}","alias":"runtime_thermostat_summary"},{"resource":"stripe_event","method":"read_id","arguments":"{}","alias":"stripe_event"}]"
  ["api_key"]=>
  string(40) "API_KEY_HERE_REMOVED_FOR_SECURITY"
}

Thanks so much.

ziebelje commented 1 year ago

In request.php:293 (the log() function) you can try updating it to just return at the top of the function. There's some other error happening that is getting logged and this particular path is apparently bugged. Doing this will at least sneak you past the first error while I look into it.

private function log() {
  return;
  ...
 }
ziebelje commented 1 year ago

Additionally could you run this query on your database server and post the results?

select
  `TABLE_NAME`,
  `COLUMN_NAME`,
  `DATA_TYPE`
from
  `information_schema`.`columns`
where
      `table_schema` = 'beestat'
  and `table_name` = 'api_log'
;
StarScream159 commented 1 year ago

Thanks for the prompt reply and possible work-around.

Unfortunately it is still failing. However, now all requests are 200 and nothing in the nginx error log.

I am getting a larger output within the beestat website now:

Expand for full error. ``` { "success": false, "data": { "error_message": "mysqli::real_escape_string(): Argument #1 ($string) must be of type string, array given", "error_code": 0, "error_detail": { "file": "/opt/beestat/api/cora/database.php", "line": 248, "trace": [ { "file": "/opt/beestat/api/cora/database.php", "line": 248, "function": "real_escape_string", "class": "mysqli", "type": "->" }, { "function": "escape", "class": "cora\\database", "type": "->" }, { "file": "/opt/beestat/api/cora/database.php", "line": 745, "function": "array_map" }, { "file": "/opt/beestat/api/cora/crud.php", "line": 41, "function": "create", "class": "cora\\database", "type": "->" }, { "function": "create", "class": "cora\\crud", "type": "->" }, { "file": "/opt/beestat/api/cora/api.php", "line": 83, "function": "call_user_func_array" }, { "file": "/opt/beestat/api/ecobee_thermostat.php", "line": 220, "function": "api", "class": "cora\\api", "type": "->" }, { "function": "sync", "class": "ecobee_thermostat", "type": "->" }, { "file": "/opt/beestat/api/cora/api.php", "line": 83, "function": "call_user_func_array" }, { "file": "/opt/beestat/api/thermostat.php", "line": 162, "function": "api", "class": "cora\\api", "type": "->" }, { "function": "sync", "class": "thermostat", "type": "->" }, { "file": "/opt/beestat/api/cora/api_call.php", "line": 169, "function": "call_user_func_array" }, { "file": "/opt/beestat/api/cora/request.php", "line": 160, "function": "process", "class": "cora\\api_call", "type": "->" }, { "file": "/opt/beestat/api/index.php", "line": 47, "function": "process", "class": "cora\\request", "type": "->" } ], "extra": null, "queries": [ { "query": "select * from `api_user` where `api_key`=\"API_KEY_HERE_REMOVED\" and `deleted`=0", "time": 0.00029778480529785156 }, { "query": "select * from `session` where `session_key`=\"API_KEY_HERE_REMOVED\" and `deleted`=0", "time": 0.00016307830810546875 }, { "query": "\n select\n `TABLE_NAME`,\n `COLUMN_NAME`,\n `DATA_TYPE`\n from\n `information_schema`.`columns`\n where\n `table_schema` = \"beestat\"\n ", "time": 0.0013799667358398438 }, { "query": "start transaction", "time": 0.00007510185241699219 }, { "query": "update `session` set `last_used_at`=\"2023-03-31 01:35:41\",`last_used_by`=3232236135 where `session_id`=1", "time": 0.0001728534698486328 }, { "query": "select * from `session` where `session_id`=1", "time": 0.00013184547424316406 }, { "query": "select * from `api_cache` where `key`=\"API_KEY_HERE_REMOVED\" and `deleted`=0 and `user_id`=1", "time": 0.00015783309936523438 }, { "query": "\n select\n get_lock(\"beestat_thermostat->sync(1)\", 0) `lock`\n ", "time": 0.0001010894775390625 }, { "query": "select * from `ecobee_token` where `deleted`=0 and `user_id`=1", "time": 0.00011992454528808594 }, { "query": "select * from `user` where `user_id`=1 and `deleted`=0", "time": 0.00010514259338378906 }, { "query": "select * from `ecobee_thermostat` where `identifier`=\"415553052457\" and `deleted`=0 and `user_id`=1", "time": 0.00038814544677734375 }, { "query": "insert into `ecobee_thermostat`(`identifier`,`user_id`) values (\"415553052457\",1)", "time": 0.00017595291137695312 }, { "query": "select * from `ecobee_thermostat` where `ecobee_thermostat_id`=7", "time": 0.0001990795135498047 }, { "query": "rollback", "time": 0.01985001564025879 } ] } } } ```

Results from your query:

TABLE_NAME COLUMN_NAME DATA_TYPE
api_log api_log_id int
api_log user_id int
api_log api_user_id int
api_log ip_address int
api_log timestamp timestamp
api_log request longtext
api_log response longtext
api_log error_code int
api_log error_detail longtext
api_log total_time decimal
api_log query_count int
api_log query_time decimal
ziebelje commented 1 year ago

Got 'em.

The database wrapper in beestat allows you to do things like $database->create($table, $attributes) where $attributes is an array where the keys are column names and the values are the data to go into the columns. Certain columns are the "json" data type, and when that happens beestat automatically encodes/decodes the JSON for you.

What's happening for you is the JSON columns are "longtext" instead of "json". Beestat actually runs the query I gave you to determine the column data type. Since it's not the expected result it doesn't encode the object and then it breaks when it goes to escape the alleged string.

Here's what you should expect to see from that query:

+------------+--------------+-----------+
| TABLE_NAME | COLUMN_NAME  | DATA_TYPE |
+------------+--------------+-----------+
| api_log    | api_log_id   | int       |
| api_log    | api_user_id  | int       |
| api_log    | error_code   | int       |
| api_log    | error_detail | json      |
| api_log    | ip_address   | int       |
| api_log    | query_count  | int       |
| api_log    | query_time   | decimal   |
| api_log    | request      | json      |
| api_log    | response     | json      |
| api_log    | timestamp    | timestamp |
| api_log    | total_time   | decimal   |
| api_log    | user_id      | int       |
+------------+--------------+-----------+

MariaDB behaves different than MySQL with the JSON data type: https://mariadb.com/kb/en/json-data-type/

Let me see if I can come up with a workaround.

ziebelje commented 1 year ago

Could you do one more query? I need to see if MariaDB internally marks these as JSON columns even though they are stored as longtext.

select
  *
from
  `information_schema`.`columns`
where
      `table_schema` = 'beestat'
  and `table_name` = 'api_log'
  and `column_name` = 'request'
;
*************************** 1. row ***************************
           TABLE_CATALOG: def
            TABLE_SCHEMA: beestat
              TABLE_NAME: api_log
             COLUMN_NAME: request
        ORDINAL_POSITION: 6
          COLUMN_DEFAULT: NULL
             IS_NULLABLE: YES
               DATA_TYPE: json
CHARACTER_MAXIMUM_LENGTH: NULL
  CHARACTER_OCTET_LENGTH: NULL
       NUMERIC_PRECISION: NULL
           NUMERIC_SCALE: NULL
      DATETIME_PRECISION: NULL
      CHARACTER_SET_NAME: NULL
          COLLATION_NAME: NULL
             COLUMN_TYPE: json
              COLUMN_KEY: 
                   EXTRA: 
              PRIVILEGES: select,insert,update,references
          COLUMN_COMMENT: 
   GENERATION_EXPRESSION: 
                  SRS_ID: NULL
StarScream159 commented 1 year ago

Ah. Good catch. Here's the output of your query (sorry don't know how to format it the same way you do).

$columns = array(
    array(
        "TABLE_CATALOG" => "def",
        "TABLE_SCHEMA" => "beestat",
        "TABLE_NAME" => "api_log",
        "COLUMN_NAME" => "request",
        "ORDINAL_POSITION" => 6,
        "COLUMN_DEFAULT" => "NULL",
        "IS_NULLABLE" => "YES",
        "DATA_TYPE" => "longtext",
        "CHARACTER_MAXIMUM_LENGTH" => 4294967295,
        "CHARACTER_OCTET_LENGTH" => 4294967295,
        "NUMERIC_PRECISION" => NULL,
        "NUMERIC_SCALE" => NULL,
        "DATETIME_PRECISION" => NULL,
        "CHARACTER_SET_NAME" => "utf8mb4",
        "COLLATION_NAME" => "utf8mb4_bin",
        "COLUMN_TYPE" => "longtext",
        "COLUMN_KEY" => "",
        "EXTRA" => "",
        "PRIVILEGES" => "select,insert,update,references",
        "COLUMN_COMMENT" => "",
        "IS_GENERATED" => "NEVER",
        "GENERATION_EXPRESSION" => NULL,
    ),
);

Looks like longtext. I don't think json column type exists outside of mysql.

ziebelje commented 1 year ago

I'm not seeing any trivial way to identify this column type in MariaDB once it's been created. I think the best quick solution is to add this line of code on database.php:840. It will assume all "longtext" columns are "json", unless the column is named "response", except if the table is "api_log"...so basically just converts only the columns that need to be converted without explicitly listing them all out.

$row['DATA_TYPE'] = $row['DATA_TYPE'] == 'longtext' ? ($row['COLUMN_NAME'] == 'response' && $row['TABLE_NAME'] !== 'api_log' ? $row['DATA_TYPE'] : 'json') : $row['DATA_TYPE'];

In context:

    // Otherwise query the entire schema (and cache it) to see what the type is.
    if(isset(self::$types) === false) {
      self::$types = [];
      $result = $this->query('
        select
          `TABLE_NAME`,
          `COLUMN_NAME`,
          `DATA_TYPE`
        from
          `information_schema`.`columns`
        where
          `table_schema` = ' . $this->escape($this->setting->get('database_name')) . '
      ');
      while($row = $result->fetch_assoc()) {
        $row['DATA_TYPE'] = $row['DATA_TYPE'] == 'longtext' ? ($row['COLUMN_NAME'] == 'response' && $row['TABLE_NAME'] !== 'api_log' ? $row['DATA_TYPE'] : 'json') : $row['DATA_TYPE'];
        self::$types[$row['TABLE_NAME'] . '.' . $row['COLUMN_NAME']] = $row['DATA_TYPE'];
      }
    }
StarScream159 commented 1 year ago

Thanks. Quick work around, however it looks like this JSON issues causes errors elsewhere too. After implementing that change it fails on a new line:

    "error_message": "Cannot access offset of type string on string",
    "error_code": 0,
    "error_detail": {
      "file": "/opt/beestat/api/ecobee_thermostat.php",
      "line": 1152,

That line is a json string, I assume from the database. Nested JSON I'm guessing? Because it isn't auto parsed, it's being returned as a long string (i.e. $ecobee_thermostat['location'] is a string):

["location"]=>
  string(250) "{"timeZoneOffsetMinutes":-300,"timeZone":"America\/Toronto","isDaylight"...

I fixed it with a simple check:

    if (!is_array($ecobee_thermostat['location'])) {
        $ecobee_thermostat['location'] = json_decode($ecobee_thermostat['location'], true);
    }
    $time_zone = $ecobee_thermostat['location']['timeZone'];

That got me past that one but then there was another one:

    "error_message": "foreach() argument must be of type array|object, string given",
    "error_code": 2,
    "error_detail": {
      "file": "/opt/beestat/api/ecobee_thermostat.php",
      "line": 786,

And I bet there are more and more. Seems as though this just won't run easily on MariaDB.

ziebelje commented 1 year ago

We missed a spot. Looks like I'm analyzing type two different ways as a slight optimization for read-only API calls.

On database.php:496 add the following line of code. I confirmed this fixes the latest issue you're having...as long as MariaDB doesn't return different codes for other field types this should fix it. If not it can be fixed other ways. :)

$field_info->type = $field_info->type == 252 ? ($field_info->name == 'response' && $field_info->table !== 'api_log' ? $field_info->type : 245) : $field_info->type;

In context:

$float_fields = [];
$boolean_fields = [];
$json_fields = [];
while($field_info = $result->fetch_field()) {
  $field_info->type = $field_info->type == 252 ? ($field_info->name == 'response' && $field_info->table !== 'api_log' ? $field_info->type : 245) : $field_info->type;

  if($field_info->type === 1 && $field_info->length === 1) {
    $boolean_fields[] = $field_info->name;
  }
  else if($field_info->type === 246) {
    $float_fields[] = $field_info->name;
  }
  else if($field_info->type === 245) {
    $json_fields[] = $field_info->name;
  }
}
StarScream159 commented 1 year ago

Thanks. That got us closer. No more error messages from the server. However now there is an error in the console.

load.js:161 Uncaught TypeError: Cannot read properties of undefined (reading 'thermostat_id')
    at beestat.api.callback_ (load.js:161:46)
    at beestat.api.load_ (api.js:265:10)
    at XMLHttpRequest.<anonymous> (api.js:32:12)

To confirm nothing was messed up from everything we've done. I cleared the database and re-initialized it, re-created the two api user accounts and set their keys in the settings file. Lastly I checked out the files that were changed and reapplied the two fixes to the database.php file.

So no errors reported from any xhr requests or nginx. But getting this console error.

    // Set the active thermostat_id if this is your first time visiting.
    if (beestat.setting('thermostat_id') === undefined) {
      beestat.setting(
        'thermostat_id',
        $.values(beestat.cache.thermostat)[0].thermostat_id    // error here
      );
    }

Thanks for all the help.

StarScream159 commented 1 year ago

I decided to redo a new VM with standard mysql: mysql Ver 8.0.30 for Linux on x86_64 (Source distribution)

I still have the old VM if you want me to test anything relating to MariaDB.

ziebelje commented 1 year ago

Closing as not planned for now. If someone wants to create a MariaDB fork or something go for it. I've noted this discussion in the self-hosting guide.