diff --git a/controllers/ManageUsers.php b/controllers/ManageUsers.php index 8473596..9e80cb9 100644 --- a/controllers/ManageUsers.php +++ b/controllers/ManageUsers.php @@ -62,6 +62,7 @@ class ManageUsers extends HTMLController 'type' => 'timestamp', 'pattern' => 'long', 'value' => 'last_action_time', + 'if_null' => 'n/a', ], 'header' => 'Last activity', 'is_sortable' => true, diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php index 1d2272a..a491005 100644 --- a/controllers/ViewPhotoAlbum.php +++ b/controllers/ViewPhotoAlbum.php @@ -289,10 +289,4 @@ class ViewPhotoAlbum extends HTMLController $description = !empty($tag->description) ? $tag->description : ''; return new AlbumHeaderBox($tag->tag, $description, $back_link, $back_link_title); } - - public function __destruct() - { - if (isset($this->iterator)) - $this->iterator->clean(); - } } diff --git a/controllers/ViewTimeline.php b/controllers/ViewTimeline.php index d5dd9d3..33d933c 100644 --- a/controllers/ViewTimeline.php +++ b/controllers/ViewTimeline.php @@ -54,10 +54,4 @@ class ViewTimeline extends HTMLController // Set the canonical url. $this->page->setCanonicalUrl(BASEURL . '/timeline/'); } - - public function __destruct() - { - if (isset($this->iterator)) - $this->iterator->clean(); - } } diff --git a/models/Asset.php b/models/Asset.php index b3803bf..b008bf5 100644 --- a/models/Asset.php +++ b/models/Asset.php @@ -24,7 +24,7 @@ class Asset protected $tags; protected $thumbnails; - protected function __construct(array $data) + public function __construct(array $data) { foreach ($data as $attribute => $value) { @@ -32,7 +32,7 @@ class Asset $this->$attribute = $value; } - if (isset($data['date_captured']) && $data['date_captured'] !== 'NULL' && !is_object($data['date_captured'])) + if (isset($data['date_captured']) && $data['date_captured'] !== null && !is_object($data['date_captured'])) $this->date_captured = new DateTime($data['date_captured']); } @@ -56,7 +56,7 @@ class Asset $row = Registry::get('db')->queryAssoc(' SELECT * FROM assets - WHERE id_asset = {int:id_asset}', + WHERE id_asset = :id_asset', [ 'id_asset' => $id_asset, ]); @@ -69,7 +69,7 @@ class Asset $row = Registry::get('db')->queryAssoc(' SELECT * FROM assets - WHERE slug = {string:slug}', + WHERE slug = :slug', [ 'slug' => $slug, ]); @@ -85,7 +85,7 @@ class Asset $row['meta'] = $db->queryPair(' SELECT variable, value FROM assets_meta - WHERE id_asset = {int:id_asset}', + WHERE id_asset = :id_asset', [ 'id_asset' => $row['id_asset'], ]); @@ -94,16 +94,15 @@ class Asset $row['thumbnails'] = $db->queryPair(' SELECT CONCAT( - width, - {string:x}, - height, - IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty}) + width, :x, height, + IF(mode != :empty1, CONCAT(:_, mode), :empty2) ) AS selector, filename FROM assets_thumbs - WHERE id_asset = {int:id_asset}', + WHERE id_asset = :id_asset', [ 'id_asset' => $row['id_asset'], - 'empty' => '', + 'empty1' => '', + 'empty2' => '', 'x' => 'x', '_' => '_', ]); @@ -121,14 +120,14 @@ class Asset $res = $db->query(' SELECT * FROM assets - WHERE id_asset IN ({array_int:id_assets}) + WHERE id_asset IN (@id_assets) ORDER BY id_asset', [ 'id_assets' => $id_assets, ]); $assets = []; - while ($asset = $db->fetch_assoc($res)) + while ($asset = $db->fetchAssoc($res)) { $assets[$asset['id_asset']] = $asset; $assets[$asset['id_asset']]['meta'] = []; @@ -138,7 +137,7 @@ class Asset $metas = $db->queryRows(' SELECT id_asset, variable, value FROM assets_meta - WHERE id_asset IN ({array_int:id_assets}) + WHERE id_asset IN (@id_assets) ORDER BY id_asset', [ 'id_assets' => $id_assets, @@ -150,17 +149,16 @@ class Asset $thumbnails = $db->queryRows(' SELECT id_asset, CONCAT( - width, - {string:x}, - height, - IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty}) + width, :x, height, + IF(mode != :empty1, CONCAT(:_, mode), :empty2) ) AS selector, filename FROM assets_thumbs - WHERE id_asset IN ({array_int:id_assets}) + WHERE id_asset IN (@id_assets) ORDER BY id_asset', [ 'id_assets' => $id_assets, - 'empty' => '', + 'empty1' => '', + 'empty2' => '', 'x' => 'x', '_' => '_', ]); @@ -169,7 +167,9 @@ class Asset $assets[$thumb[0]]['thumbnails'][$thumb[1]] = $thumb[2]; if ($return_format === 'array') + { return $assets; + } else { $objects = []; @@ -262,10 +262,10 @@ class Asset INSERT INTO assets (id_user_uploaded, subdir, filename, title, slug, mimetype, image_width, image_height, date_captured, priority) VALUES - ({int:id_user_uploaded}, {string:subdir}, {string:filename}, {string:title}, {string:slug}, {string:mimetype}, - {int:image_width}, {int:image_height}, - IF({int:date_captured} > 0, FROM_UNIXTIME({int:date_captured}), NULL), - {int:priority})', + (:id_user_uploaded, :subdir, :filename, :title, :slug, :mimetype, + :image_width, :image_height, + IF(:date_captured > 0, FROM_UNIXTIME(:date_captured), NULL), + :priority)', [ 'id_user_uploaded' => isset($id_user) ? $id_user : Registry::get('user')->getUserId(), 'subdir' => $preferred_subdir, @@ -273,9 +273,9 @@ class Asset 'title' => $title, 'slug' => $slug, 'mimetype' => $mimetype, - 'image_width' => isset($image_width) ? $image_width : 'NULL', - 'image_height' => isset($image_height) ? $image_height : 'NULL', - 'date_captured' => isset($date_captured) ? $date_captured : 'NULL', + 'image_width' => isset($image_width) ? $image_width : null, + 'image_height' => isset($image_height) ? $image_height : null, + 'date_captured' => isset($date_captured) ? $date_captured : null, 'priority' => isset($priority) ? (int) $priority : 0, ]); @@ -285,7 +285,7 @@ class Asset return false; } - $data['id_asset'] = $db->insert_id(); + $data['id_asset'] = $db->insertId(); return $return_format === 'object' ? new self($data) : $data; } @@ -324,7 +324,7 @@ class Asset $posts = Registry::get('db')->queryValues(' SELECT id_post FROM posts_assets - WHERE id_asset = {int:id_asset}', + WHERE id_asset = :id_asset', ['id_asset' => $this->id_asset]); // TODO: fix empty post iterator. @@ -495,18 +495,18 @@ class Asset return Registry::get('db')->query(' UPDATE assets SET - mimetype = {string:mimetype}, - image_width = {int:image_width}, - image_height = {int:image_height}, - date_captured = {datetime:date_captured}, - priority = {int:priority} - WHERE id_asset = {int:id_asset}', + mimetype = :mimetype, + image_width = :image_width, + image_height = :image_height, + date_captured = :date_captured, + priority = :priority + WHERE id_asset = :id_asset', [ 'id_asset' => $this->id_asset, 'mimetype' => $this->mimetype, - 'image_width' => isset($this->image_width) ? $this->image_width : 'NULL', - 'image_height' => isset($this->image_height) ? $this->image_height : 'NULL', - 'date_captured' => isset($this->date_captured) ? $this->date_captured : 'NULL', + 'image_width' => isset($this->image_width) ? $this->image_width : null, + 'image_height' => isset($this->image_height) ? $this->image_height : null, + 'date_captured' => isset($this->date_captured) ? $this->date_captured : null, 'priority' => $this->priority, ]); } @@ -527,8 +527,8 @@ class Asset if (!empty($to_remove)) $db->query(' DELETE FROM assets_meta - WHERE id_asset = {int:id_asset} AND - variable IN({array_string:variables})', + WHERE id_asset = :id_asset AND + variable IN(@variables)', [ 'id_asset' => $this->id_asset, 'variables' => array_keys($to_remove), @@ -559,63 +559,40 @@ class Asset { $db = Registry::get('db'); - // First: delete associated metadata + // Delete any and all thumbnails, if this is an image. + if ($this->isImage()) + { + $image = $this->getImage(); + $image->removeAllThumbnails(); + } + + // Delete all meta info for this asset. $db->query(' DELETE FROM assets_meta - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); + WHERE id_asset = :id_asset', + ['id_asset' => $this->id_asset]); - // Second: figure out what tags to recount cardinality for + // Figure out what tags to recount cardinality for $recount_tags = $db->queryValues(' SELECT id_tag FROM assets_tags - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); + WHERE id_asset = :id_asset', + ['id_asset' => $this->id_asset]); + // Delete asset association for these tags $db->query(' DELETE FROM assets_tags - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); + WHERE id_asset = :id_asset', + ['id_asset' => $this->id_asset]); Tag::recount($recount_tags); - // Third: figure out what associated thumbs to delete - $thumbs_to_delete = $db->queryValues(' - SELECT filename - FROM assets_thumbs - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); - - foreach ($thumbs_to_delete as $filename) - { - $thumb_path = THUMBSDIR . '/' . $this->subdir . '/' . $filename; - if (is_file($thumb_path)) - unlink($thumb_path); - } - - $db->query(' - DELETE FROM assets_thumbs - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); - // Reset asset ID for tags that use this asset for their thumbnail - $rows = $db->query(' + $rows = $db->queryValues(' SELECT id_tag FROM tags - WHERE id_asset_thumb = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); + WHERE id_asset_thumb = :id_asset', + ['id_asset' => $this->id_asset]); if (!empty($rows)) { @@ -632,10 +609,8 @@ class Asset $return = $db->query(' DELETE FROM assets - WHERE id_asset = {int:id_asset}', - [ - 'id_asset' => $this->id_asset, - ]); + WHERE id_asset = :id_asset', + ['id_asset' => $this->id_asset]); return $return; } @@ -664,7 +639,7 @@ class Asset Registry::get('db')->query(' DELETE FROM assets_tags - WHERE id_asset = {int:id_asset} AND id_tag IN ({array_int:id_tags})', + WHERE id_asset = :id_asset AND id_tag IN (@id_tags)', [ 'id_asset' => $this->id_asset, 'id_tags' => $id_tags, @@ -682,16 +657,17 @@ class Asset public static function getOffset($offset, $limit, $order, $direction) { + $order = $order . ($direction == 'up' ? ' ASC' : ' DESC'); + return Registry::get('db')->queryAssocs(' SELECT a.id_asset, a.subdir, a.filename, a.image_width, a.image_height, a.mimetype, u.id_user, u.first_name, u.surname FROM assets AS a LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user - ORDER BY {raw:order} - LIMIT {int:offset}, {int:limit}', + ORDER BY ' . $order . ' + LIMIT :offset, :limit', [ - 'order' => $order . ($direction == 'up' ? ' ASC' : ' DESC'), 'offset' => $offset, 'limit' => $limit, ]); @@ -704,18 +680,16 @@ class Asset return Registry::get('db')->query(' UPDATE assets - SET id_asset = {int:id_asset}, - id_user_uploaded = {int:id_user_uploaded}, - subdir = {string:subdir}, - filename = {string:filename}, - title = {string:title}, - slug = {string:slug}, - mimetype = {string:mimetype}, - image_width = {int:image_width}, - image_height = {int:image_height}, - date_captured = {datetime:date_captured}, - priority = {int:priority} - WHERE id_asset = {int:id_asset}', + SET subdir = :subdir, + filename = :filename, + title = :title, + slug = :slug, + mimetype = :mimetype, + image_width = :image_width, + image_height = :image_height, + date_captured = :date_captured, + priority = :priority + WHERE id_asset = :id_asset', get_object_vars($this)); } @@ -733,27 +707,27 @@ class Asset // Direction depends on whether we're browsing a tag or timeline if (isset($tag)) { - $where[] = 't.id_tag = {int:id_tag}'; + $where[] = 't.id_tag = :id_tag'; $params['id_tag'] = $tag->id_tag; - $params['where_op'] = $previous ? '<' : '>'; - $params['order_dir'] = $previous ? 'DESC' : 'ASC'; + $where_op = $previous ? '<' : '>'; + $order_dir = $previous ? 'DESC' : 'ASC'; } else { - $params['where_op'] = $previous ? '>' : '<'; - $params['order_dir'] = $previous ? 'ASC' : 'DESC'; + $where_op = $previous ? '>' : '<'; + $order_dir = $previous ? 'ASC' : 'DESC'; } // Take active filter into account as well if (!empty($activeFilter) && ($user = Member::fromSlug($activeFilter)) !== false) { - $where[] = 'id_user_uploaded = {int:id_user_uploaded}'; + $where[] = 'id_user_uploaded = :id_user_uploaded'; $params['id_user_uploaded'] = $user->getUserId(); } // Use complete ordering when sorting the set - $where[] = '(a.date_captured, a.id_asset) {raw:where_op} ' . - '({datetime:date_captured}, {int:id_asset})'; + $where[] = '(a.date_captured, a.id_asset) ' . $where_op . + ' (:date_captured, :id_asset)'; // Stringify conditions together $where = '(' . implode(') AND (', $where) . ')'; @@ -765,7 +739,7 @@ class Asset ' . (isset($tag) ? ' INNER JOIN assets_tags AS t ON a.id_asset = t.id_asset' : '') . ' WHERE ' . $where . ' - ORDER BY a.date_captured {raw:order_dir}, a.id_asset {raw:order_dir} + ORDER BY a.date_captured ' . $order_dir . ', a.id_asset ' . $order_dir . ' LIMIT 1', $params); diff --git a/models/AssetIterator.php b/models/AssetIterator.php index 2eabbf3..9088719 100644 --- a/models/AssetIterator.php +++ b/models/AssetIterator.php @@ -1,42 +1,50 @@ db = Registry::get('db'); $this->direction = $direction; - $this->res_assets = $res_assets; - $this->res_meta = $res_meta; - $this->res_thumbs = $res_thumbs; $this->return_format = $return_format; + $this->rowCount = $stmt_assets->rowCount(); + + $this->assets_iterator = new CachedPDOIterator($stmt_assets); + $this->assets_iterator->rewind(); + + $this->meta_iterator = new CachedPDOIterator($stmt_meta); + $this->thumbs_iterator = new CachedPDOIterator($stmt_thumbs); } - public function next() + public static function all() { - $row = $this->db->fetch_assoc($this->res_assets); + return self::getByOptions(); + } - // No more rows? + public function current(): mixed + { + $row = $this->assets_iterator->current(); if (!$row) - return false; + return $row; - // Looks up metadata. + // Collect metadata $row['meta'] = []; - while ($meta = $this->db->fetch_assoc($this->res_meta)) + $this->meta_iterator->rewind(); + foreach ($this->meta_iterator as $meta) { if ($meta['id_asset'] != $row['id_asset']) continue; @@ -44,54 +52,23 @@ class AssetIterator extends Asset $row['meta'][$meta['variable']] = $meta['value']; } - // Reset internal pointer for next asset. - $this->db->data_seek($this->res_meta, 0); - - // Looks up thumbnails. + // Collect thumbnails $row['thumbnails'] = []; - while ($thumbs = $this->db->fetch_assoc($this->res_thumbs)) + $this->thumbs_iterator->rewind(); + foreach ($this->thumbs_iterator as $thumb) { - if ($thumbs['id_asset'] != $row['id_asset']) + if ($thumb['id_asset'] != $row['id_asset']) continue; - $row['thumbnails'][$thumbs['selector']] = $thumbs['filename']; + $row['thumbnails'][$thumb['selector']] = $thumb['filename']; } - // Reset internal pointer for next asset. - $this->db->data_seek($this->res_thumbs, 0); - if ($this->return_format === 'object') return new Asset($row); else return $row; } - public function reset() - { - $this->db->data_seek($this->res_assets, 0); - $this->db->data_seek($this->res_meta, 0); - $this->db->data_seek($this->res_thumbs, 0); - } - - public function clean() - { - if (!$this->res_assets) - return; - - $this->db->free_result($this->res_assets); - $this->res_assets = null; - } - - public function num() - { - return $this->db->num_rows($this->res_assets); - } - - public static function all() - { - return self::getByOptions(); - } - public static function getByOptions(array $options = [], $return_count = false, $return_format = 'object') { $params = [ @@ -114,9 +91,9 @@ class AssetIterator extends Asset { $params['mime_type'] = $options['mime_type']; if (is_array($options['mime_type'])) - $where[] = 'a.mimetype IN({array_string:mime_type})'; + $where[] = 'a.mimetype IN(@mime_type)'; else - $where[] = 'a.mimetype = {string:mime_type}'; + $where[] = 'a.mimetype = :mime_type'; } if (isset($options['id_user_uploaded'])) { @@ -129,7 +106,17 @@ class AssetIterator extends Asset $where[] = 'id_asset IN( SELECT l.id_asset FROM assets_tags AS l - WHERE l.id_tag = {int:id_tag})'; + WHERE l.id_tag = :id_tag)'; + } + elseif (isset($options['tag'])) + { + $params['tag'] = $options['tag']; + $where[] = 'id_asset IN( + SELECT l.id_asset + FROM assets_tags AS l + INNER JOIN tags AS t + ON l.id_tag = t.id_tag + WHERE t.slug = :tag)'; } // Make it valid SQL. @@ -145,7 +132,7 @@ class AssetIterator extends Asset FROM assets AS a WHERE ' . $where . ' ORDER BY ' . $order . (!empty($params['limit']) ? ' - LIMIT {int:offset}, {int:limit}' : ''), + LIMIT :offset, :limit' : ''), $params); // Get a resource object for the asset meta. @@ -165,9 +152,9 @@ class AssetIterator extends Asset SELECT id_asset, filename, CONCAT( width, - {string:x}, + :x, height, - IF(mode != {string:empty}, CONCAT({string:_}, mode), {string:empty}) + IF(mode != :empty1, CONCAT(:_, mode), :empty2) ) AS selector FROM assets_thumbs WHERE id_asset IN( @@ -177,7 +164,8 @@ class AssetIterator extends Asset ) ORDER BY id_asset', $params + [ - 'empty' => '', + 'empty1' => '', + 'empty2' => '', 'x' => 'x', '_' => '_', ]); @@ -199,13 +187,38 @@ class AssetIterator extends Asset return $iterator; } - public function isAscending() + public function key(): mixed + { + return $this->assets_iterator->key(); + } + + public function isAscending(): bool { return $this->direction === 'asc'; } - public function isDescending() + public function isDescending(): bool { return $this->direction === 'desc'; } + + public function next(): void + { + $this->assets_iterator->next(); + } + + public function num(): int + { + return $this->rowCount; + } + + public function rewind(): void + { + $this->assets_iterator->rewind(); + } + + public function valid(): bool + { + return $this->assets_iterator->valid(); + } } diff --git a/models/Authentication.php b/models/Authentication.php index 324ad91..98b3fb0 100644 --- a/models/Authentication.php +++ b/models/Authentication.php @@ -23,7 +23,7 @@ class Authentication $password_hash = Registry::get('db')->queryValue(' SELECT password_hash FROM users - WHERE emailaddress = {string:emailaddress}', + WHERE emailaddress = :emailaddress', [ 'emailaddress' => $emailaddress, ]); @@ -132,9 +132,9 @@ class Authentication return Registry::get('db')->query(' UPDATE users SET - password_hash = {string:hash}, - reset_key = {string:blank} - WHERE id_user = {int:id_user}', + password_hash = :hash, + reset_key = :blank + WHERE id_user = :id_user', [ 'id_user' => $id_user, 'hash' => $hash, diff --git a/models/CachedPDOIterator.php b/models/CachedPDOIterator.php new file mode 100644 index 0000000..99297f8 --- /dev/null +++ b/models/CachedPDOIterator.php @@ -0,0 +1,56 @@ +index === null) + { + parent::rewind(); + } + $this->index = 0; + } + + public function current(): mixed + { + if ($this->offsetExists($this->index)) + { + return $this->offsetGet($this->index); + } + return parent::current(); + } + + public function key(): mixed + { + return $this->index; + } + + public function next(): void + { + $this->index++; + if (!$this->offsetExists($this->index)) + { + parent::next(); + } + } + + public function valid(): bool + { + return $this->offsetExists($this->index) || parent::valid(); + } +} diff --git a/models/Database.php b/models/Database.php index c8e2e02..346a8b9 100644 --- a/models/Database.php +++ b/models/Database.php @@ -1,39 +1,29 @@ connection = new mysqli($server, $user, $password, $name); - $this->connection->set_charset('utf8mb4'); + $this->connection = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); } - catch (mysqli_sql_exception $e) + // Give up if we have a connection error. + catch (PDOException $e) { http_response_code(503); echo '
Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.
'; @@ -52,301 +42,178 @@ class Database } /** - * Fetches a row from a given recordset, using field names as keys. + * Fetches a row from a given statement/recordset, using field names as keys. */ - public function fetch_assoc($resource) + public function fetchAssoc($stmt) { - return mysqli_fetch_assoc($resource); + return $stmt->fetch(PDO::FETCH_ASSOC); } /** - * Fetches a row from a given recordset, encapsulating into an object. + * Fetches a row from a given statement/recordset, encapsulating into an object. */ - public function fetch_object($resource, $class) + public function fetchObject($stmt, $class) { - return mysqli_fetch_object($resource, $class); + return $stmt->fetchObject($class); } /** - * Fetches a row from a given recordset, using numeric keys. + * Fetches a row from a given statement/recordset, using numeric keys. */ - public function fetch_row($resource) + public function fetchNum($stmt) { - return mysqli_fetch_row($resource); + return $stmt->fetch(PDO::FETCH_NUM); } /** - * Destroys a given recordset. + * Destroys a given statement/recordset. */ - public function free_result($resource) + public function free($stmt) { - return mysqli_free_result($resource); - } - - public function data_seek($result, $row_num) - { - return mysqli_data_seek($result, $row_num); + return $stmt->closeCursor(); } /** - * Returns the amount of rows in a given recordset. + * Returns the amount of rows in a given statement/recordset. */ - public function num_rows($resource) + public function rowCount($stmt) { - return mysqli_num_rows($resource); + return $stmt->rowCount(); } /** - * Returns the amount of fields in a given recordset. + * Returns the amount of fields in a given statement/recordset. */ - public function num_fields($resource) + public function columnCount($stmt) { - return mysqli_num_fields($resource); - } - - /** - * Escapes a string. - */ - public function escape_string($string) - { - return mysqli_real_escape_string($this->connection, $string); - } - - /** - * Unescapes a string. - */ - public function unescape_string($string) - { - return stripslashes($string); - } - - /** - * Returns the last MySQL error. - */ - public function error() - { - return mysqli_error($this->connection); - } - - public function server_info() - { - return mysqli_get_server_info($this->connection); - } - - /** - * Selects a database on a given connection. - */ - public function select_db($database) - { - return mysqli_select_db($database, $this->connection); - } - - /** - * Returns the amount of rows affected by the previous query. - */ - public function affected_rows() - { - return mysqli_affected_rows($this->connection); + return $stmt->columnCount(); } /** * Returns the id of the row created by a previous query. */ - public function insert_id() + public function insertId($name = null) { - return mysqli_insert_id($this->connection); + return $this->connection->lastInsertId($name); } /** - * Do a MySQL transaction. + * Start a transaction. */ - public function transaction($operation = 'commit') + public function beginTransaction() { - switch ($operation) - { - case 'begin': - case 'rollback': - case 'commit': - return @mysqli_query($this->connection, strtoupper($operation)); - default: - return false; - } + return $this->connection->beginTransaction(); } /** - * Function used as a callback for the preg_match function that parses variables into database queries. + * Rollback changes in a transaction. */ - private function replacement_callback($matches) + public function rollback() { - list ($values, $connection) = $this->db_callback; + return $this->connection->rollBack(); + } - if (!isset($matches[2])) - throw new UnexpectedValueException('Invalid value inserted or no type specified.'); + /** + * Commit changes in a transaction. + */ + public function commit() + { + return $this->connection->commit(); + } - if (!isset($values[$matches[2]])) - throw new UnexpectedValueException('The database value you\'re trying to insert does not exist: ' . htmlspecialchars($matches[2])); - - $replacement = $values[$matches[2]]; - - switch ($matches[1]) + private function expandPlaceholders($db_string, array &$db_values) + { + foreach ($db_values as $key => &$value) { - case 'int': - if ((!is_numeric($replacement) || (string) $replacement !== (string) (int) $replacement) && $replacement !== 'NULL') - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Integer expected.'); - return $replacement !== 'NULL' ? (string) (int) $replacement : 'NULL'; - break; - - case 'string': - case 'text': - return $replacement !== 'NULL' ? sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $replacement)) : 'NULL'; - break; - - case 'array_int': - if (is_array($replacement)) + if (str_contains($db_string, ':' . $key)) + { + if (is_array($value)) { - if (empty($replacement)) - throw new UnexpectedValueException('Database error, given array of integer values is empty.'); - - foreach ($replacement as $key => $value) - { - if (!is_numeric($value) || (string) $value !== (string) (int) $value) - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Array of integers expected.'); - - $replacement[$key] = (string) (int) $value; - } - - return implode(', ', $replacement); + throw new UnexpectedValueException('Array ' . $key . + ' is used as a scalar placeholder. Did you mean to use \'@\' instead?'); } - else - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Array of integers expected.'); - break; - - case 'array_string': - if (is_array($replacement)) + // Prepare date/time values + if (is_a($value, 'DateTime')) { - if (empty($replacement)) - throw new UnexpectedValueException('Database error, given array of string values is empty.'); - - foreach ($replacement as $key => $value) - $replacement[$key] = sprintf('\'%1$s\'', mysqli_real_escape_string($connection, $value)); - - return implode(', ', $replacement); + $value = $value->format('Y-m-d H:i:s'); + } + } + elseif (str_contains($db_string, '@' . $key)) + { + if (!is_array($value)) + { + throw new UnexpectedValueException('Scalar value ' . $key . + ' is used as an array placeholder. Did you mean to use \':\' instead?'); } - else - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Array of strings expected.'); - break; - case 'date': - if (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d)$~', $replacement, $date_matches) === 1) - return sprintf('\'%04d-%02d-%02d\'', $date_matches[1], $date_matches[2], $date_matches[3]); - elseif ($replacement === 'NULL') - return 'NULL'; - else - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Date expected.'); - break; - - case 'datetime': - if (is_a($replacement, 'DateTime')) - return $replacement->format('\'Y-m-d H:i:s\''); - elseif (preg_match('~^(\d{4})-([0-1]?\d)-([0-3]?\d) (\d{2}):(\d{2}):(\d{2})$~', $replacement, $date_matches) === 1) - return sprintf('\'%04d-%02d-%02d %02d:%02d:%02d\'', $date_matches[1], $date_matches[2], $date_matches[3], $date_matches[4], $date_matches[5], $date_matches[6]); - elseif ($replacement === 'NULL') - return 'NULL'; - else - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. DateTime expected.'); - break; - - case 'float': - if (!is_numeric($replacement) && $replacement !== 'NULL') - throw new UnexpectedValueException('Wrong value type sent to the database for field: ' . $matches[2] . '. Floating point number expected.'); - return $replacement !== 'NULL' ? (string) (float) $replacement : 'NULL'; - break; - - case 'identifier': - // Backticks inside identifiers are supported as of MySQL 4.1. We don't need them here. - return '`' . strtr($replacement, ['`' => '', '.' => '']) . '`'; - break; - - case 'raw': - return $replacement; - break; - - case 'bool': - case 'boolean': - // In mysql this is a synonym for tinyint(1) - return (bool)$replacement ? 1 : 0; - break; - - default: - throw new UnexpectedValueException('Undefined type ' . $matches[1] . ' used in the database query'); - break; + // Create placeholders for all array elements + $placeholders = array_map(fn($num) => ':' . $key . $num, range(0, count($value) - 1)); + $db_string = str_replace('@' . $key, implode(', ', $placeholders), $db_string); + } + else + { + // throw new Exception('Warning: unused key in query: ' . $key); + } } + + return $db_string; } /** * Escapes and quotes a string using values passed, and executes the query. */ - public function query($db_string, $db_values = []) + public function query($db_string, array $db_values = []): PDOStatement { - // One more query.... - $this->query_count ++; + // One more query... + $this->query_count++; - // Overriding security? This is evil! - $security_override = $db_values === 'security_override' || !empty($db_values['security_override']); + // Error out if hardcoded strings are detected + if (strpos($db_string, '\'') !== false) + throw new UnexpectedValueException('Hack attempt: illegal character (\') used in query.'); - // Please, just use new style queries. - if (strpos($db_string, '\'') !== false && !$security_override) - throw new UnexpectedValueException('Hack attempt!', 'Illegal character (\') used in query.'); - - if (!$security_override && !empty($db_values)) - { - // Set some values for use in the callback function. - $this->db_callback = [$db_values, $this->connection]; - - // Insert the values passed to this function. - $db_string = preg_replace_callback('~{([a-z_]+)(?::([a-zA-Z0-9_-]+))?}~', [&$this, 'replacement_callback'], $db_string); - - // Save some memory. - $this->db_callback = []; - } - - if (defined("DB_LOG_QUERIES") && DB_LOG_QUERIES) + if (defined('DB_LOG_QUERIES') && DB_LOG_QUERIES) $this->logged_queries[] = $db_string; try { - $return = @mysqli_query($this->connection, $db_string, empty($this->unbuffered) ? MYSQLI_STORE_RESULT : MYSQLI_USE_RESULT); + // Preprocessing/checks: prepare any arrays for binding + $db_string = $this->expandPlaceholders($db_string, $db_values); + + // Prepare query for execution + $statement = $this->connection->prepare($db_string); + + // Bind parameters... the hard way, due to a limit/offset hack. + // NB: bindParam binds by reference, hence &$value here. + foreach ($db_values as $key => &$value) + { + // Assumption: both scalar and array values are preprocessed to use named ':' placeholders + if (!str_contains($db_string, ':' . $key)) + continue; + + if (!is_array($value)) + { + $statement->bindParam(':' . $key, $value); + continue; + } + + foreach (array_values($value) as $num => &$element) + { + $statement->bindParam(':' . $key . $num, $element); + } + } + + $statement->execute(); + return $statement; } - catch (Exception $e) + catch (PDOException $e) { - $clean_sql = implode("\n", array_map('trim', explode("\n", $db_string))); - throw new UnexpectedValueException($this->error() . '