WebOrbiton
v2.0.0.0

Publisium

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