0; } function isAdmin(): bool{ return !empty($_SESSION['admin']); } function requireLogin(string $back = ''): void { if (!authed()) { flash('Please log in to continue.', 'warning'); go('/login.php?next='.urlencode($back ?: $_SERVER['REQUEST_URI'])); } } function requireAdmin(): void { if (!isAdmin()) { flash('Access denied.', 'error'); go('/index.php'); } } function loginUser(array $u): void { session_regenerate_id(true); $_SESSION['uid'] = $u['id']; $_SESSION['uname'] = $u['username']; $_SESSION['admin'] = (bool)$u['is_admin']; qrun("UPDATE users SET last_login=datetime('now') WHERE id=?", [$u['id']]); } function logoutUser(): void { session_unset(); session_destroy(); session_start(); session_regenerate_id(true); } /* ── Flash ────────────────────────────────────────────── */ function flash(string $msg, string $type = 'info'): void { $_SESSION['_flash'][] = [$type, $msg]; } function getFlashes(): array { $f = $_SESSION['_flash'] ?? []; unset($_SESSION['_flash']); return $f; } /* ── Output ───────────────────────────────────────────── */ function e(mixed $v): string { return htmlspecialchars((string)($v ?? ''), ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); } function go(string $url): never { header('Location:'.$url); exit; } /* ── Input ────────────────────────────────────────────── */ function p(string $k, mixed $d = ''): mixed { return $_POST[$k] ?? $d; } function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; } function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); } function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); } function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); } function isPost(): bool { return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST'; } /* ── CSRF ─────────────────────────────────────────────── */ function csrf(): string { if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24)); return $_SESSION['csrf']; } function csrfField(): string { return ''; } function csrfCheck(): void { if (!hash_equals(csrf(), p('_csrf', ''))) { http_response_code(403); die('Invalid CSRF token.'); } } /* ── Settings ─────────────────────────────────────────── */ function setting(string $k, string $def = ''): string { $v = qval("SELECT value FROM settings WHERE key=?", [$k]); return ($v !== false && $v !== null) ? (string)$v : $def; } function setSetting(string $k, string $v): void { qrun("INSERT INTO settings(key,value)VALUES(?,?)ON CONFLICT(key)DO UPDATE SET value=excluded.value", [$k,$v]); } /* ── Ownership ────────────────────────────────────────── */ function owns(int $bizId): bool { if (isAdmin()) return true; if (!authed()) return false; return (bool)qval("SELECT 1 FROM business_owners WHERE user_id=? AND business_id=?", [uid(),$bizId]); } /* ── Stars ────────────────────────────────────────────── */ function starDisplay(float $avg): string { $r = (int)round($avg); $out = ''; for ($i = 1; $i <= 5; $i++) $out .= ''; return $out.''; } function starPicker(string $name = 'rating', int $sel = 0): string { $out = '
'; for ($i = 5; $i >= 1; $i--) { $chk = $sel === $i ? 'checked' : ''; $out .= ''; $out .= ''; } return $out.'
'; } /* ── Date / Money ─────────────────────────────────────── */ function ago(mixed $dt): string { if ($dt === null || $dt === '' || $dt === false) return ''; $ts = is_int($dt) ? $dt : @strtotime((string)$dt); if (!$ts) return ''; $d = time() - $ts; if ($d < 60) return 'Just now'; if ($d < 3600) return floor($d / 60).'m ago'; if ($d < 86400) return floor($d / 3600).'h ago'; if ($d < 604800) return floor($d / 86400).'d ago'; return date('M j, Y', $ts); } function fdate(mixed $dt, string $fmt = 'M j, Y'): string { if ($dt === null || $dt === '' || $dt === false) return ''; $ts = is_int($dt) ? $dt : @strtotime((string)$dt); return $ts ? date($fmt, $ts) : ''; } function money(float $n): string { return '$'.number_format($n, 2); } /* ── Misc ─────────────────────────────────────────────── */ function alertIcon(mixed $t): string { return match((string)($t ?? '')) { 'news' => '📰', 'event' => '🎉', 'warning' => '⚠️', 'sale' => '🏷️', default => 'ℹ️', }; } function parseHours(mixed $json): array { $s = (string)($json ?? ''); if ($s === '' || $s === '[]' || $s === 'null') return []; $decoded = json_decode($s, true); if (!is_array($decoded)) return []; // Must be an object (associative), not a plain list foreach (array_keys($decoded) as $key) { if (!is_string($key)) return []; } return $decoded; } function makeSlug(string $s): string { return trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($s)), '-'); } function uniqueSlug(string $base): string { $slug = makeSlug($base); $i = 0; while (qval("SELECT id FROM businesses WHERE slug=?", [$slug.($i ? "-$i" : "")])) $i++; return $slug . ($i ? "-$i" : ""); } function businessVideoProvider(string $url): string { $host = strtolower((string)(parse_url(trim($url), PHP_URL_HOST) ?? '')); $host = preg_replace('/^www\./', '', $host); if ($host === 'youtu.be' || $host === 'youtube.com' || str_ends_with($host, '.youtube.com') || $host === 'youtube-nocookie.com' || str_ends_with($host, '.youtube-nocookie.com')) return 'YouTube'; if ($host === 'vimeo.com' || str_ends_with($host, '.vimeo.com')) return 'Vimeo'; return ''; } function validBusinessVideoUrl(string $url): bool { $url = trim($url); if ($url === '') return true; $scheme = strtolower((string)(parse_url($url, PHP_URL_SCHEME) ?? '')); return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== ''; } /* ── Uploads / Images ─────────────────────────────────── */ function uploadRoot(): string { return dirname(__DIR__).'/uploads'; } function uploadPublicPath(string $subdir, string $filename): string { return '/uploads/'.trim($subdir, '/').'/'.$filename; } function cleanUploadSubdir(string $subdir): string { $subdir = trim(str_replace('\\', '/', $subdir), '/'); return preg_replace('/[^a-zA-Z0-9_\/-]+/', '', $subdir) ?: 'misc'; } function ensureUploadDir(string $subdir): string { $subdir = cleanUploadSubdir($subdir); $dir = uploadRoot().'/'.$subdir; if (!is_dir($dir)) mkdir($dir, 0775, true); return $dir; } function mbToBytes(mixed $mb, float $default = 5): int { $n = (float)$mb; if ($n <= 0) $n = $default; return max(1, (int)round($n * 1024 * 1024)); } function uploadAllowedExts(string $csv, array $fallback): array { $raw = array_filter(array_map('trim', explode(',', strtolower($csv)))); $exts = $raw ?: $fallback; return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $exts))); } function imageExts(): array { return ['jpg','jpeg','png','gif','webp']; } function isImageExt(string $ext): bool { return in_array(strtolower($ext), imageExts(), true); } function isImagePath(string $path): bool { return isImageExt(strtolower(pathinfo(parse_url($path, PHP_URL_PATH) ?: $path, PATHINFO_EXTENSION))); } function uploadErrorText(int $code): string { return match($code) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded file is too large.', UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially received.', UPLOAD_ERR_NO_TMP_DIR => 'The server is missing a temporary upload directory.', UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded file.', UPLOAD_ERR_EXTENSION => 'A server extension stopped the upload.', default => 'The file could not be uploaded.', }; } function resizeImageFile(string $path, int $thresholdBytes, int $maxWidth = 1600, int $quality = 82): bool { if ($thresholdBytes <= 0 || !file_exists($path) || filesize($path) <= $thresholdBytes) return false; if (!function_exists('getimagesize') || !function_exists('imagecreatetruecolor')) return false; $info = @getimagesize($path); if (!$info || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false; [$w, $h] = $info; $mime = $info['mime']; $loader = match($mime) { 'image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng', 'image/gif' => 'imagecreatefromgif', 'image/webp' => 'imagecreatefromwebp', default => '', }; if ($loader === '' || !function_exists($loader)) return false; $src = @$loader($path); if (!$src) return false; $scale = min(1, $maxWidth / max($w, $h)); $nw = max(1, (int)round($w * $scale)); $nh = max(1, (int)round($h * $scale)); $dst = imagecreatetruecolor($nw, $nh); if ($mime === 'image/png' || $mime === 'image/webp') { imagealphablending($dst, false); imagesavealpha($dst, true); $transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127); imagefilledrectangle($dst, 0, 0, $nw, $nh, $transparent); } imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h); $quality = max(40, min(95, $quality)); $ok = match($mime) { 'image/jpeg' => imagejpeg($dst, $path, $quality), 'image/png' => imagepng($dst, $path, 6), 'image/gif' => imagegif($dst, $path), 'image/webp' => function_exists('imagewebp') ? imagewebp($dst, $path, $quality) : false, default => false, }; imagedestroy($src); imagedestroy($dst); if ($ok) @chmod($path, 0664); return (bool)$ok; } function normalizeUploadFiles(string $field): array { if (empty($_FILES[$field])) return []; $file = $_FILES[$field]; if (!is_array($file['name'])) return [$file]; $out = []; foreach ($file['name'] as $i => $name) { $out[] = [ 'name' => $name, 'type' => $file['type'][$i] ?? '', 'tmp_name' => $file['tmp_name'][$i] ?? '', 'error' => $file['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $file['size'][$i] ?? 0, ]; } return $out; } function storeUploadedFile(array $file, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return ['ok'=>true, 'path'=>'']; if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) return ['ok'=>false, 'error'=>uploadErrorText((int)$file['error'])]; if ((int)($file['size'] ?? 0) > $maxBytes) return ['ok'=>false, 'error'=>'The uploaded file exceeds the configured size limit.']; $original = (string)($file['name'] ?? 'upload'); $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION)); $allowedExts = array_map('strtolower', $allowedExts); if ($ext === '' || !in_array($ext, $allowedExts, true)) { return ['ok'=>false, 'error'=>'This file type is not allowed.']; } if (!empty($opts['image_only']) && !isImageExt($ext)) return ['ok'=>false, 'error'=>'Please upload an image file.']; if (!empty($opts['image_only']) && function_exists('getimagesize') && !@getimagesize((string)$file['tmp_name'])) { return ['ok'=>false, 'error'=>'The selected file is not a valid image.']; } $dir = ensureUploadDir($subdir); $filename = date('YmdHis').'-'.bin2hex(random_bytes(5)).'.'.$ext; $dest = $dir.'/'.$filename; $tmp = (string)$file['tmp_name']; $moved = is_uploaded_file($tmp) ? move_uploaded_file($tmp, $dest) : @rename($tmp, $dest); if (!$moved) return ['ok'=>false, 'error'=>'The uploaded file could not be saved.']; @chmod($dest, 0664); if (isImageExt($ext)) { resizeImageFile( $dest, (int)($opts['resize_over_bytes'] ?? 0), (int)($opts['image_max_width'] ?? 1600), (int)($opts['image_quality'] ?? 82) ); } $mime = ''; if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); if ($finfo) { $mime = (string)finfo_file($finfo, $dest); finfo_close($finfo); } } return [ 'ok' => true, 'path' => uploadPublicPath($subdir, $filename), 'name' => $original, 'mime' => $mime, 'size' => file_exists($dest) ? filesize($dest) : (int)($file['size'] ?? 0), ]; } function storeSingleUpload(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array { $files = normalizeUploadFiles($field); return $files ? storeUploadedFile($files[0], $subdir, $allowedExts, $maxBytes, $opts) : ['ok'=>true, 'path'=>'']; } function storeMultipleUploads(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array { $saved = []; foreach (normalizeUploadFiles($field) as $file) { if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue; $res = storeUploadedFile($file, $subdir, $allowedExts, $maxBytes, $opts); if (!$res['ok']) return ['ok'=>false, 'error'=>$res['error'] ?? 'Upload failed.', 'files'=>$saved]; if (!empty($res['path'])) $saved[] = $res; } return ['ok'=>true, 'files'=>$saved]; } function formatBytes(int $bytes): string { if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB'; if ($bytes >= 1024) return number_format($bytes / 1024, 1).' KB'; return $bytes.' B'; } function uniqueForumSlug(string $title, int $id): string { $base = makeSlug($title) ?: 'topic'; return $base.'-'.$id; } function forumTopicUrl(array $topic): string { return '/forum/'.rawurlencode((string)$topic['slug']).'/'; } function eventUrl(array $event): string { return '/event.php?id='.(int)($event['id'] ?? 0); } function externalHref(string $url): string { $url = trim($url); if ($url === '') return ''; if (preg_match('~^https?://~i', $url)) return $url; return 'https://'.$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 = ''.e($buttonText).''; } return ''. '
'.$introEsc.'
'. ''. '
'. '
'. '
'.$site.'
'. '

'.$titleEsc.'

'. '
'. $bodyHtml. ''.$button.'
'. '

Keyser, West Virginia - local businesses, events, and community information.

'. '
'; } 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 = '

Hi '.e($name).',

'. '

Thanks for registering with My Keyser. Please verify your email address before signing in.

'. ($isOwner ? '

After verification, your business claim will stay queued for review and our team will contact you within a couple of business days.

' : ''). '

This link expires in 24 hours.

'; 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 = '

Hi '.e($name).',

'. '

Thank you for registering as a business owner with My Keyser. Your 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.

'. '

Thank you for helping keep Keyser’s directory accurate.

'; 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 = '

Hi '.e($name).',

'. '

Thank you for registering with My Keyser. Your email is verified and you can now log in.

'. '

We are glad to have you in the Keyser community.

'; 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); }