commit 0feba3038f96bca3fc8f8f519310edb590696cfe Author: info@myprestarocks Date: Wed Dec 31 19:40:28 2025 +0100 Add .gitignore and .htaccess for security 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97c1e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# IDE +.idea/ +.vscode/ +*.sublime-* + +# OS +.DS_Store +Thumbs.db + +# Node (if any build tools) +node_modules/ + +# Composer dev dependencies (if installed locally) +vendor/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..3de9e40 --- /dev/null +++ b/.htaccess @@ -0,0 +1,10 @@ +# Apache 2.2 + + Order deny,allow + Deny from all + + +# Apache 2.4 + + Require all denied + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..55529a2 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "myprestarocks/prestashop-session", + "description": "Shared session tracking for PrestaShop modules - bot detection, browser/device/OS detection, session hash generation, shared mpr_sessions table", + "keywords": ["prestashop", "session", "tracking", "bot-detection", "analytics"], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "mypresta.rocks", + "email": "info@mypresta.rocks" + } + ], + "require": { + "php": ">=7.1" + }, + "autoload": { + "psr-4": { + "MyPrestaRocks\\Session\\": "src/" + } + }, + "extra": { + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "minimum-stability": "stable" +} diff --git a/src/CartActionsTable.php b/src/CartActionsTable.php new file mode 100644 index 0000000..39e614a --- /dev/null +++ b/src/CartActionsTable.php @@ -0,0 +1,262 @@ + + * @copyright Copyright (c) mypresta.rocks + * @license MIT + */ + +namespace MyPrestaRocks\Session; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class CartActionsTable +{ + /** + * Table name without prefix + */ + private const TABLE_NAME = 'mpr_cart_actions'; + + /** + * Get full table name with prefix + * + * @return string + */ + public static function getTableName() + { + return _DB_PREFIX_ . self::TABLE_NAME; + } + + /** + * Check if the table exists + * + * @return bool + */ + public static function tableExists() + { + $sql = "SHOW TABLES LIKE '" . self::getTableName() . "'"; + return (bool) \Db::getInstance()->getValue($sql); + } + + /** + * Install the cart actions table + * + * @return bool + */ + public static function install() + { + $engine = _MYSQL_ENGINE_; + $charset = 'utf8mb4'; + $table = self::getTableName(); + + $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( + `id_cart_action` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `id_session` INT(10) UNSIGNED NOT NULL, + `id_cart` INT(10) UNSIGNED NOT NULL, + `id_product` INT(10) UNSIGNED NOT NULL, + `id_product_attribute` INT(10) UNSIGNED DEFAULT 0, + `action_type` ENUM('add', 'remove', 'update', 'voucher_add', 'voucher_remove') NOT NULL, + `quantity` INT(10) UNSIGNED NOT NULL DEFAULT 1, + `quantity_before` INT(10) UNSIGNED DEFAULT NULL, + `product_name` VARCHAR(255) DEFAULT NULL, + `product_price` DECIMAL(20,6) DEFAULT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id_cart_action`), + KEY `idx_session` (`id_session`), + KEY `idx_cart` (`id_cart`), + KEY `idx_product` (`id_product`), + KEY `idx_action_type` (`action_type`), + KEY `idx_date_add` (`date_add`), + KEY `idx_session_date` (`id_session`, `date_add`) + ) ENGINE={$engine} DEFAULT CHARSET={$charset};"; + + return \Db::getInstance()->execute($sql); + } + + /** + * Uninstall the cart actions table + * + * @param string $currentModule The module being uninstalled + * @return bool + */ + public static function uninstall($currentModule) + { + foreach (SessionTable::getModulesUsingSession() as $moduleName) { + if ($moduleName === $currentModule) { + continue; + } + if (\Module::isInstalled($moduleName)) { + return true; + } + } + + return \Db::getInstance()->execute("DROP TABLE IF EXISTS `" . self::getTableName() . "`"); + } + + /** + * Track a cart action + * + * @param int $idSession Session ID + * @param int $idCart Cart ID + * @param int $idProduct Product ID + * @param int $idProductAttribute Product attribute ID (0 if none) + * @param string $actionType 'add', 'remove', 'update', 'voucher_add', or 'voucher_remove' + * @param int $quantity Current/new quantity + * @param int|null $quantityBefore Previous quantity (for update actions) + * @param string|null $productName Product name or voucher name + * @param float|null $productPrice Product price or voucher value + * @return bool + */ + public static function trackCartAction( + $idSession, + $idCart, + $idProduct, + $idProductAttribute, + $actionType, + $quantity, + $quantityBefore = null, + $productName = null, + $productPrice = null + ) { + if (!$idSession || !$idCart) { + return false; + } + + $validActions = ['add', 'remove', 'update', 'voucher_add', 'voucher_remove']; + if (!in_array($actionType, $validActions)) { + return false; + } + + $isVoucherAction = strpos($actionType, 'voucher_') === 0; + if (!$isVoucherAction && !$idProduct) { + return false; + } + + // Look up product name if not provided (only for product actions) + if ($productName === null && !$isVoucherAction && $idProduct) { + $idLang = (int) \Context::getContext()->language->id; + $productName = \Db::getInstance()->getValue( + 'SELECT name FROM ' . _DB_PREFIX_ . 'product_lang + WHERE id_product = ' . (int) $idProduct . ' + AND id_lang = ' . $idLang + ); + } + + // Look up price if not provided + if ($productPrice === null && $idProduct) { + $productPrice = \Product::getPriceStatic( + (int) $idProduct, + true, + (int) $idProductAttribute ?: null, + 6 + ); + } + + $table = self::getTableName(); + + $sql = "INSERT INTO `{$table}` + (id_session, id_cart, id_product, id_product_attribute, action_type, + quantity, quantity_before, product_name, product_price, date_add) + VALUES ( + " . (int) $idSession . ", + " . (int) $idCart . ", + " . (int) $idProduct . ", + " . (int) $idProductAttribute . ", + '" . pSQL($actionType) . "', + " . (int) $quantity . ", + " . ($quantityBefore !== null ? (int) $quantityBefore : 'NULL') . ", + " . ($productName ? "'" . pSQL(substr($productName, 0, 255)) . "'" : 'NULL') . ", + " . ($productPrice !== null ? (float) $productPrice : 'NULL') . ", + NOW() + )"; + + return \Db::getInstance()->execute($sql); + } + + /** + * Get cart actions for a session + * + * @param int $idSession Session ID + * @param int $limit Max records + * @return array + */ + public static function getCartActionsForSession($idSession, $limit = 100) + { + $table = self::getTableName(); + + return \Db::getInstance()->executeS( + "SELECT * FROM `{$table}` + WHERE id_session = " . (int) $idSession . " + ORDER BY date_add ASC + LIMIT " . (int) $limit + ) ?: []; + } + + /** + * Get cart actions for multiple sessions (batch lookup) + * + * @param array $sessionIds Array of session IDs + * @param int $limit Max records per session + * @return array Grouped by session ID + */ + public static function getCartActionsForSessions(array $sessionIds, $limit = 100) + { + if (empty($sessionIds)) { + return []; + } + + $table = self::getTableName(); + $ids = array_map('intval', $sessionIds); + + $actions = \Db::getInstance()->executeS( + "SELECT * FROM `{$table}` + WHERE id_session IN (" . implode(',', $ids) . ") + ORDER BY id_session, date_add ASC" + ) ?: []; + + $grouped = []; + foreach ($actions as $action) { + $sessId = $action['id_session']; + if (!isset($grouped[$sessId])) { + $grouped[$sessId] = []; + } + if (count($grouped[$sessId]) < $limit) { + $grouped[$sessId][] = $action; + } + } + + return $grouped; + } + + /** + * Clean old cart action records + * + * @param int $hours Hours to keep + * @return bool + */ + public static function cleanOldCartActions($hours = 168) + { + $table = self::getTableName(); + + return \Db::getInstance()->execute( + "DELETE FROM `{$table}` + WHERE date_add < DATE_SUB(NOW(), INTERVAL " . (int) $hours . " HOUR)" + ); + } +} diff --git a/src/PageViewsTable.php b/src/PageViewsTable.php new file mode 100644 index 0000000..18dd3d4 --- /dev/null +++ b/src/PageViewsTable.php @@ -0,0 +1,189 @@ + + * @copyright Copyright (c) mypresta.rocks + * @license MIT + */ + +namespace MyPrestaRocks\Session; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class PageViewsTable +{ + /** + * Table name without prefix + */ + private const TABLE_NAME = 'mpr_page_views'; + + /** + * Get full table name with prefix + * + * @return string + */ + public static function getTableName() + { + return _DB_PREFIX_ . self::TABLE_NAME; + } + + /** + * Check if the table exists + * + * @return bool + */ + public static function tableExists() + { + $sql = "SHOW TABLES LIKE '" . self::getTableName() . "'"; + return (bool) \Db::getInstance()->getValue($sql); + } + + /** + * Install the page views table + * + * @return bool + */ + public static function install() + { + $engine = _MYSQL_ENGINE_; + $charset = 'utf8mb4'; + $table = self::getTableName(); + + $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( + `id_page_view` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `id_session` INT(10) UNSIGNED NOT NULL, + `page_type` TINYINT(2) UNSIGNED NOT NULL, + `page_id` INT(10) UNSIGNED DEFAULT NULL, + `controller` VARCHAR(64) DEFAULT NULL, + `date_add` DATETIME NOT NULL, + PRIMARY KEY (`id_page_view`), + KEY `id_session` (`id_session`), + KEY `page_type` (`page_type`), + KEY `date_add` (`date_add`), + KEY `session_date` (`id_session`, `date_add`) + ) ENGINE={$engine} DEFAULT CHARSET={$charset};"; + + return \Db::getInstance()->execute($sql); + } + + /** + * Uninstall the page views table + * + * @param string $currentModule The module being uninstalled + * @return bool + */ + public static function uninstall($currentModule) + { + foreach (SessionTable::getModulesUsingSession() as $moduleName) { + if ($moduleName === $currentModule) { + continue; + } + if (\Module::isInstalled($moduleName)) { + return true; + } + } + + return \Db::getInstance()->execute("DROP TABLE IF EXISTS `" . self::getTableName() . "`"); + } + + /** + * Track detailed page view + * + * @param int $idSession Session ID + * @param int $pageType Page type constant + * @param int|null $pageId Page ID + * @param string|null $controller Controller name + * @return bool + */ + public static function trackPageView($idSession, $pageType, $pageId = null, $controller = null) + { + if (!$idSession) { + return false; + } + + $table = self::getTableName(); + + $sql = "INSERT INTO `{$table}` (id_session, page_type, page_id, controller, date_add) + VALUES (" . (int) $idSession . ", " . (int) $pageType . ", " . + ($pageId !== null ? (int) $pageId : 'NULL') . ", " . + ($controller ? "'" . pSQL(substr($controller, 0, 64)) . "'" : 'NULL') . ", NOW())"; + + return \Db::getInstance()->execute($sql); + } + + /** + * Get page views for a session + * + * @param int $idSession Session ID + * @param int $limit Max records to return + * @return array + */ + public static function getPageViewsForSession($idSession, $limit = 100) + { + $table = self::getTableName(); + + $sql = "SELECT * FROM `{$table}` + WHERE id_session = " . (int) $idSession . " + ORDER BY date_add ASC + LIMIT " . (int) $limit; + + return \Db::getInstance()->executeS($sql) ?: []; + } + + /** + * Get page view statistics for a session + * + * @param int $idSession Session ID + * @return array + */ + public static function getPageViewStats($idSession) + { + $table = self::getTableName(); + + $sql = "SELECT page_type, COUNT(*) as count + FROM `{$table}` + WHERE id_session = " . (int) $idSession . " + GROUP BY page_type"; + + $byType = \Db::getInstance()->executeS($sql) ?: []; + + $stats = ['by_type' => []]; + foreach ($byType as $row) { + $stats['by_type'][$row['page_type']] = (int) $row['count']; + } + + return $stats; + } + + /** + * Clean old page view records + * + * @param int $hours Hours to keep + * @return bool + */ + public static function cleanOldPageViews($hours = 24) + { + $table = self::getTableName(); + + return \Db::getInstance()->execute( + "DELETE FROM `{$table}` + WHERE date_add < DATE_SUB(NOW(), INTERVAL " . (int) $hours . " HOUR)" + ); + } +} diff --git a/src/SessionInstaller.php b/src/SessionInstaller.php new file mode 100644 index 0000000..4996593 --- /dev/null +++ b/src/SessionInstaller.php @@ -0,0 +1,77 @@ + + * @copyright Copyright (c) mypresta.rocks + * @license MIT + */ + +namespace MyPrestaRocks\Session; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SessionInstaller +{ + /** + * Install all shared session tables + * + * @return bool + */ + public static function installAll() + { + $result = true; + $result = $result && SessionTable::install(); + $result = $result && PageViewsTable::install(); + $result = $result && CartActionsTable::install(); + return $result; + } + + /** + * Uninstall all shared session tables + * Only drops tables if no other module using them is installed + * + * @param string $currentModule The module being uninstalled + * @return bool + */ + public static function uninstallAll($currentModule) + { + $result = true; + $result = $result && CartActionsTable::uninstall($currentModule); + $result = $result && PageViewsTable::uninstall($currentModule); + $result = $result && SessionTable::uninstall($currentModule); + return $result; + } + + /** + * Clean up old data from all session tables + * + * @param int $sessionHours Hours to keep sessions (default 24) + * @param int $pageViewsHours Hours to keep page views (default 24) + * @param int $cartActionsHours Hours to keep cart actions (default 168 / 7 days) + * @return bool + */ + public static function cleanAll($sessionHours = 24, $pageViewsHours = 24, $cartActionsHours = 168) + { + $result = true; + $result = $result && SessionTable::cleanOldSessions($sessionHours); + $result = $result && PageViewsTable::cleanOldPageViews($pageViewsHours); + $result = $result && CartActionsTable::cleanOldCartActions($cartActionsHours); + return $result; + } +} diff --git a/src/SessionTable.php b/src/SessionTable.php new file mode 100644 index 0000000..ee1cd71 --- /dev/null +++ b/src/SessionTable.php @@ -0,0 +1,395 @@ + + * @copyright Copyright (c) mypresta.rocks + * @license MIT + */ + +namespace MyPrestaRocks\Session; + +if (!defined('_PS_VERSION_')) { + exit; +} + +class SessionTable +{ + /** + * List of modules that use the shared mpr_sessions table. + * Add your module name here when integrating. + */ + private const MODULES_USING_SESSION = [ + 'mprexpresscheckout', + 'mprtotaldefender', + 'mprtradeaccount', + ]; + + /** + * Table name without prefix + */ + private const TABLE_NAME = 'mpr_sessions'; + + /** + * Get full table name with prefix + * + * @return string + */ + public static function getTableName() + { + return _DB_PREFIX_ . self::TABLE_NAME; + } + + /** + * Check if the session table exists + * + * @return bool + */ + public static function tableExists() + { + $sql = "SHOW TABLES LIKE '" . self::getTableName() . "'"; + return (bool) \Db::getInstance()->getValue($sql); + } + + /** + * Install the shared session table + * Uses IF NOT EXISTS so any module can safely call this + * + * @return bool + */ + public static function install() + { + $engine = _MYSQL_ENGINE_; + $charset = 'utf8mb4'; + $table = self::getTableName(); + + $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( + `id_session` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `id_customer` INT(10) UNSIGNED DEFAULT NULL, + `id_guest` INT(10) UNSIGNED NOT NULL, + `session_hash` VARCHAR(32) NOT NULL, + + -- Core tracking (all modules) + `ip_address` VARCHAR(45) DEFAULT NULL, + `user_agent` VARCHAR(512) DEFAULT NULL, + `browser` VARCHAR(32) DEFAULT NULL, + `device_type` VARCHAR(16) DEFAULT NULL, + `os` VARCHAR(32) DEFAULT NULL, + `is_bot` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + + -- Attribution tracking (mprexpresscheckout) + `source_type` TINYINT(3) UNSIGNED NOT NULL DEFAULT 0, + `source_detail` VARCHAR(500) DEFAULT NULL, + `utm_source` VARCHAR(128) DEFAULT NULL, + `utm_medium` VARCHAR(128) DEFAULT NULL, + `utm_campaign` VARCHAR(128) DEFAULT NULL, + `utm_term` VARCHAR(255) DEFAULT NULL, + `utm_content` VARCHAR(255) DEFAULT NULL, + `gclid` VARCHAR(255) DEFAULT NULL, + `fbclid` VARCHAR(255) DEFAULT NULL, + `msclkid` VARCHAR(255) DEFAULT NULL, + `ttclid` VARCHAR(255) DEFAULT NULL, + + -- Landing page tracking (mprexpresscheckout) + `landing_page_type` TINYINT(2) UNSIGNED DEFAULT NULL, + `landing_page_id` INT(10) UNSIGNED DEFAULT NULL, + `landing_url` VARCHAR(500) DEFAULT NULL, + + -- Context + `id_lang` INT(10) UNSIGNED DEFAULT NULL, + `id_currency` INT(10) UNSIGNED DEFAULT NULL, + `is_first_visit` TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, + + -- Page tracking (lightweight counters) + `pages_viewed` INT(10) UNSIGNED NOT NULL DEFAULT 0, + `last_page_type` TINYINT(2) UNSIGNED DEFAULT NULL, + `last_page_id` INT(10) UNSIGNED DEFAULT NULL, + + -- Timestamps + `date_add` DATETIME NOT NULL, + `date_last_activity` DATETIME NOT NULL, + + PRIMARY KEY (`id_session`), + UNIQUE KEY `session_hash` (`session_hash`), + KEY `id_customer` (`id_customer`), + KEY `id_guest` (`id_guest`), + KEY `ip_address` (`ip_address`), + KEY `source_type` (`source_type`), + KEY `is_bot` (`is_bot`), + KEY `date_add` (`date_add`), + KEY `date_last_activity` (`date_last_activity`), + KEY `customer_sessions` (`id_customer`, `date_add`), + KEY `guest_sessions` (`id_guest`, `date_add`) + ) ENGINE={$engine} DEFAULT CHARSET={$charset};"; + + return \Db::getInstance()->execute($sql); + } + + /** + * Uninstall the shared session table + * Only drops the table if no other module using it is still installed + * + * @param string $currentModule The module being uninstalled + * @return bool + */ + public static function uninstall($currentModule) + { + // Check if any other module using sessions is still installed + foreach (self::MODULES_USING_SESSION as $moduleName) { + if ($moduleName === $currentModule) { + continue; + } + if (\Module::isInstalled($moduleName)) { + // Another module still needs the table + return true; + } + } + + // No other module needs it - drop the table + return \Db::getInstance()->execute("DROP TABLE IF EXISTS `" . self::getTableName() . "`"); + } + + /** + * Get list of modules that use the session table + * + * @return array + */ + public static function getModulesUsingSession() + { + return self::MODULES_USING_SESSION; + } + + /** + * Check if another module using the session table is installed + * + * @param string $excludeModule Module to exclude from check + * @return bool + */ + public static function isAnotherModuleInstalled($excludeModule) + { + foreach (self::MODULES_USING_SESSION as $moduleName) { + if ($moduleName === $excludeModule) { + continue; + } + if (\Module::isInstalled($moduleName)) { + return true; + } + } + return false; + } + + /** + * Update session last activity (with 1-minute throttling) + * + * @param int $idSession Session ID + * @return bool + */ + public static function updateSessionActivity($idSession) + { + $table = self::getTableName(); + + // Check if recently updated (within 1 minute) + $sql = "SELECT 1 FROM `{$table}` + WHERE id_session = " . (int) $idSession . " + AND date_last_activity > DATE_SUB(NOW(), INTERVAL 1 MINUTE)"; + + if (\Db::getInstance()->getValue($sql)) { + return true; // Skip update - already recent + } + + return \Db::getInstance()->execute( + "UPDATE `{$table}` + SET date_last_activity = NOW() + WHERE id_session = " . (int) $idSession + ); + } + + /** + * Get active session by hash + * + * @param string $sessionHash + * @param int $timeoutMinutes + * @return int|null Session ID or null + */ + public static function getActiveSessionId($sessionHash, $timeoutMinutes = 60) + { + $table = self::getTableName(); + + $sql = "SELECT id_session FROM `{$table}` + WHERE session_hash = '" . pSQL($sessionHash) . "' + AND date_last_activity > DATE_SUB(NOW(), INTERVAL " . (int) $timeoutMinutes . " MINUTE)"; + + $result = \Db::getInstance()->getValue($sql); + return $result ? (int) $result : null; + } + + /** + * Link guest sessions to customer (on login/registration) + * + * @param int $idGuest + * @param int $idCustomer + * @return bool + */ + public static function linkGuestToCustomer($idGuest, $idCustomer) + { + if (!$idGuest || !$idCustomer) { + return false; + } + + return \Db::getInstance()->update( + self::TABLE_NAME, + ['id_customer' => (int) $idCustomer], + 'id_guest = ' . (int) $idGuest . ' AND id_customer IS NULL' + ); + } + + /** + * Clean old sessions + * + * @param int $hours Hours to keep (default 24) + * @return bool + */ + public static function cleanOldSessions($hours = 24) + { + $table = self::getTableName(); + + return \Db::getInstance()->execute( + "DELETE FROM `{$table}` + WHERE date_last_activity < DATE_SUB(NOW(), INTERVAL " . (int) $hours . " HOUR)" + ); + } + + /** + * Get active session count + * + * @param int $timeoutMinutes + * @param bool $excludeBots + * @return int + */ + public static function getActiveSessionCount($timeoutMinutes = 60, $excludeBots = true) + { + $table = self::getTableName(); + + $sql = "SELECT COUNT(*) FROM `{$table}` + WHERE date_last_activity > DATE_SUB(NOW(), INTERVAL " . (int) $timeoutMinutes . " MINUTE)"; + + if ($excludeBots) { + $sql .= " AND is_bot = 0"; + } + + return (int) \Db::getInstance()->getValue($sql); + } + + /** + * Get sessions for an IP address + * + * @param string $ipAddress + * @param int $hours Time window (0 for all) + * @return array + */ + public static function getSessionsForIP($ipAddress, $hours = 24) + { + $table = self::getTableName(); + + $sql = "SELECT * FROM `{$table}` WHERE ip_address = '" . pSQL($ipAddress) . "'"; + + if ($hours > 0) { + $sql .= " AND date_add >= DATE_SUB(NOW(), INTERVAL " . (int) $hours . " HOUR)"; + } + + $sql .= " ORDER BY date_add DESC"; + + return \Db::getInstance()->executeS($sql); + } + + /** + * Count sessions for an IP address + * + * @param string $ipAddress + * @param int $hours Time window + * @param bool $excludeBots Exclude bot sessions + * @return int + */ + public static function countSessionsForIP($ipAddress, $hours = 24, $excludeBots = true) + { + $table = self::getTableName(); + + $sql = "SELECT COUNT(*) FROM `{$table}` WHERE ip_address = '" . pSQL($ipAddress) . "'"; + + if ($excludeBots) { + $sql .= " AND is_bot = 0"; + } + + if ($hours > 0) { + $sql .= " AND date_add >= DATE_SUB(NOW(), INTERVAL " . (int) $hours . " HOUR)"; + } + + return (int) \Db::getInstance()->getValue($sql); + } + + /** + * Track page view for a session (lightweight - just increment counter) + * + * @param int $idSession Session ID + * @param int|null $pageType Page type constant + * @param int|null $pageId Page ID (product, category, etc.) + * @return bool + */ + public static function trackPageView($idSession, $pageType = null, $pageId = null) + { + if (!$idSession) { + return false; + } + + $table = self::getTableName(); + + $sql = "UPDATE `{$table}` SET + pages_viewed = pages_viewed + 1, + last_page_type = " . ($pageType !== null ? (int) $pageType : 'NULL') . ", + last_page_id = " . ($pageId !== null ? (int) $pageId : 'NULL') . ", + date_last_activity = NOW() + WHERE id_session = " . (int) $idSession; + + return \Db::getInstance()->execute($sql); + } + + /** + * Track page view by session hash (optimized - single query when session exists) + * + * @param string $sessionHash Session hash + * @param int|null $pageType Page type constant + * @param int|null $pageId Page ID + * @param int $timeoutMinutes Session timeout + * @return bool True if page was tracked, false if session not found + */ + public static function trackPageViewByHash($sessionHash, $pageType = null, $pageId = null, $timeoutMinutes = 60) + { + $table = self::getTableName(); + + $sql = "UPDATE `{$table}` SET + pages_viewed = pages_viewed + 1, + last_page_type = " . ($pageType !== null ? (int) $pageType : 'NULL') . ", + last_page_id = " . ($pageId !== null ? (int) $pageId : 'NULL') . ", + date_last_activity = NOW() + WHERE session_hash = '" . pSQL($sessionHash) . "' + AND date_last_activity > DATE_SUB(NOW(), INTERVAL " . (int) $timeoutMinutes . " MINUTE)"; + + \Db::getInstance()->execute($sql); + + return \Db::getInstance()->Affected_Rows() > 0; + } +} diff --git a/src/SessionTrait.php b/src/SessionTrait.php new file mode 100644 index 0000000..1c23913 --- /dev/null +++ b/src/SessionTrait.php @@ -0,0 +1,364 @@ + + * @copyright Copyright (c) mypresta.rocks + * @license MIT + */ + +namespace MyPrestaRocks\Session; + +if (!defined('_PS_VERSION_')) { + exit; +} + +trait SessionTrait +{ + /** + * Page type values for last_page_type column + * Note: Using static array instead of constants for PHP 7.4 compatibility + * (traits cannot have constants in PHP < 8.2) + */ + protected static $PAGE_TYPES = [ + 'HOME' => 1, + 'CATEGORY' => 2, + 'PRODUCT' => 3, + 'CMS' => 4, + 'CART' => 5, + 'CHECKOUT' => 6, + 'ORDER_CONFIRMATION' => 7, + 'MY_ACCOUNT' => 8, + 'SEARCH' => 9, + 'MANUFACTURER' => 10, + 'SUPPLIER' => 11, + 'CONTACT' => 12, + 'OTHER' => 99, + ]; + + /** + * Detect if request is from a bot + * + * @param string|null $userAgent Optional user agent (uses $_SERVER if not provided) + * @return bool + */ + protected static function mprIsBot($userAgent = null) + { + if ($userAgent === null) { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return true; + } + $userAgent = $_SERVER['HTTP_USER_AGENT']; + } + + $userAgent = strtolower($userAgent); + + $botPatterns = [ + // Search engine crawlers + 'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider', + 'yandexbot', 'sogou', 'exabot', + // Social media bots + 'facebot', 'facebookexternalhit', 'twitterbot', 'linkedinbot', + 'whatsapp', 'telegrambot', 'slackbot', 'discordbot', 'pinterestbot', + // SEO tools + 'applebot', 'semrushbot', 'ahrefsbot', 'mj12bot', 'dotbot', + 'rogerbot', 'petalbot', 'screaming frog', + // Archive bots + 'ia_archiver', 'archive.org_bot', + // Generic patterns + 'crawler', 'spider', 'bot.htm', 'bot.php', 'crawl', + // HTTP clients + 'wget', 'curl', 'python-requests', 'python/', 'scrapy', + 'php/', 'java/', 'go-http-client', 'httpclient', 'apache-httpclient', + 'libwww', 'lwp-', 'mechanize', + // Headless browsers + 'headlesschrome', 'phantomjs', 'selenium', 'webdriver', 'puppeteer', + // API testing tools + 'postman', 'insomnia', + // Monitoring + 'pingdom', 'uptimerobot', 'monitoring', 'check_http', + // Generic http pattern (often bots) + 'http', + 'httrack', + 'mediapartners', + ]; + + foreach ($botPatterns as $pattern) { + if (strpos($userAgent, $pattern) !== false) { + return true; + } + } + + return false; + } + + /** + * Detect browser from user agent + * + * @param string|null $userAgent Optional user agent + * @return string Browser name (chrome, firefox, safari, edge, opera, ie, other) + */ + protected static function mprDetectBrowser($userAgent = null) + { + if ($userAgent === null) { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return 'other'; + } + $userAgent = $_SERVER['HTTP_USER_AGENT']; + } + + // Edge must be checked before Chrome (Edge contains "Chrome" in UA) + if (strpos($userAgent, 'Edg') !== false || strpos($userAgent, 'Edge') !== false) { + return 'edge'; + } + // Chrome must be checked before Safari (Chrome contains "Safari" in UA) + if (strpos($userAgent, 'Chrome') !== false && strpos($userAgent, 'Chromium') === false) { + return 'chrome'; + } + if (strpos($userAgent, 'Firefox') !== false) { + return 'firefox'; + } + if (strpos($userAgent, 'Safari') !== false && strpos($userAgent, 'Chrome') === false) { + return 'safari'; + } + if (strpos($userAgent, 'OPR') !== false || strpos($userAgent, 'Opera') !== false) { + return 'opera'; + } + if (strpos($userAgent, 'MSIE') !== false || strpos($userAgent, 'Trident') !== false) { + return 'ie'; + } + + return 'other'; + } + + /** + * Detect device type from user agent + * + * @param string|null $userAgent Optional user agent + * @return string Device type (mobile, tablet, desktop) + */ + protected static function mprDetectDeviceType($userAgent = null) + { + if ($userAgent === null) { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return 'desktop'; + } + $userAgent = $_SERVER['HTTP_USER_AGENT']; + } + + $userAgentLower = strtolower($userAgent); + + // Check mobile first (phones) + if (preg_match('/mobile|android.*mobile|iphone|ipod|phone|blackberry|iemobile|opera mini|opera mobi/i', $userAgentLower)) { + return 'mobile'; + } + + // Check tablet + if (preg_match('/tablet|ipad|android(?!.*mobile)|kindle|silk/i', $userAgentLower)) { + return 'tablet'; + } + + return 'desktop'; + } + + /** + * Detect operating system from user agent + * + * @param string|null $userAgent Optional user agent + * @return string OS name (windows, macos, android, ios, linux, other) + */ + protected static function mprDetectOS($userAgent = null) + { + if ($userAgent === null) { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return 'other'; + } + $userAgent = $_SERVER['HTTP_USER_AGENT']; + } + + if (strpos($userAgent, 'Windows') !== false) { + return 'windows'; + } + if (strpos($userAgent, 'Mac OS X') !== false) { + return 'macos'; + } + if (strpos($userAgent, 'Android') !== false) { + return 'android'; + } + if (strpos($userAgent, 'iPhone') !== false || strpos($userAgent, 'iPad') !== false) { + return 'ios'; + } + if (strpos($userAgent, 'Linux') !== false) { + return 'linux'; + } + + return 'other'; + } + + /** + * Generate session hash + * + * @param int $idGuest Guest ID + * @param string|null $extra Additional data to include in hash (e.g., campaign params) + * @param int $timeBucket Time bucket in seconds (default 30 minutes) + * @return string MD5 hash + */ + protected static function mprGenerateSessionHash($idGuest, $extra = null, $timeBucket = 1800) + { + $timestamp = floor(time() / $timeBucket); + $hashData = $idGuest . '_' . $timestamp; + + if ($extra) { + $hashData .= '_' . $extra; + } + + return md5($hashData); + } + + /** + * Get current IP address + * + * @return string + */ + protected static function mprGetIPAddress() + { + return \Tools::getRemoteAddr(); + } + + /** + * Get current user agent + * + * @param int $maxLength Maximum length to return + * @return string|null + */ + protected static function mprGetUserAgent($maxLength = 512) + { + if (!isset($_SERVER['HTTP_USER_AGENT'])) { + return null; + } + return substr($_SERVER['HTTP_USER_AGENT'], 0, $maxLength); + } + + /** + * Detect current page type from controller + * + * @return array [page_type, page_id] + */ + protected static function mprDetectPageType() + { + $controller = \Tools::getValue('controller', ''); + + switch ($controller) { + case 'index': + return [self::$PAGE_TYPES['HOME'], null]; + + case 'category': + $pageId = (int) \Tools::getValue('id_category'); + return [self::$PAGE_TYPES['CATEGORY'], $pageId ?: null]; + + case 'product': + $pageId = (int) \Tools::getValue('id_product'); + return [self::$PAGE_TYPES['PRODUCT'], $pageId ?: null]; + + case 'cms': + $pageId = (int) \Tools::getValue('id_cms'); + return [self::$PAGE_TYPES['CMS'], $pageId ?: null]; + + case 'cart': + return [self::$PAGE_TYPES['CART'], null]; + + case 'order': + case 'orderopc': + case 'checkout': + case 'mprexpresscheckoutcheckout': + return [self::$PAGE_TYPES['CHECKOUT'], null]; + + case 'orderconfirmation': + case 'order-confirmation': + $pageId = (int) \Tools::getValue('id_order'); + return [self::$PAGE_TYPES['ORDER_CONFIRMATION'], $pageId ?: null]; + + case 'my-account': + case 'identity': + case 'addresses': + case 'address': + case 'history': + case 'order-detail': + case 'order-follow': + case 'order-slip': + return [self::$PAGE_TYPES['MY_ACCOUNT'], null]; + + case 'search': + return [self::$PAGE_TYPES['SEARCH'], null]; + + case 'manufacturer': + $pageId = (int) \Tools::getValue('id_manufacturer'); + return [self::$PAGE_TYPES['MANUFACTURER'], $pageId ?: null]; + + case 'supplier': + $pageId = (int) \Tools::getValue('id_supplier'); + return [self::$PAGE_TYPES['SUPPLIER'], $pageId ?: null]; + + case 'contact': + case 'contact-us': + return [self::$PAGE_TYPES['CONTACT'], null]; + + default: + return [self::$PAGE_TYPES['OTHER'], null]; + } + } + + /** + * Get page type name for display + * + * @param int $pageType Page type constant + * @return string + */ + public static function getPageTypeName($pageType) + { + $names = [ + self::$PAGE_TYPES['HOME'] => 'Home', + self::$PAGE_TYPES['CATEGORY'] => 'Category', + self::$PAGE_TYPES['PRODUCT'] => 'Product', + self::$PAGE_TYPES['CMS'] => 'CMS', + self::$PAGE_TYPES['CART'] => 'Cart', + self::$PAGE_TYPES['CHECKOUT'] => 'Checkout', + self::$PAGE_TYPES['ORDER_CONFIRMATION'] => 'Order Confirmation', + self::$PAGE_TYPES['MY_ACCOUNT'] => 'My Account', + self::$PAGE_TYPES['SEARCH'] => 'Search', + self::$PAGE_TYPES['MANUFACTURER'] => 'Manufacturer', + self::$PAGE_TYPES['SUPPLIER'] => 'Supplier', + self::$PAGE_TYPES['CONTACT'] => 'Contact', + self::$PAGE_TYPES['OTHER'] => 'Other', + ]; + + return $names[$pageType] ?? 'Unknown'; + } + + /** + * Get page types array + * + * @return array + */ + public static function getPageTypes() + { + return self::$PAGE_TYPES; + } +}