WebOrbiton
v1.0.0.8

Publisium

368 lines · 18.8 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. 'articles' => [
  87. 'deleted_at' => "ALTER TABLE articles ADD COLUMN deleted_at DATETIME NULL",
  88. 'trashed_status' => "ALTER TABLE articles ADD COLUMN trashed_status VARCHAR(20) NULL",
  89. 'read_aloud' => "ALTER TABLE articles ADD COLUMN read_aloud TINYINT(1) NOT NULL DEFAULT 0",
  90. 'read_aloud_source' => "ALTER TABLE articles ADD COLUMN read_aloud_source VARCHAR(10) NOT NULL DEFAULT 'auto'",
  91. 'read_aloud_audio' => "ALTER TABLE articles ADD COLUMN read_aloud_audio VARCHAR(255) NULL",
  92. 'read_aloud_hash' => "ALTER TABLE articles ADD COLUMN read_aloud_hash CHAR(40) NULL",
  93. 'read_aloud_status' => "ALTER TABLE articles ADD COLUMN read_aloud_status VARCHAR(12) NULL",
  94. 'read_aloud_error' => "ALTER TABLE articles ADD COLUMN read_aloud_error VARCHAR(255) NULL",
  95. 'read_aloud_started' => "ALTER TABLE articles ADD COLUMN read_aloud_started DATETIME NULL",
  96. 'author_name' => "ALTER TABLE articles ADD COLUMN author_name VARCHAR(120) NULL AFTER author_id",
  97. ],
  98. 'analytics_scripts' => [
  99. 'consent_category' => "ALTER TABLE analytics_scripts ADD COLUMN consent_category VARCHAR(20) NOT NULL DEFAULT 'analytics' AFTER requires_consent",
  100. ],
  101. 'pages' => [
  102. 'author_name' => "ALTER TABLE pages ADD COLUMN author_name VARCHAR(120) NULL AFTER author_id",
  103. ],
  104. 'ads' => [
  105. 'hide_for_subscribers' => "ALTER TABLE ads ADD COLUMN hide_for_subscribers TINYINT(1) NOT NULL DEFAULT 0 AFTER is_active",
  106. 'audience' => "ALTER TABLE ads ADD COLUMN audience VARCHAR(10) NOT NULL DEFAULT 'all' AFTER hide_for_subscribers",
  107. 'excluded_items' => "ALTER TABLE ads ADD COLUMN excluded_items TEXT NULL AFTER audience",
  108. 'consent_category' => "ALTER TABLE ads ADD COLUMN consent_category VARCHAR(20) NOT NULL DEFAULT 'none' AFTER excluded_items",
  109. ],
  110. 'categories' => [
  111. 'show_in_menu' => "ALTER TABLE categories ADD COLUMN show_in_menu TINYINT(1) NOT NULL DEFAULT 1 AFTER sort_order",
  112. ],
  113. 'article_comments' => [
  114. 'author_email' => "ALTER TABLE article_comments ADD COLUMN author_email VARCHAR(190) NULL AFTER author_name",
  115. ],
  116. 'author_profiles' => [
  117. 'schema_type' => "ALTER TABLE author_profiles ADD COLUMN schema_type VARCHAR(12) NOT NULL DEFAULT 'Person' AFTER website_url",
  118. ],
  119. 'nav_items' => [
  120. 'icon_key' => "ALTER TABLE nav_items ADD COLUMN icon_key VARCHAR(30) NULL AFTER target",
  121. 'icon_svg' => "ALTER TABLE nav_items ADD COLUMN icon_svg MEDIUMTEXT NULL AFTER icon_key",
  122. ],
  123. ];
  124. ​
  125. foreach ($requiredColumns as $table => $columns) {
  126. $tableCheck = $pdo->prepare(
  127. 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table'
  128. );
  129. $tableCheck->execute(['db' => $dbName, 'table' => $table]);
  130. if ((int) $tableCheck->fetchColumn() === 0) {
  131. continue;
  132. }
  133. ​
  134. foreach ($columns as $column => $alterSql) {
  135. $columnCheck->execute(['db' => $dbName, 'table' => $table, 'column' => $column]);
  136. if ((int) $columnCheck->fetchColumn() === 0) {
  137. try {
  138. $pdo->exec($alterSql);
  139. } catch (PDOException $e) {
  140. error_log('Publisium: could not auto-add column ' . $table . '.' . $column . ': ' . $e->getMessage());
  141. }
  142. }
  143. }
  144. }
  145. ​
  146. $requiredTables = [
  147. 'site_settings' => "CREATE TABLE site_settings (
  148. setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
  149. setting_value LONGTEXT NULL,
  150. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  151. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  152. 'nav_items' => "CREATE TABLE nav_items (
  153. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  154. item_type ENUM('category','page','custom') NOT NULL,
  155. ref_id INT UNSIGNED NULL,
  156. label VARCHAR(120) NULL,
  157. url VARCHAR(255) NULL,
  158. target ENUM('_self','_blank') NOT NULL DEFAULT '_self',
  159. icon_key VARCHAR(30) NULL,
  160. icon_svg MEDIUMTEXT NULL,
  161. sort_order INT NOT NULL DEFAULT 0,
  162. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  163. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  164. KEY idx_nav_sort (sort_order)
  165. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  166. 'activity_log' => "CREATE TABLE activity_log (
  167. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  168. user_id INT UNSIGNED NULL,
  169. user_name VARCHAR(120) NULL,
  170. action VARCHAR(60) NOT NULL,
  171. entity_type VARCHAR(30) NULL,
  172. entity_id INT UNSIGNED NULL,
  173. details VARCHAR(500) NULL,
  174. ip_address VARCHAR(45) NULL,
  175. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  176. KEY idx_activity_created (created_at)
  177. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  178. 'author_profiles' => "CREATE TABLE author_profiles (
  179. account_id INT UNSIGNED NOT NULL PRIMARY KEY,
  180. slug VARCHAR(120) NOT NULL,
  181. headline VARCHAR(160) NOT NULL DEFAULT '',
  182. bio TEXT NULL,
  183. website_url VARCHAR(255) NOT NULL DEFAULT '',
  184. schema_type VARCHAR(12) NOT NULL DEFAULT 'Person',
  185. draft_headline VARCHAR(160) NULL,
  186. draft_bio TEXT NULL,
  187. draft_website_url VARCHAR(255) NULL,
  188. is_public TINYINT(1) NOT NULL DEFAULT 0,
  189. status ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft',
  190. rejection_reason VARCHAR(500) NULL,
  191. reviewed_by INT UNSIGNED NULL,
  192. reviewed_at DATETIME NULL,
  193. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  194. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  195. UNIQUE KEY uniq_author_slug (slug),
  196. KEY idx_author_status (status)
  197. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  198. 'article_versions' => "CREATE TABLE article_versions (
  199. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  200. article_id INT UNSIGNED NOT NULL,
  201. content_hash CHAR(40) NOT NULL,
  202. title VARCHAR(255) NOT NULL,
  203. excerpt TEXT NULL,
  204. content_blocks LONGTEXT NULL,
  205. category_id INT UNSIGNED NULL,
  206. cover_image_path VARCHAR(500) NULL,
  207. seo_title VARCHAR(255) NULL,
  208. seo_description TEXT NULL,
  209. access_type VARCHAR(10) NOT NULL DEFAULT 'free',
  210. price_cents INT UNSIGNED NULL,
  211. saved_by INT UNSIGNED NULL,
  212. saved_by_name VARCHAR(120) NULL,
  213. note VARCHAR(60) NOT NULL DEFAULT 'save',
  214. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  215. KEY idx_version_article (article_id, id)
  216. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  217. 'tags' => "CREATE TABLE tags (
  218. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  219. name VARCHAR(60) NOT NULL,
  220. slug VARCHAR(80) NOT NULL,
  221. UNIQUE KEY uniq_tag_slug (slug)
  222. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  223. 'article_tags' => "CREATE TABLE article_tags (
  224. article_id INT UNSIGNED NOT NULL,
  225. tag_id INT UNSIGNED NOT NULL,
  226. PRIMARY KEY (article_id, tag_id),
  227. KEY idx_article_tags_tag (tag_id)
  228. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  229. 'login_attempts' => "CREATE TABLE login_attempts (
  230. id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  231. scope VARCHAR(20) NOT NULL,
  232. identifier_hash CHAR(64) NOT NULL,
  233. ip_address VARCHAR(45) NOT NULL,
  234. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  235. KEY idx_attempt_identifier (scope, identifier_hash, created_at),
  236. KEY idx_attempt_ip (scope, ip_address, created_at)
  237. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  238. 'article_comments' => "CREATE TABLE article_comments (
  239. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  240. article_id INT UNSIGNED NOT NULL,
  241. parent_id INT UNSIGNED NULL,
  242. user_account_id INT UNSIGNED NULL,
  243. author_name VARCHAR(80) NOT NULL,
  244. author_email VARCHAR(190) NULL,
  245. body TEXT NOT NULL,
  246. status VARCHAR(10) NOT NULL DEFAULT 'pending',
  247. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  248. KEY idx_comments_article (article_id, status, created_at),
  249. KEY idx_comments_status (status, created_at),
  250. KEY idx_comments_parent (parent_id)
  251. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  252. 'indexnow_log' => "CREATE TABLE indexnow_log (
  253. article_id INT UNSIGNED NOT NULL PRIMARY KEY,
  254. submitted_at DATETIME NOT NULL
  255. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  256. 'clean_urls' => "CREATE TABLE clean_urls (
  257. slug VARCHAR(191) NOT NULL PRIMARY KEY,
  258. entity_type VARCHAR(10) NOT NULL,
  259. entity_id INT UNSIGNED NOT NULL,
  260. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  261. UNIQUE KEY uniq_clean_entity (entity_type, entity_id)
  262. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  263. 'site_languages' => "CREATE TABLE site_languages (
  264. code VARCHAR(10) NOT NULL PRIMARY KEY,
  265. name VARCHAR(60) NOT NULL,
  266. sort_order INT NOT NULL DEFAULT 0,
  267. is_active TINYINT(1) NOT NULL DEFAULT 0
  268. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  269. 'content_translations' => "CREATE TABLE content_translations (
  270. id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  271. entity_type VARCHAR(12) NOT NULL,
  272. entity_id INT UNSIGNED NOT NULL,
  273. lang VARCHAR(10) NOT NULL,
  274. title VARCHAR(255) NOT NULL DEFAULT '',
  275. excerpt TEXT NULL,
  276. content_blocks LONGTEXT NULL,
  277. seo_title VARCHAR(255) NULL,
  278. seo_description VARCHAR(500) NULL,
  279. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  280. UNIQUE KEY uniq_translation (entity_type, entity_id, lang),
  281. KEY idx_translation_lang (lang, entity_type)
  282. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
  283. ];
  284. ​
  285. $tableExistsCheck = $pdo->prepare(
  286. 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table'
  287. );
  288. ​
  289. foreach ($requiredTables as $table => $createSql) {
  290. $tableExistsCheck->execute(['db' => $dbName, 'table' => $table]);
  291. if ((int) $tableExistsCheck->fetchColumn() === 0) {
  292. try {
  293. $pdo->exec($createSql);
  294. } catch (PDOException $e) {
  295. error_log('Publisium: could not auto-create table ' . $table . ': ' . $e->getMessage());
  296. }
  297. }
  298. }
  299. ​
  300. self::detachContentFromAccounts($pdo, (string) $dbName);
  301. } catch (PDOException $e) {
  302. error_log('Publisium: schema auto-check failed: ' . $e->getMessage());
  303. }
  304. }
  305. ​
  306. // Content must survive account deletion: owner columns become nullable (NULL = system) and
  307. // their foreign keys switch from ON DELETE CASCADE to ON DELETE SET NULL.
  308. private static function detachContentFromAccounts(PDO $pdo, string $dbName): void
  309. {
  310. $ownerColumns = [
  311. 'articles' => 'author_id',
  312. 'pages' => 'author_id',
  313. 'ads' => 'created_by',
  314. 'article_revisions' => 'editor_id',
  315. ];
  316. ​
  317. $foreignKeys = $pdo->prepare(
  318. "SELECT k.TABLE_NAME, k.COLUMN_NAME, k.CONSTRAINT_NAME, r.DELETE_RULE
  319. FROM information_schema.KEY_COLUMN_USAGE k
  320. JOIN information_schema.REFERENTIAL_CONSTRAINTS r
  321. ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
  322. WHERE k.TABLE_SCHEMA = :db AND k.REFERENCED_TABLE_NAME = 'team_accounts'"
  323. );
  324. $foreignKeys->execute(['db' => $dbName]);
  325. $keysByColumn = [];
  326. foreach ($foreignKeys->fetchAll() as $foreignKey) {
  327. $keysByColumn[$foreignKey['TABLE_NAME'] . '.' . $foreignKey['COLUMN_NAME']][] = $foreignKey;
  328. }
  329. ​
  330. $columnInfo = $pdo->prepare(
  331. 'SELECT IS_NULLABLE, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table AND COLUMN_NAME = :column'
  332. );
  333. ​
  334. foreach ($ownerColumns as $table => $column) {
  335. $columnInfo->execute(['db' => $dbName, 'table' => $table, 'column' => $column]);
  336. $info = $columnInfo->fetch();
  337. if (!$info) {
  338. continue;
  339. }
  340. ​
  341. $keys = $keysByColumn[$table . '.' . $column] ?? [];
  342. $cascadingKeys = array_filter($keys, static fn(array $key): bool => strtoupper((string) $key['DELETE_RULE']) !== 'SET NULL');
  343. $isNullable = strtoupper((string) $info['IS_NULLABLE']) === 'YES';
  344. ​
  345. if ($isNullable && empty($cascadingKeys)) {
  346. continue;
  347. }
  348. ​
  349. $columnType = preg_match('/^[a-z]+(\(\d+\))?( unsigned)?$/i', (string) $info['COLUMN_TYPE']) === 1 ? (string) $info['COLUMN_TYPE'] : 'INT UNSIGNED';
  350. ​
  351. try {
  352. foreach ($keys as $key) {
  353. $pdo->exec('ALTER TABLE `' . $table . '` DROP FOREIGN KEY `' . str_replace('`', '', (string) $key['CONSTRAINT_NAME']) . '`');
  354. }
  355. if (!$isNullable) {
  356. $pdo->exec('ALTER TABLE `' . $table . '` MODIFY `' . $column . '` ' . $columnType . ' NULL');
  357. }
  358. if (!empty($keys)) {
  359. $constraint = str_replace('`', '', (string) $keys[0]['CONSTRAINT_NAME']);
  360. $pdo->exec('ALTER TABLE `' . $table . '` ADD CONSTRAINT `' . $constraint . '` FOREIGN KEY (`' . $column . '`) REFERENCES team_accounts(id) ON DELETE SET NULL');
  361. }
  362. } catch (PDOException $e) {
  363. error_log('Publisium: could not detach ' . $table . '.' . $column . ' from team accounts: ' . $e->getMessage());
  364. }
  365. }
  366. }
  367. }
  368. ​