WebOrbiton
v1.0.0.1

StocketBase

311 lines · 15.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/config.php';
  6. ​
  7. final class Database
  8. {
  9. private static ?PDO $site = null;
  10. private static ?PDO $users = null;
  11. private static bool $schemaChecked = false;
  12. ​
  13. public static function site(): PDO
  14. {
  15. if (self::$site instanceof PDO) {
  16. return self::$site;
  17. }
  18. ​
  19. self::$site = self::connect(
  20. Config::get('DB_SITE_HOST', '127.0.0.1'),
  21. Config::get('DB_SITE_PORT', '3306'),
  22. Config::get('DB_SITE_NAME', ''),
  23. Config::get('DB_SITE_USER', ''),
  24. Config::get('DB_SITE_PASS', '')
  25. );
  26. ​
  27. self::ensureSiteSchema(self::$site);
  28. ​
  29. return self::$site;
  30. }
  31. ​
  32. public static function users(): PDO
  33. {
  34. if (self::$users instanceof PDO) {
  35. return self::$users;
  36. }
  37. ​
  38. self::$users = self::connect(
  39. Config::get('DB_USERS_HOST', '127.0.0.1'),
  40. Config::get('DB_USERS_PORT', '3306'),
  41. Config::get('DB_USERS_NAME', ''),
  42. Config::get('DB_USERS_USER', ''),
  43. Config::get('DB_USERS_PASS', '')
  44. );
  45. ​
  46. return self::$users;
  47. }
  48. ​
  49. private static function connect(?string $host, ?string $port, ?string $name, ?string $user, ?string $pass): PDO
  50. {
  51. $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $name);
  52. ​
  53. return new PDO($dsn, $user, $pass, [
  54. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  55. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  56. PDO::ATTR_EMULATE_PREPARES => false,
  57. ]);
  58. }
  59. ​
  60. private static function ensureSiteSchema(PDO $pdo): void
  61. {
  62. if (self::$schemaChecked) {
  63. return;
  64. }
  65. self::$schemaChecked = true;
  66. ​
  67. try {
  68. $dbName = $pdo->query('SELECT DATABASE()')->fetchColumn();
  69. if (!$dbName) {
  70. return;
  71. }
  72. ​
  73. $columnCheck = $pdo->prepare(
  74. 'SELECT COUNT(*) FROM information_schema.COLUMNS
  75. WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table AND COLUMN_NAME = :column'
  76. );
  77. ​
  78. $requiredColumns = [
  79. 'team_accounts' => [
  80. 'avatar_path' => "ALTER TABLE team_accounts ADD COLUMN avatar_path VARCHAR(255) NULL",
  81. 'totp_secret' => "ALTER TABLE team_accounts ADD COLUMN totp_secret VARCHAR(64) NULL",
  82. 'totp_enabled' => "ALTER TABLE team_accounts ADD COLUMN totp_enabled TINYINT(1) NOT NULL DEFAULT 0",
  83. 'totp_recovery' => "ALTER TABLE team_accounts ADD COLUMN totp_recovery TEXT NULL",
  84. 'totp_last_step' => "ALTER TABLE team_accounts ADD COLUMN totp_last_step BIGINT NULL",
  85. ],
  86. 'products' => [
  87. 'deleted_at' => "ALTER TABLE products ADD COLUMN deleted_at DATETIME NULL",
  88. 'trashed_status' => "ALTER TABLE products ADD COLUMN trashed_status VARCHAR(20) NULL",
  89. ],
  90. 'pages' => [
  91. 'is_home' => "ALTER TABLE pages ADD COLUMN is_home TINYINT(1) NOT NULL DEFAULT 0",
  92. ],
  93. 'categories' => [
  94. 'show_in_menu' => "ALTER TABLE categories ADD COLUMN show_in_menu TINYINT(1) NOT NULL DEFAULT 1 AFTER sort_order",
  95. ],
  96. 'product_files' => [
  97. 'product_title' => "ALTER TABLE product_files ADD COLUMN product_title VARCHAR(255) NULL",
  98. 'detached_at' => "ALTER TABLE product_files ADD COLUMN detached_at DATETIME NULL",
  99. 'button_label' => "ALTER TABLE product_files ADD COLUMN button_label VARCHAR(120) NULL",
  100. 'download_count' => "ALTER TABLE product_files ADD COLUMN download_count INT UNSIGNED NOT NULL DEFAULT 0",
  101. ],
  102. 'nav_items' => [
  103. 'icon_key' => "ALTER TABLE nav_items ADD COLUMN icon_key VARCHAR(30) NULL AFTER target",
  104. 'icon_svg' => "ALTER TABLE nav_items ADD COLUMN icon_svg MEDIUMTEXT NULL AFTER icon_key",
  105. ],
  106. ];
  107. ​
  108. foreach ($requiredColumns as $table => $columns) {
  109. $tableCheck = $pdo->prepare(
  110. 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table'
  111. );
  112. $tableCheck->execute(['db' => $dbName, 'table' => $table]);
  113. if ((int) $tableCheck->fetchColumn() === 0) {
  114. continue;
  115. }
  116. ​
  117. foreach ($columns as $column => $alterSql) {
  118. $columnCheck->execute(['db' => $dbName, 'table' => $table, 'column' => $column]);
  119. if ((int) $columnCheck->fetchColumn() === 0) {
  120. try {
  121. $pdo->exec($alterSql);
  122. } catch (PDOException $e) {
  123. error_log('StocketBase: could not auto-add column ' . $table . '.' . $column . ': ' . $e->getMessage());
  124. }
  125. }
  126. }
  127. }
  128. ​
  129. $authorColumns = ['products' => 'created_by', 'pages' => 'author_id', 'ads' => 'created_by', 'discounts' => 'created_by'];
  130. $authorState = $pdo->prepare(
  131. "SELECT c.TABLE_NAME, c.IS_NULLABLE, k.CONSTRAINT_NAME, r.DELETE_RULE
  132. FROM information_schema.COLUMNS c
  133. LEFT JOIN information_schema.KEY_COLUMN_USAGE k
  134. ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME AND k.REFERENCED_TABLE_NAME = 'team_accounts'
  135. LEFT JOIN information_schema.REFERENTIAL_CONSTRAINTS r
  136. ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
  137. WHERE c.TABLE_SCHEMA = :db
  138. AND ((c.TABLE_NAME = 'products' AND c.COLUMN_NAME = 'created_by')
  139. OR (c.TABLE_NAME = 'pages' AND c.COLUMN_NAME = 'author_id')
  140. OR (c.TABLE_NAME = 'ads' AND c.COLUMN_NAME = 'created_by')
  141. OR (c.TABLE_NAME = 'discounts' AND c.COLUMN_NAME = 'created_by'))"
  142. );
  143. $authorState->execute(['db' => $dbName]);
  144. foreach ($authorState->fetchAll(PDO::FETCH_ASSOC) as $state) {
  145. $table = (string) $state['TABLE_NAME'];
  146. $column = $authorColumns[$table] ?? null;
  147. if ($column === null) {
  148. continue;
  149. }
  150. $constraint = (string) ($state['CONSTRAINT_NAME'] ?? '');
  151. $needsNullable = $state['IS_NULLABLE'] === 'NO';
  152. $needsRule = $constraint !== '' && strtoupper((string) $state['DELETE_RULE']) !== 'SET NULL';
  153. if (!$needsNullable && !$needsRule) {
  154. continue;
  155. }
  156. if (preg_match('/^[A-Za-z0-9_]+$/', $constraint) !== 1 && $constraint !== '') {
  157. continue;
  158. }
  159. try {
  160. if ($needsRule) {
  161. $pdo->exec('ALTER TABLE ' . $table . ' DROP FOREIGN KEY ' . $constraint);
  162. }
  163. if ($needsNullable) {
  164. $pdo->exec('ALTER TABLE ' . $table . ' MODIFY ' . $column . ' INT UNSIGNED NULL');
  165. }
  166. if ($needsRule) {
  167. $pdo->exec('ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $constraint . ' FOREIGN KEY (' . $column . ') REFERENCES team_accounts(id) ON DELETE SET NULL');
  168. }
  169. if ($table === 'pages') {
  170. $pdo->exec('UPDATE pages SET author_id = NULL WHERE is_home = 1');
  171. }
  172. } catch (PDOException $e) {
  173. error_log('StocketBase: could not update author key on ' . $table . ': ' . $e->getMessage());
  174. }
  175. }
  176. ​
  177. $requiredTables = [
  178. 'site_settings' => "CREATE TABLE site_settings (
  179. setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
  180. setting_value LONGTEXT NULL,
  181. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  182. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  183. 'nav_items' => "CREATE TABLE nav_items (
  184. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  185. item_type ENUM('category','page','custom') NOT NULL,
  186. ref_id INT UNSIGNED NULL,
  187. label VARCHAR(120) NULL,
  188. url VARCHAR(255) NULL,
  189. target ENUM('_self','_blank') NOT NULL DEFAULT '_self',
  190. icon_key VARCHAR(30) NULL,
  191. icon_svg MEDIUMTEXT NULL,
  192. sort_order INT NOT NULL DEFAULT 0,
  193. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  194. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  195. KEY idx_nav_sort (sort_order)
  196. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  197. 'activity_log' => "CREATE TABLE activity_log (
  198. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  199. user_id INT UNSIGNED NULL,
  200. user_name VARCHAR(120) NULL,
  201. action VARCHAR(60) NOT NULL,
  202. entity_type VARCHAR(30) NULL,
  203. entity_id INT UNSIGNED NULL,
  204. details VARCHAR(500) NULL,
  205. ip_address VARCHAR(45) NULL,
  206. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  207. KEY idx_activity_created (created_at)
  208. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  209. 'product_versions' => "CREATE TABLE product_versions (
  210. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  211. product_id INT UNSIGNED NOT NULL,
  212. content_hash CHAR(40) NOT NULL,
  213. title VARCHAR(255) NOT NULL,
  214. excerpt TEXT NULL,
  215. content_blocks LONGTEXT NULL,
  216. category_id INT UNSIGNED NULL,
  217. cover_image_path VARCHAR(500) NULL,
  218. seo_title VARCHAR(255) NULL,
  219. seo_description TEXT NULL,
  220. price_cents INT UNSIGNED NULL,
  221. compare_at_price_cents INT UNSIGNED NULL,
  222. saved_by INT UNSIGNED NULL,
  223. saved_by_name VARCHAR(120) NULL,
  224. note VARCHAR(60) NOT NULL DEFAULT 'save',
  225. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  226. KEY idx_version_product (product_id, id)
  227. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  228. 'tags' => "CREATE TABLE tags (
  229. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  230. name VARCHAR(60) NOT NULL,
  231. slug VARCHAR(80) NOT NULL,
  232. UNIQUE KEY uniq_tag_slug (slug)
  233. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  234. 'product_tags' => "CREATE TABLE product_tags (
  235. product_id INT UNSIGNED NOT NULL,
  236. tag_id INT UNSIGNED NOT NULL,
  237. PRIMARY KEY (product_id, tag_id),
  238. KEY idx_product_tags_tag (tag_id)
  239. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  240. 'product_files' => "CREATE TABLE product_files (
  241. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  242. product_id INT UNSIGNED NOT NULL,
  243. original_name VARCHAR(190) NOT NULL,
  244. stored_name CHAR(32) NOT NULL,
  245. mime_type VARCHAR(120) NOT NULL DEFAULT 'application/octet-stream',
  246. size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
  247. sha256 CHAR(64) NULL,
  248. sort_order INT NOT NULL DEFAULT 0,
  249. product_title VARCHAR(255) NULL,
  250. detached_at DATETIME NULL,
  251. button_label VARCHAR(120) NULL,
  252. download_count INT UNSIGNED NOT NULL DEFAULT 0,
  253. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  254. UNIQUE KEY uq_product_file_stored (stored_name),
  255. KEY idx_product_files_product (product_id)
  256. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  257. 'login_attempts' => "CREATE TABLE login_attempts (
  258. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  259. scope VARCHAR(20) NOT NULL,
  260. identifier_hash CHAR(64) NOT NULL,
  261. ip_address VARCHAR(45) NOT NULL,
  262. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  263. KEY idx_attempt_identifier (scope, identifier_hash, created_at),
  264. KEY idx_attempt_ip (scope, ip_address, created_at)
  265. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  266. 'indexnow_log' => "CREATE TABLE indexnow_log (
  267. product_id INT UNSIGNED NOT NULL PRIMARY KEY,
  268. submitted_at DATETIME NOT NULL
  269. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  270. 'site_languages' => "CREATE TABLE site_languages (
  271. code VARCHAR(10) NOT NULL PRIMARY KEY,
  272. name VARCHAR(60) NOT NULL,
  273. sort_order INT NOT NULL DEFAULT 0,
  274. is_active TINYINT(1) NOT NULL DEFAULT 0
  275. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  276. 'content_translations' => "CREATE TABLE content_translations (
  277. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  278. entity_type VARCHAR(12) NOT NULL,
  279. entity_id INT UNSIGNED NOT NULL,
  280. lang VARCHAR(10) NOT NULL,
  281. title VARCHAR(255) NOT NULL DEFAULT '',
  282. excerpt TEXT NULL,
  283. content_blocks LONGTEXT NULL,
  284. seo_title VARCHAR(255) NULL,
  285. seo_description VARCHAR(500) NULL,
  286. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  287. UNIQUE KEY uniq_translation (entity_type, entity_id, lang),
  288. KEY idx_translation_lang (lang, entity_type)
  289. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  290. ];
  291. ​
  292. $tableExistsCheck = $pdo->prepare(
  293. 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table'
  294. );
  295. ​
  296. foreach ($requiredTables as $table => $createSql) {
  297. $tableExistsCheck->execute(['db' => $dbName, 'table' => $table]);
  298. if ((int) $tableExistsCheck->fetchColumn() === 0) {
  299. try {
  300. $pdo->exec($createSql);
  301. } catch (PDOException $e) {
  302. error_log('StocketBase: could not auto-create table ' . $table . ': ' . $e->getMessage());
  303. }
  304. }
  305. }
  306. } catch (PDOException $e) {
  307. error_log('StocketBase: schema auto-check failed: ' . $e->getMessage());
  308. }
  309. }
  310. }
  311. ​