25327e3302
[events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/events.php) and [admin/events.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/events.php): event image uploads. [menu.php](/Users/tyemeclifford/Documents/GH/MyKeyser/menu.php), [admin/menus.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/menus.php), and [business.php](/Users/tyemeclifford/Documents/GH/MyKeyser/business.php): one configurable menu item image slot. [account.php](/Users/tyemeclifford/Documents/GH/MyKeyser/account.php): circular avatar upload and crop controls. [forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum.php), [admin/forum.php](/Users/tyemeclifford/Documents/GH/MyKeyser/admin/forum.php), [.htaccess](/Users/tyemeclifford/Documents/GH/MyKeyser/.htaccess), and [forum/index.php](/Users/tyemeclifford/Documents/GH/MyKeyser/forum/index.php): forum, categories, topic/reply uploads, permalinks, toggle/moderation. [includes/db.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/db.php) and [includes/functions.php](/Users/tyemeclifford/Documents/GH/MyKeyser/includes/functions.php): schema upgrades and shared upload/image helpers. [assets/css/style.css](/Users/tyemeclifford/Documents/GH/MyKeyser/assets/css/style.css): neon styling for forum, images, avatars, attachments.
546 lines
28 KiB
PHP
546 lines
28 KiB
PHP
<?php
|
||
/* ── Auth ─────────────────────────────────────────────── */
|
||
function uid(): int { return (int)($_SESSION['uid'] ?? 0); }
|
||
function uname(): string{ return (string)($_SESSION['uname'] ?? ''); }
|
||
function authed(): bool { return uid() > 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($_SERVER['REQUEST_METHOD']) === 'POST'; }
|
||
|
||
/* ── CSRF ─────────────────────────────────────────────── */
|
||
function csrf(): string {
|
||
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(24));
|
||
return $_SESSION['csrf'];
|
||
}
|
||
function csrfField(): string { return '<input type="hidden" name="_csrf" value="'.e(csrf()).'">'; }
|
||
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 = '<span class="stars">';
|
||
for ($i = 1; $i <= 5; $i++) $out .= '<span class="star'.($i<=$r?' on':'').'">★</span>';
|
||
return $out.'</span>';
|
||
}
|
||
function starPicker(string $name = 'rating', int $sel = 0): string {
|
||
$out = '<div class="star-picker">';
|
||
for ($i = 5; $i >= 1; $i--) {
|
||
$chk = $sel === $i ? 'checked' : '';
|
||
$out .= '<input type="radio" id="sr'.$i.'" name="'.$name.'" value="'.$i.'" '.$chk.'>';
|
||
$out .= '<label for="sr'.$i.'">★</label>';
|
||
}
|
||
return $out.'</div>';
|
||
}
|
||
|
||
/* ── 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 storeAvatarUpload(string $field, int $maxBytes): array {
|
||
$files = normalizeUploadFiles($field);
|
||
if (!$files || ($files[0]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return ['ok'=>true, 'path'=>''];
|
||
$file = $files[0];
|
||
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 avatar exceeds the configured size limit.'];
|
||
$ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
|
||
if (!isImageExt($ext)) return ['ok'=>false, 'error'=>'Please choose a JPG, PNG, GIF, or WebP avatar.'];
|
||
|
||
if (!function_exists('getimagesize') || !function_exists('imagecreatetruecolor')) {
|
||
return storeUploadedFile($file, 'avatars', imageExts(), $maxBytes, ['image_only'=>true, 'resize_over_bytes'=>mbToBytes(1), 'image_max_width'=>512, 'image_quality'=>86]);
|
||
}
|
||
$info = @getimagesize((string)$file['tmp_name']);
|
||
if (!$info || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return ['ok'=>false, 'error'=>'The selected avatar is not a valid image.'];
|
||
$loader = match($info['mime']) {
|
||
'image/jpeg' => 'imagecreatefromjpeg',
|
||
'image/png' => 'imagecreatefrompng',
|
||
'image/gif' => 'imagecreatefromgif',
|
||
'image/webp' => 'imagecreatefromwebp',
|
||
default => '',
|
||
};
|
||
if ($loader === '' || !function_exists($loader)) return ['ok'=>false, 'error'=>'That avatar image type is not supported by this server.'];
|
||
$src = @$loader((string)$file['tmp_name']);
|
||
if (!$src) return ['ok'=>false, 'error'=>'The avatar could not be opened.'];
|
||
|
||
$w = (int)$info[0]; $h = (int)$info[1];
|
||
$size = (int)p('avatar_crop_size', min($w, $h));
|
||
$x = (int)p('avatar_crop_x', max(0, (int)(($w - $size) / 2)));
|
||
$y = (int)p('avatar_crop_y', max(0, (int)(($h - $size) / 2)));
|
||
$size = max(1, min($size, $w, $h));
|
||
$x = max(0, min($x, $w - $size));
|
||
$y = max(0, min($y, $h - $size));
|
||
|
||
$dst = imagecreatetruecolor(512, 512);
|
||
imagecopyresampled($dst, $src, 0, 0, $x, $y, 512, 512, $size, $size);
|
||
$dir = ensureUploadDir('avatars');
|
||
$filename = date('YmdHis').'-'.bin2hex(random_bytes(5)).'.jpg';
|
||
$dest = $dir.'/'.$filename;
|
||
$ok = imagejpeg($dst, $dest, 86);
|
||
imagedestroy($src);
|
||
imagedestroy($dst);
|
||
if (!$ok) return ['ok'=>false, 'error'=>'The cropped avatar could not be saved.'];
|
||
@chmod($dest, 0664);
|
||
return ['ok'=>true, 'path'=>uploadPublicPath('avatars', $filename)];
|
||
}
|
||
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 userAvatarHtml(?array $user, string $class = 'avatar-sm'): string {
|
||
$name = (string)($user['username'] ?? 'User');
|
||
$path = (string)($user['avatar_path'] ?? '');
|
||
if ($path !== '') return '<img src="'.e($path).'" alt="'.e($name).'" class="avatar '.$class.'">';
|
||
$initial = strtoupper(substr($name, 0, 1) ?: 'U');
|
||
return '<span class="avatar avatar-initial '.$class.'" aria-hidden="true">'.e($initial).'</span>';
|
||
}
|
||
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']).'/';
|
||
}
|
||
|
||
/* ── 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’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);
|
||
}
|