- Email verification (regular, business owner), CLI administration

This commit is contained in:
Ty Clifford
2026-06-18 14:50:48 -04:00
parent 66f93757b6
commit cfd1fba5ab
9 changed files with 778 additions and 28 deletions
+45 -3
View File
@@ -41,6 +41,13 @@ function _hasColumn(PDO $db, string $table, string $column): bool {
}
function _ensureAppUpgrades(PDO $db): void {
if (!_hasColumn($db, 'users', 'email_verified_at')) {
$db->exec("ALTER TABLE users ADD COLUMN email_verified_at TEXT");
$db->exec("UPDATE users SET email_verified_at=datetime('now') WHERE is_active=1");
}
if (!_hasColumn($db, 'users', 'member_type')) {
$db->exec("ALTER TABLE users ADD COLUMN member_type TEXT NOT NULL DEFAULT 'member'");
}
if (!_hasColumn($db, 'businesses', 'video_url')) {
$db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''");
}
@@ -69,10 +76,27 @@ function _ensureAppUpgrades(PDO $db): void {
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_claim_requests_status ON business_claim_requests(status, created_at);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL DEFAULT 'email_verify',
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at);
");
$defaults = [
'home_random_interval' => '2hours',
'site_base_url' => '',
'mailgun_region' => 'us',
'mailgun_domain' => '',
'mailgun_api_key' => '',
'mailgun_from_name' => 'Discover Keyser WV',
'mailgun_from_email' => '',
'mailgun_send_mode' => 'html',
];
$stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)");
foreach ($defaults as $k => $v) $stmt->execute([$k, $v]);
@@ -93,6 +117,8 @@ function _schema(PDO $db): void {
password TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
email_verified_at TEXT,
member_type TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL DEFAULT(datetime('now')),
last_login TEXT
);
@@ -148,6 +174,15 @@ function _schema(PDO $db): void {
created_at TEXT NOT NULL DEFAULT(datetime('now')),
reviewed_at TEXT
);
CREATE TABLE IF NOT EXISTS email_verification_tokens(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL DEFAULT 'email_verify',
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE IF NOT EXISTS reviews(
id INTEGER PRIMARY KEY AUTOINCREMENT,
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
@@ -239,13 +274,20 @@ function _seed(PDO $db): void {
'site_tagline'=>'The Friendliest City in the U.S.A.',
'contact_email'=>'info@discoverkeyser.com',
'events_require_approval'=>'1',
'home_random_interval'=>'2hours'] as $k=>$v)
'home_random_interval'=>'2hours',
'site_base_url'=>'',
'mailgun_region'=>'us',
'mailgun_domain'=>'',
'mailgun_api_key'=>'',
'mailgun_from_name'=>'Discover Keyser WV',
'mailgun_from_email'=>'',
'mailgun_send_mode'=>'html'] as $k=>$v)
qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]);
/* Admin user — password "password123" */
$hash = password_hash('password123', PASSWORD_BCRYPT);
qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin)
VALUES('administrator','admin@discoverkeyser.com',?,1)",[$hash]);
qrun("INSERT OR IGNORE INTO users(username,email,password,is_admin,email_verified_at,member_type)
VALUES('administrator','admin@discoverkeyser.com',?,1,datetime('now'),'member')",[$hash]);
/* Categories */
foreach ([
+167
View File
@@ -149,3 +149,170 @@ function validBusinessVideoUrl(string $url): bool {
$scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? ''));
return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== '';
}
/* ── Email verification / Mailgun ─────────────────────── */
function requestBaseUrl(): string {
$configured = trim(setting('site_base_url', ''));
if ($configured !== '') return rtrim($configured, '/');
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
if ($host === '') return '';
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? '') === '443');
return ($https ? 'https' : 'http').'://'.$host;
}
function absoluteUrl(string $path): string {
$base = requestBaseUrl();
if ($base === '') return $path;
return rtrim($base, '/').'/'.ltrim($path, '/');
}
function mailgunEndpoint(): string {
$region = strtolower(setting('mailgun_region', 'us'));
$base = $region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
return $base.'/v3/'.rawurlencode(setting('mailgun_domain', '')).'/messages';
}
function mailgunConfigured(): bool {
return setting('mailgun_domain', '') !== '' && setting('mailgun_api_key', '') !== '';
}
function emailFromAddress(): string {
$from = trim(setting('mailgun_from_email', ''));
if ($from !== '') return $from;
$domain = setting('mailgun_domain', '');
return $domain !== '' ? 'postmaster@'.$domain : '';
}
function formattedFromAddress(): string {
$name = trim(setting('mailgun_from_name', 'My Keyser'));
$email = emailFromAddress();
return $name !== '' ? "$name <$email>" : $email;
}
function createEmailVerificationToken(int $userId): string {
qrun("UPDATE email_verification_tokens SET used_at=datetime('now') WHERE user_id=? AND purpose='email_verify' AND used_at IS NULL", [$userId]);
$token = bin2hex(random_bytes(32));
qrun(
"INSERT INTO email_verification_tokens(user_id,token_hash,expires_at)VALUES(?,?,datetime('now','+24 hours'))",
[$userId, hash('sha256', $token)]
);
return $token;
}
function mailgunSend(string $to, string $subject, string $text, string $html = ''): array {
if (!mailgunConfigured()) return [false, 'Mailgun domain and API key are required.'];
$from = formattedFromAddress();
if ($from === '' || !filter_var(emailFromAddress(), FILTER_VALIDATE_EMAIL)) {
return [false, 'A valid Mailgun from email is required.'];
}
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) return [false, 'Recipient email is invalid.'];
$fields = [
'from' => $from,
'to' => $to,
'subject' => $subject,
'text' => $text,
];
if (setting('mailgun_send_mode', 'html') === 'html' && $html !== '') {
$fields['html'] = $html;
}
$endpoint = mailgunEndpoint();
$apiKey = setting('mailgun_api_key', '');
if (function_exists('curl_init')) {
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $fields,
CURLOPT_USERPWD => 'api:'.$apiKey,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno) return [false, $error ?: 'Mailgun request failed.'];
if ($status < 200 || $status >= 300) return [false, 'Mailgun returned HTTP '.$status.': '.substr((string)$body, 0, 180)];
return [true, ''];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Authorization: Basic ".base64_encode('api:'.$apiKey)."\r\n".
"Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query($fields),
'timeout' => 20,
'ignore_errors' => true,
],
]);
$body = @file_get_contents($endpoint, false, $context);
$statusLine = $http_response_header[0] ?? '';
if (!preg_match('/\s(2\d\d)\s/', $statusLine)) {
return [false, trim(($statusLine ?: 'Mailgun request failed.').' '.substr((string)$body, 0, 180))];
}
return [true, ''];
}
function emailShellHtml(string $title, string $intro, string $bodyHtml, string $buttonText = '', string $buttonUrl = ''): string {
$site = e(setting('site_name', 'My Keyser'));
$titleEsc = e($title);
$introEsc = e($intro);
$button = '';
if ($buttonText !== '' && $buttonUrl !== '') {
$button = '<tr><td style="padding:22px 0 6px"><a href="'.e($buttonUrl).'" style="display:inline-block;background:linear-gradient(135deg,#ffd166,#26f4ff);color:#03050b;text-decoration:none;font-family:Arial,sans-serif;font-size:14px;font-weight:800;letter-spacing:1px;text-transform:uppercase;padding:13px 20px;border-radius:6px">'.e($buttonText).'</a></td></tr>';
}
return '<!doctype html><html><body style="margin:0;background:#03050b;color:#f2f4ff;font-family:Arial,sans-serif">'.
'<div style="display:none;max-height:0;overflow:hidden;color:transparent">'.$introEsc.'</div>'.
'<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:radial-gradient(circle at 15% 0%,rgba(38,244,255,.18),transparent 340px),radial-gradient(circle at 85% 10%,rgba(255,79,216,.14),transparent 360px),#03050b;padding:34px 14px">'.
'<tr><td align="center"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:620px;background:#0e172b;border:1px solid rgba(38,244,255,.28);border-radius:8px;overflow:hidden;box-shadow:0 18px 44px rgba(0,0,0,.55)">'.
'<tr><td style="padding:24px 26px;border-bottom:1px solid rgba(38,244,255,.18);background:linear-gradient(135deg,rgba(38,244,255,.12),rgba(255,79,216,.08),rgba(255,209,102,.08))">'.
'<div style="font-size:12px;letter-spacing:4px;color:#26f4ff;text-transform:uppercase;font-weight:700">'.$site.'</div>'.
'<h1 style="margin:10px 0 0;font-family:Georgia,serif;font-size:32px;line-height:1.1;color:#ffffff">'.$titleEsc.'</h1>'.
'</td></tr><tr><td style="padding:26px;color:#aab7cf;font-size:16px;line-height:1.75">'.
$bodyHtml.
'<table role="presentation" cellspacing="0" cellpadding="0">'.$button.'</table>'.
'<p style="margin:24px 0 0;color:#65768f;font-size:13px">Keyser, West Virginia - local businesses, events, and community information.</p>'.
'</td></tr></table></td></tr></table></body></html>';
}
function verificationEmailContent(array $user, string $verifyUrl): array {
$name = (string)($user['username'] ?? 'there');
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
$title = $isOwner ? 'Verify Your Business Owner Account' : 'Verify Your Account';
$subject = $isOwner ? 'Verify your My Keyser business owner account' : 'Verify your My Keyser account';
$text = "Hi $name,\n\n".
"Thanks for registering with My Keyser. Please verify your email address before signing in:\n\n".
"$verifyUrl\n\n".
"This link expires in 24 hours.\n\n".
($isOwner ? "After verification, your business claim will remain queued and we will contact you within a couple of business days.\n\n" : '').
"My Keyser";
$htmlBody = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thanks for registering with My Keyser. Please verify your email address before signing in.</p>'.
($isOwner ? '<p style="margin:0 0 16px">After verification, your business claim will stay queued for review and our team will contact you within a couple of business days.</p>' : '').
'<p style="margin:0;color:#65768f;font-size:13px">This link expires in 24 hours.</p>';
return [$subject, $text, emailShellHtml($title, 'Verify your My Keyser account.', $htmlBody, 'Verify Email', $verifyUrl)];
}
function registrationCompleteEmailContent(array $user): array {
$name = (string)($user['username'] ?? 'there');
$isOwner = ($user['member_type'] ?? 'member') === 'business_owner';
if ($isOwner) {
$subject = 'Thank you for claiming your My Keyser business page';
$text = "Hi $name,\n\nThank you for registering as a business owner with My Keyser.\n\nYour email is verified and you can now log in. Our team will contact you within a couple of business days for a verification call before assigning your business page.\n\nThank you for helping keep Keyser's directory accurate.\n\nMy Keyser";
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thank you for registering as a business owner with My Keyser. Your email is verified and you can now log in.</p>'.
'<p style="margin:0 0 16px">Our team will contact you within a couple of business days for a verification call before assigning your business page.</p>'.
'<p style="margin:0">Thank you for helping keep Keyser&rsquo;s directory accurate.</p>';
return [$subject, $text, emailShellHtml('Thanks, Business Owner', 'Your business owner account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
}
$subject = 'Thank you for registering with My Keyser';
$text = "Hi $name,\n\nThank you for registering with My Keyser. Your email is verified and you can now log in.\n\nWe are glad to have you in the Keyser community.\n\nMy Keyser";
$body = '<p style="margin:0 0 16px;color:#f2f4ff">Hi '.e($name).',</p>'.
'<p style="margin:0 0 16px">Thank you for registering with My Keyser. Your email is verified and you can now log in.</p>'.
'<p style="margin:0">We are glad to have you in the Keyser community.</p>';
return [$subject, $text, emailShellHtml('Thanks For Registering', 'Your My Keyser account is verified.', $body, 'Log In', absoluteUrl('/login.php'))];
}
function sendVerificationEmail(array $user, string $token): array {
$url = absoluteUrl('/verify-email.php?token='.rawurlencode($token));
[$subject, $text, $html] = verificationEmailContent($user, $url);
return mailgunSend((string)$user['email'], $subject, $text, $html);
}
function sendRegistrationCompleteEmail(array $user): array {
[$subject, $text, $html] = registrationCompleteEmailContent($user);
return mailgunSend((string)$user['email'], $subject, $text, $html);
}