WebOrbiton
v1.0.0.9

Publisium

350 lines · 12.1 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/slugger.php';
  7. ​
  8. final class AuthorProfile
  9. {
  10. public const HEADLINE_MAX = 160;
  11. public const BIO_MAX = 2000;
  12. public const URL_MAX = 255;
  13. public const REASON_MAX = 500;
  14. public const SCHEMA_TYPES = ['Person', 'Organization'];
  15. ​
  16. private static ?bool $enabled = null;
  17. ​
  18. public static function isEnabled(): bool
  19. {
  20. if (self::$enabled !== null) {
  21. return self::$enabled;
  22. }
  23. ​
  24. try {
  25. $statement = Database::site()->prepare('SELECT setting_value FROM site_settings WHERE setting_key = :key LIMIT 1');
  26. $statement->execute(['key' => 'author_pages_enabled']);
  27. self::$enabled = $statement->fetchColumn() === '1';
  28. } catch (PDOException $e) {
  29. self::$enabled = false;
  30. }
  31. ​
  32. return self::$enabled;
  33. }
  34. ​
  35. public static function canHaveProfile(): bool
  36. {
  37. return Auth::hasRoleAtLeast(Auth::ROLE_WRITER);
  38. }
  39. ​
  40. public static function forAccount(int $accountId): ?array
  41. {
  42. $statement = Database::site()->prepare('SELECT * FROM author_profiles WHERE account_id = :account LIMIT 1');
  43. $statement->execute(['account' => $accountId]);
  44. ​
  45. return $statement->fetch() ?: null;
  46. }
  47. ​
  48. public static function findPublicBySlug(string $slug): ?array
  49. {
  50. if (preg_match('/^[a-z0-9-]{1,120}$/', $slug) !== 1) {
  51. return null;
  52. }
  53. ​
  54. $statement = Database::site()->prepare(
  55. "SELECT p.*, t.display_name, t.avatar_path
  56. FROM author_profiles p
  57. JOIN team_accounts t ON t.id = p.account_id
  58. WHERE p.slug = :slug AND p.is_public = 1 AND t.status = 'active'
  59. LIMIT 1"
  60. );
  61. $statement->execute(['slug' => $slug]);
  62. ​
  63. return $statement->fetch() ?: null;
  64. }
  65. ​
  66. public static function publicSummary(int $accountId): ?array
  67. {
  68. try {
  69. $statement = Database::site()->prepare('SELECT slug, headline FROM author_profiles WHERE account_id = :account AND is_public = 1 LIMIT 1');
  70. $statement->execute(['account' => $accountId]);
  71. ​
  72. return $statement->fetch() ?: null;
  73. } catch (PDOException $e) {
  74. return null;
  75. }
  76. }
  77. ​
  78. public static function schemaType(int $accountId): string
  79. {
  80. if ($accountId <= 0) {
  81. return 'Person';
  82. }
  83. ​
  84. try {
  85. $statement = Database::site()->prepare('SELECT schema_type FROM author_profiles WHERE account_id = :account LIMIT 1');
  86. $statement->execute(['account' => $accountId]);
  87. $type = (string) $statement->fetchColumn();
  88. } catch (PDOException $e) {
  89. return 'Person';
  90. }
  91. ​
  92. return in_array($type, self::SCHEMA_TYPES, true) ? $type : 'Person';
  93. }
  94. ​
  95. public static function pendingCount(): int
  96. {
  97. try {
  98. return (int) Database::site()->query("SELECT COUNT(*) FROM author_profiles WHERE status = 'pending'")->fetchColumn();
  99. } catch (PDOException $e) {
  100. return 0;
  101. }
  102. }
  103. ​
  104. public static function formValues(?array $profile): array
  105. {
  106. if ($profile === null) {
  107. return ['headline' => '', 'bio' => '', 'website_url' => ''];
  108. }
  109. ​
  110. return [
  111. 'headline' => (string) ($profile['draft_headline'] ?? $profile['headline'] ?? ''),
  112. 'bio' => (string) ($profile['draft_bio'] ?? $profile['bio'] ?? ''),
  113. 'website_url' => (string) ($profile['draft_website_url'] ?? $profile['website_url'] ?? ''),
  114. ];
  115. }
  116. ​
  117. public static function statusClass(string $status): string
  118. {
  119. return match ($status) {
  120. 'approved' => 'published',
  121. 'pending' => 'pending_review',
  122. 'rejected' => 'rejected',
  123. default => 'draft',
  124. };
  125. }
  126. ​
  127. public static function statusLabel(string $status): string
  128. {
  129. return match ($status) {
  130. 'approved' => 'Approved',
  131. 'pending' => 'Waiting for review',
  132. 'rejected' => 'Needs changes',
  133. default => 'Draft',
  134. };
  135. }
  136. ​
  137. public static function safeUrl(string $url): ?string
  138. {
  139. $url = trim($url);
  140. if ($url === '') {
  141. return '';
  142. }
  143. ​
  144. if (mb_strlen($url) > self::URL_MAX || filter_var($url, FILTER_VALIDATE_URL) === false) {
  145. return null;
  146. }
  147. ​
  148. $parts = parse_url($url);
  149. if ($parts === false || empty($parts['host']) || !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)) {
  150. return null;
  151. }
  152. ​
  153. return $url;
  154. }
  155. ​
  156. public static function sanitize(array $input): array
  157. {
  158. $headline = self::cleanLine((string) ($input['headline'] ?? ''));
  159. $bio = self::cleanText((string) ($input['bio'] ?? ''));
  160. $website = self::safeUrl((string) ($input['website_url'] ?? ''));
  161. $values = ['headline' => $headline, 'bio' => $bio, 'website_url' => (string) $website];
  162. ​
  163. if (mb_strlen($headline) > self::HEADLINE_MAX) {
  164. return [$values, 'The headline can be up to ' . self::HEADLINE_MAX . ' characters.'];
  165. }
  166. ​
  167. if (mb_strlen($bio) > self::BIO_MAX) {
  168. return [$values, 'The bio can be up to ' . self::BIO_MAX . ' characters.'];
  169. }
  170. ​
  171. if ($website === null) {
  172. return [$values, 'The website address should start with http:// or https://.'];
  173. }
  174. ​
  175. return [$values, null];
  176. }
  177. ​
  178. public static function save(int $accountId, string $displayName, array $input, string $intent, bool $publishDirectly): array
  179. {
  180. [$values, $error] = self::sanitize($input);
  181. ​
  182. if ($error === null && $intent === 'submit' && $values['bio'] === '') {
  183. $error = 'Add a short bio before sending it for review.';
  184. }
  185. ​
  186. if ($error !== null) {
  187. return [$error, 'error'];
  188. }
  189. ​
  190. $profile = self::forAccount($accountId) ?? self::create($accountId, $displayName);
  191. if ($profile === null) {
  192. return ['We couldn’t save your author page. Please try again.', 'error'];
  193. }
  194. ​
  195. $db = Database::site();
  196. ​
  197. if (in_array($input['schema_type'] ?? null, self::SCHEMA_TYPES, true)) {
  198. $db->prepare('UPDATE author_profiles SET schema_type = :type WHERE account_id = :account')
  199. ->execute(['type' => $input['schema_type'], 'account' => $accountId]);
  200. }
  201. ​
  202. if ($intent === 'submit' && $publishDirectly) {
  203. $db->prepare(
  204. "UPDATE author_profiles
  205. SET headline = :headline, bio = :bio, website_url = :website,
  206. draft_headline = NULL, draft_bio = NULL, draft_website_url = NULL,
  207. is_public = 1, status = 'approved', rejection_reason = NULL,
  208. reviewed_by = :reviewer, reviewed_at = NOW()
  209. WHERE account_id = :account"
  210. )->execute([
  211. 'headline' => $values['headline'],
  212. 'bio' => $values['bio'],
  213. 'website' => $values['website_url'],
  214. 'reviewer' => $accountId,
  215. 'account' => $accountId,
  216. ]);
  217. ​
  218. return ['Your author page is live.', 'success'];
  219. }
  220. ​
  221. $status = $intent === 'submit' ? 'pending' : 'draft';
  222. ​
  223. $db->prepare(
  224. "UPDATE author_profiles
  225. SET draft_headline = :headline, draft_bio = :bio, draft_website_url = :website,
  226. status = :status, rejection_reason = NULL
  227. WHERE account_id = :account"
  228. )->execute([
  229. 'headline' => $values['headline'],
  230. 'bio' => $values['bio'],
  231. 'website' => $values['website_url'],
  232. 'status' => $status,
  233. 'account' => $accountId,
  234. ]);
  235. ​
  236. return [$status === 'pending' ? 'Sent for review. An editor will take a look.' : 'Draft saved.', 'success'];
  237. }
  238. ​
  239. public static function review(int $accountId, array $input, string $decision, int $reviewerId, string $reason): array
  240. {
  241. $profile = self::forAccount($accountId);
  242. if ($profile === null || $profile['status'] !== 'pending') {
  243. return ['This author page isn’t waiting for review anymore.', 'error'];
  244. }
  245. ​
  246. $db = Database::site();
  247. ​
  248. if ($decision === 'approve') {
  249. [$values, $error] = self::sanitize($input);
  250. ​
  251. if ($error === null && $values['bio'] === '') {
  252. $error = 'The bio can’t be empty.';
  253. }
  254. ​
  255. if ($error !== null) {
  256. return [$error, 'error'];
  257. }
  258. ​
  259. $statement = $db->prepare(
  260. "UPDATE author_profiles
  261. SET headline = :headline, bio = :bio, website_url = :website,
  262. draft_headline = NULL, draft_bio = NULL, draft_website_url = NULL,
  263. is_public = 1, status = 'approved', rejection_reason = NULL,
  264. reviewed_by = :reviewer, reviewed_at = NOW()
  265. WHERE account_id = :account AND status = 'pending'"
  266. );
  267. $statement->execute([
  268. 'headline' => $values['headline'],
  269. 'bio' => $values['bio'],
  270. 'website' => $values['website_url'],
  271. 'reviewer' => $reviewerId,
  272. 'account' => $accountId,
  273. ]);
  274. ​
  275. return $statement->rowCount() === 1 ? ['Approved. The author page is now live.', 'success'] : ['Someone already reviewed this author page.', 'error'];
  276. }
  277. ​
  278. if ($decision === 'reject') {
  279. $reason = self::cleanLine($reason);
  280. ​
  281. if ($reason === '') {
  282. return ['Add a short note so the author knows what to change.', 'error'];
  283. }
  284. ​
  285. if (mb_strlen($reason) > self::REASON_MAX) {
  286. return ['The note can be up to ' . self::REASON_MAX . ' characters.', 'error'];
  287. }
  288. ​
  289. $statement = $db->prepare(
  290. "UPDATE author_profiles
  291. SET status = 'rejected', rejection_reason = :reason, reviewed_by = :reviewer, reviewed_at = NOW()
  292. WHERE account_id = :account AND status = 'pending'"
  293. );
  294. $statement->execute(['reason' => $reason, 'reviewer' => $reviewerId, 'account' => $accountId]);
  295. ​
  296. return $statement->rowCount() === 1 ? ['Sent back to the author with your note.', 'success'] : ['Someone already reviewed this author page.', 'error'];
  297. }
  298. ​
  299. return ['Something went wrong. Please try again.', 'error'];
  300. }
  301. ​
  302. private static function create(int $accountId, string $displayName): ?array
  303. {
  304. $base = substr(Slugger::make($displayName), 0, 100);
  305. $base = $base !== '' ? $base : 'author';
  306. $candidates = [$base, $base . '-2', $base . '-3', $base . '-4', $base . '-' . bin2hex(random_bytes(3))];
  307. ​
  308. $insert = Database::site()->prepare('INSERT INTO author_profiles (account_id, slug) VALUES (:account, :slug)');
  309. ​
  310. foreach ($candidates as $slug) {
  311. try {
  312. $insert->execute(['account' => $accountId, 'slug' => $slug]);
  313. if (class_exists('CleanUrls')) {
  314. CleanUrls::sync();
  315. }
  316. ​
  317. return self::forAccount($accountId);
  318. } catch (PDOException $e) {
  319. if ((string) $e->getCode() !== '23000') {
  320. throw $e;
  321. }
  322. ​
  323. $existing = self::forAccount($accountId);
  324. if ($existing !== null) {
  325. return $existing;
  326. }
  327. }
  328. }
  329. ​
  330. return null;
  331. }
  332. ​
  333. private static function cleanLine(string $value): string
  334. {
  335. $value = strip_tags($value);
  336. $value = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value);
  337. ​
  338. return trim((string) preg_replace('/\s+/u', ' ', $value));
  339. }
  340. ​
  341. private static function cleanText(string $value): string
  342. {
  343. $value = strip_tags(str_replace(["\r\n", "\r"], "\n", $value));
  344. $value = (string) preg_replace('/[\x00-\x09\x0B-\x1F\x7F]+/u', '', $value);
  345. $value = (string) preg_replace("/[ \t]+\n/", "\n", $value);
  346. ​
  347. return trim((string) preg_replace("/\n{3,}/", "\n\n", $value));
  348. }
  349. }
  350. ​