v2.0.0.0
Publisium
- <?php
-
- declare(strict_types=1);
-
- require_once __DIR__ . '/database.php';
- require_once __DIR__ . '/slugger.php';
-
- final class AuthorProfile
- {
- public const HEADLINE_MAX = 160;
- public const BIO_MAX = 2000;
- public const URL_MAX = 255;
- public const REASON_MAX = 500;
- public const SCHEMA_TYPES = ['Person', 'Organization'];
-
- private static ?bool $enabled = null;
-
- public static function isEnabled(): bool
- {
- if (self::$enabled !== null) {
- return self::$enabled;
- }
-
- try {
- $statement = Database::site()->prepare('SELECT setting_value FROM site_settings WHERE setting_key = :key LIMIT 1');
- $statement->execute(['key' => 'author_pages_enabled']);
- self::$enabled = $statement->fetchColumn() === '1';
- } catch (PDOException $e) {
- self::$enabled = false;
- }
-
- return self::$enabled;
- }
-
- public static function canHaveProfile(): bool
- {
- return Auth::hasRoleAtLeast(Auth::ROLE_WRITER);
- }
-
- public static function forAccount(int $accountId): ?array
- {
- $statement = Database::site()->prepare('SELECT * FROM author_profiles WHERE account_id = :account LIMIT 1');
- $statement->execute(['account' => $accountId]);
-
- return $statement->fetch() ?: null;
- }
-
- public static function findPublicBySlug(string $slug): ?array
- {
- if (preg_match('/^[a-z0-9-]{1,120}$/', $slug) !== 1) {
- return null;
- }
-
- $statement = Database::site()->prepare(
- "SELECT p.*, t.display_name, t.avatar_path
- FROM author_profiles p
- JOIN team_accounts t ON t.id = p.account_id
- WHERE p.slug = :slug AND p.is_public = 1 AND t.status = 'active'
- LIMIT 1"
- );
- $statement->execute(['slug' => $slug]);
-
- return $statement->fetch() ?: null;
- }
-
- public static function publicSummary(int $accountId): ?array
- {
- try {
- $statement = Database::site()->prepare('SELECT slug, headline FROM author_profiles WHERE account_id = :account AND is_public = 1 LIMIT 1');
- $statement->execute(['account' => $accountId]);
-
- return $statement->fetch() ?: null;
- } catch (PDOException $e) {
- return null;
- }
- }
-
- public static function schemaType(int $accountId): string
- {
- if ($accountId <= 0) {
- return 'Person';
- }
-
- try {
- $statement = Database::site()->prepare('SELECT schema_type FROM author_profiles WHERE account_id = :account LIMIT 1');
- $statement->execute(['account' => $accountId]);
- $type = (string) $statement->fetchColumn();
- } catch (PDOException $e) {
- return 'Person';
- }
-
- return in_array($type, self::SCHEMA_TYPES, true) ? $type : 'Person';
- }
-
- public static function pendingCount(): int
- {
- try {
- return (int) Database::site()->query("SELECT COUNT(*) FROM author_profiles WHERE status = 'pending'")->fetchColumn();
- } catch (PDOException $e) {
- return 0;
- }
- }
-
- public static function formValues(?array $profile): array
- {
- if ($profile === null) {
- return ['headline' => '', 'bio' => '', 'website_url' => ''];
- }
-
- return [
- 'headline' => (string) ($profile['draft_headline'] ?? $profile['headline'] ?? ''),
- 'bio' => (string) ($profile['draft_bio'] ?? $profile['bio'] ?? ''),
- 'website_url' => (string) ($profile['draft_website_url'] ?? $profile['website_url'] ?? ''),
- ];
- }
-
- public static function statusClass(string $status): string
- {
- return match ($status) {
- 'approved' => 'published',
- 'pending' => 'pending_review',
- 'rejected' => 'rejected',
- default => 'draft',
- };
- }
-
- public static function statusLabel(string $status): string
- {
- return match ($status) {
- 'approved' => 'Approved',
- 'pending' => 'Waiting for review',
- 'rejected' => 'Needs changes',
- default => 'Draft',
- };
- }
-
- public static function safeUrl(string $url): ?string
- {
- $url = trim($url);
- if ($url === '') {
- return '';
- }
-
- if (mb_strlen($url) > self::URL_MAX || filter_var($url, FILTER_VALIDATE_URL) === false) {
- return null;
- }
-
- $parts = parse_url($url);
- if ($parts === false || empty($parts['host']) || !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)) {
- return null;
- }
-
- return $url;
- }
-
- public static function sanitize(array $input): array
- {
- $headline = self::cleanLine((string) ($input['headline'] ?? ''));
- $bio = self::cleanText((string) ($input['bio'] ?? ''));
- $website = self::safeUrl((string) ($input['website_url'] ?? ''));
- $values = ['headline' => $headline, 'bio' => $bio, 'website_url' => (string) $website];
-
- if (mb_strlen($headline) > self::HEADLINE_MAX) {
- return [$values, 'The headline can be up to ' . self::HEADLINE_MAX . ' characters.'];
- }
-
- if (mb_strlen($bio) > self::BIO_MAX) {
- return [$values, 'The bio can be up to ' . self::BIO_MAX . ' characters.'];
- }
-
- if ($website === null) {
- return [$values, 'The website address should start with http:// or https://.'];
- }
-
- return [$values, null];
- }
-
- public static function save(int $accountId, string $displayName, array $input, string $intent, bool $publishDirectly): array
- {
- [$values, $error] = self::sanitize($input);
-
- if ($error === null && $intent === 'submit' && $values['bio'] === '') {
- $error = 'Add a short bio before sending it for review.';
- }
-
- if ($error !== null) {
- return [$error, 'error'];
- }
-
- $profile = self::forAccount($accountId) ?? self::create($accountId, $displayName);
- if ($profile === null) {
- return ['We couldn’t save your author page. Please try again.', 'error'];
- }
-
- $db = Database::site();
-
- if (in_array($input['schema_type'] ?? null, self::SCHEMA_TYPES, true)) {
- $db->prepare('UPDATE author_profiles SET schema_type = :type WHERE account_id = :account')
- ->execute(['type' => $input['schema_type'], 'account' => $accountId]);
- }
-
- if ($intent === 'submit' && $publishDirectly) {
- $db->prepare(
- "UPDATE author_profiles
- SET headline = :headline, bio = :bio, website_url = :website,
- draft_headline = NULL, draft_bio = NULL, draft_website_url = NULL,
- is_public = 1, status = 'approved', rejection_reason = NULL,
- reviewed_by = :reviewer, reviewed_at = NOW()
- WHERE account_id = :account"
- )->execute([
- 'headline' => $values['headline'],
- 'bio' => $values['bio'],
- 'website' => $values['website_url'],
- 'reviewer' => $accountId,
- 'account' => $accountId,
- ]);
-
- return ['Your author page is live.', 'success'];
- }
-
- $status = $intent === 'submit' ? 'pending' : 'draft';
-
- $db->prepare(
- "UPDATE author_profiles
- SET draft_headline = :headline, draft_bio = :bio, draft_website_url = :website,
- status = :status, rejection_reason = NULL
- WHERE account_id = :account"
- )->execute([
- 'headline' => $values['headline'],
- 'bio' => $values['bio'],
- 'website' => $values['website_url'],
- 'status' => $status,
- 'account' => $accountId,
- ]);
-
- return [$status === 'pending' ? 'Sent for review. An editor will take a look.' : 'Draft saved.', 'success'];
- }
-
- public static function review(int $accountId, array $input, string $decision, int $reviewerId, string $reason): array
- {
- $profile = self::forAccount($accountId);
- if ($profile === null || $profile['status'] !== 'pending') {
- return ['This author page isn’t waiting for review anymore.', 'error'];
- }
-
- $db = Database::site();
-
- if ($decision === 'approve') {
- [$values, $error] = self::sanitize($input);
-
- if ($error === null && $values['bio'] === '') {
- $error = 'The bio can’t be empty.';
- }
-
- if ($error !== null) {
- return [$error, 'error'];
- }
-
- $statement = $db->prepare(
- "UPDATE author_profiles
- SET headline = :headline, bio = :bio, website_url = :website,
- draft_headline = NULL, draft_bio = NULL, draft_website_url = NULL,
- is_public = 1, status = 'approved', rejection_reason = NULL,
- reviewed_by = :reviewer, reviewed_at = NOW()
- WHERE account_id = :account AND status = 'pending'"
- );
- $statement->execute([
- 'headline' => $values['headline'],
- 'bio' => $values['bio'],
- 'website' => $values['website_url'],
- 'reviewer' => $reviewerId,
- 'account' => $accountId,
- ]);
-
- return $statement->rowCount() === 1 ? ['Approved. The author page is now live.', 'success'] : ['Someone already reviewed this author page.', 'error'];
- }
-
- if ($decision === 'reject') {
- $reason = self::cleanLine($reason);
-
- if ($reason === '') {
- return ['Add a short note so the author knows what to change.', 'error'];
- }
-
- if (mb_strlen($reason) > self::REASON_MAX) {
- return ['The note can be up to ' . self::REASON_MAX . ' characters.', 'error'];
- }
-
- $statement = $db->prepare(
- "UPDATE author_profiles
- SET status = 'rejected', rejection_reason = :reason, reviewed_by = :reviewer, reviewed_at = NOW()
- WHERE account_id = :account AND status = 'pending'"
- );
- $statement->execute(['reason' => $reason, 'reviewer' => $reviewerId, 'account' => $accountId]);
-
- return $statement->rowCount() === 1 ? ['Sent back to the author with your note.', 'success'] : ['Someone already reviewed this author page.', 'error'];
- }
-
- return ['Something went wrong. Please try again.', 'error'];
- }
-
- private static function create(int $accountId, string $displayName): ?array
- {
- $base = substr(Slugger::make($displayName), 0, 100);
- $base = $base !== '' ? $base : 'author';
- $candidates = [$base, $base . '-2', $base . '-3', $base . '-4', $base . '-' . bin2hex(random_bytes(3))];
-
- $insert = Database::site()->prepare('INSERT INTO author_profiles (account_id, slug) VALUES (:account, :slug)');
-
- foreach ($candidates as $slug) {
- try {
- $insert->execute(['account' => $accountId, 'slug' => $slug]);
- if (class_exists('CleanUrls')) {
- CleanUrls::sync();
- }
-
- return self::forAccount($accountId);
- } catch (PDOException $e) {
- if ((string) $e->getCode() !== '23000') {
- throw $e;
- }
-
- $existing = self::forAccount($accountId);
- if ($existing !== null) {
- return $existing;
- }
- }
- }
-
- return null;
- }
-
- private static function cleanLine(string $value): string
- {
- $value = strip_tags($value);
- $value = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value);
-
- return trim((string) preg_replace('/\s+/u', ' ', $value));
- }
-
- private static function cleanText(string $value): string
- {
- $value = strip_tags(str_replace(["\r\n", "\r"], "\n", $value));
- $value = (string) preg_replace('/[\x00-\x09\x0B-\x1F\x7F]+/u', '', $value);
- $value = (string) preg_replace("/[ \t]+\n/", "\n", $value);
-
- return trim((string) preg_replace("/\n{3,}/", "\n\n", $value));
- }
- }
-