diff --git a/app/Controllers/ContactController.php b/app/Controllers/ContactController.php index b0ff36f..e31a2ad 100644 --- a/app/Controllers/ContactController.php +++ b/app/Controllers/ContactController.php @@ -1,9 +1,9 @@ Sanitizer::sanitizeString($_POST['first_name'] ?? ''), + 'last_name' => Sanitizer::sanitizeString($_POST['last_name'] ?? ''), + 'email' => Sanitizer::sanitizeString($_POST['email'] ?? ''), + 'phone' => Sanitizer::sanitizeString($_POST['phone'] ?? ''), + 'subject' => Sanitizer::sanitizeString($_POST['subject'] ?? ''), + 'message' => Sanitizer::sanitizeString($_POST['message'] ?? ''), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', + ]; - try { - $formData = [ - 'first_name' => Sanitizer::sanitizeString($_POST['first_name'] ?? ''), - 'last_name' => Sanitizer::sanitizeString($_POST['last_name'] ?? ''), - 'email' => Sanitizer::sanitizeString($_POST['email'] ?? ''), - 'phone' => Sanitizer::sanitizeString($_POST['phone'] ?? ''), - 'subject' => Sanitizer::sanitizeString($_POST['subject'] ?? ''), - 'message' => Sanitizer::sanitizeString($_POST['message'] ?? ''), - 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', - 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', - ]; + foreach ($formData as $key => $value) { + Logger::info("Sanitized input: {$key} = {$value}"); + } - foreach ($formData as $key => $value) { - Logger::info("Sanitized input: {$key} = {$value}"); - } + if ( + empty($formData['first_name']) || + empty($formData['last_name']) || + empty($formData['email']) || + empty($formData['phone']) || + empty($formData['subject']) || + empty($formData['message']) || + !Validator::isEmail($formData['email']) + ) { + Logger::info("Validation failed for contact form submission"); + $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; + SessionHelper::writeClose(); + header("Location: /#contact"); + exit; + } - // Validate required fields - if ( - empty($formData['first_name']) || - empty($formData['last_name']) || - empty($formData['email']) || - empty($formData['phone']) || - empty($formData['subject']) || - empty($formData['message']) || - !Validator::isEmail($formData['email']) - ) { - Logger::info("Validation failed for contact form submission"); + $db = Database::getConnection(); + + $evaluation = SubmissionCheck::evaluate($db, $formData['email'], $formData['phone'], $formData['ip_address']); + Logger::info("Submission evaluation result: " . json_encode($evaluation)); + + if ($evaluation['action'] === 'block') { + $_SESSION['contact_error'] = "Submission blocked due to suspicious activity. If this is a mistake, please contact us directly."; + Logger::warning("Blocked submission from IP: {$formData['ip_address']}, Reason: {$evaluation['reason']}"); + EmailHelper::alertAdmins('Blocked Submission Detected', "A submission was blocked for the following reason: {$evaluation['reason']}", $formData); + SessionHelper::writeClose(); + header("Location: /#contact"); + exit; + } + + $logId = null; + try { + $logStmt = $db->prepare("INSERT INTO submission_logs (email, phone, ip_address, user_agent, was_saved, reason) VALUES (:email, :phone, :ip, :ua, :saved, :reason)"); + $logStmt->execute([ + ':email' => $formData['email'], + ':phone' => $formData['phone'], + ':ip' => $formData['ip_address'], + ':ua' => $formData['user_agent'], + ':saved' => 0, + ':reason' => $evaluation['reason'], + ]); + $logId = $db->lastInsertId(); + } catch (\Throwable $e) { + Logger::error("Failed to insert into submission_logs: " . $e->getMessage()); + } + + $contactModel = new ContactModel($db); + $saveSuccess = $contactModel->saveContactForm($formData); + + $contactId = $db->lastInsertId(); + $verificationCode = bin2hex(random_bytes(16)); + $expiresAt = (new \DateTime('+72 hours'))->format('Y-m-d H:i:s'); + + if ($saveSuccess) { + $stmt = $db->prepare("UPDATE contact_messages SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $stmt->execute([$verificationCode, $expiresAt, $contactId]); + ContactService::sendVerificationEmail($formData['email'], $verificationCode); + } + + if ($saveSuccess && $logId) { + $update = $db->prepare("UPDATE submission_logs SET was_saved = 1 WHERE id = :id"); + $update->execute([':id' => $logId]); + } + + // Newsletter opt-in logic + if (!empty($_POST['subscribe_newsletter'])) { + Logger::info("Contact opted into newsletter: {$formData['email']}"); + NewsletterService::subscribeOrResend($formData['email']); + } + + Logger::info("✅ Writing session flag: contact_success = true"); + Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); + + SessionHelper::writeClose(); + View::render('pages/contact_check_email'); + return; + + } catch (\Throwable $e) { + Logger::error("Fatal error in ContactController::submit: " . $e->getMessage()); + EmailHelper::alertAdmins('ContactController::submit - Uncaught Exception', $e->getMessage(), $_POST ?? []); $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; - - header("Location: /?contact_error=1#contact"); - + SessionHelper::writeClose(); + Logger::info("✅ Writing session flag: catch contact_error = " . $_SESSION['contact_error']); + Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); + header("Location: /#contact"); exit; - } - - // Save to DB - $db = Database::getConnection(); - $contactModel = new ContactModel($db); - $saveSuccess = $contactModel->saveContactForm($formData); - - // Send to sales team - $emailSuccess = EmailHelper::sendContactNotification($formData); - - // Send confirmation to user - $confirmationSuccess = EmailHelper::sendConfirmationToUser($formData); - - if ($saveSuccess && $emailSuccess) { - $_SESSION['contact_success'] = true; - - } else { - Logger::error("Form processed but saveSuccess={$saveSuccess}, emailSuccess={$emailSuccess}"); - $_SESSION['contact_error'] = 'Your message was received but an internal error occurred. A confirmation may not have been sent.'; - - EmailHelper::alertAdmins('ContactController::submit - DB or email failure', 'Partial failure', $formData); - } - - if (!$confirmationSuccess) { - Logger::error("Confirmation email failed to send to user: {$formData['email']}"); - // Don't show user error — it's non-critical - } - Logger::info("✅ Writing session flag: contact_success = true"); - Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); - - header("Location: /?contact_submitted=1#contact"); - - exit; - - } catch (\Throwable $e) { - Logger::error("Fatal error in ContactController::submit: " . $e->getMessage()); - EmailHelper::alertAdmins('ContactController::submit - Uncaught Exception', $e->getMessage(), $_POST ?? []); - $_SESSION['contact_error'] = 'An internal error occurred. Please try again later.'; - - Logger::info("✅ Writing session flag: catch contact_error = " . $_SESSION['contact_error']); - Logger::info("✅ Session content before redirect: " . json_encode($_SESSION)); - header("Location: /?contact_error=2#contact"); - - exit; } } - -} diff --git a/app/Controllers/ResendVerficationController.php b/app/Controllers/ResendVerficationController.php new file mode 100644 index 0000000..1742120 --- /dev/null +++ b/app/Controllers/ResendVerficationController.php @@ -0,0 +1,104 @@ + 'Invalid email or type.']); + return; + } + + try { + $db = Database::getConnection(); + + // Rate limit: no more than 3 per day + $dailyCheck = $db->prepare("SELECT COUNT(*) FROM verification_attempts WHERE email = ? AND type = ? AND attempted_at >= NOW() - INTERVAL 1 DAY"); + $dailyCheck->execute([$email, $type]); + $dailyCount = $dailyCheck->fetchColumn(); + + if ($dailyCount >= 3) { + View::render('pages/verify_failed', ['reason' => 'You have reached the daily resend limit. Please try again tomorrow.']); + return; + } + + // Rate limit: no more than 1 every 5 minutes + $recentCheck = $db->prepare("SELECT COUNT(*) FROM verification_attempts WHERE email = ? AND type = ? AND attempted_at >= NOW() - INTERVAL 5 MINUTE"); + $recentCheck->execute([$email, $type]); + $recentCount = $recentCheck->fetchColumn(); + + if ($recentCount > 0) { + View::render('pages/verify_failed', ['reason' => 'You must wait a few minutes before requesting another verification email.']); + return; + } + + // Log attempt + $log = $db->prepare("INSERT INTO verification_attempts (email, type, attempted_at, ip_address, user_agent) VALUES (?, ?, NOW(), ?, ?)"); + $log->execute([ + $email, + $type, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + $_SERVER['HTTP_USER_AGENT'] ?? 'unknown', + ]); + + $code = bin2hex(random_bytes(16)); + $expiry = (new \DateTime())->modify('+72 hours')->format('Y-m-d H:i:s'); + + if ($type === 'newsletter') { + $stmt = $db->prepare("SELECT id, is_verified FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if (!$row || (int)$row['is_verified'] === 1) { + View::render('pages/verify_failed', ['reason' => 'Email is already verified or not found.']); + return; + } + + $update = $db->prepare("UPDATE subscribers SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $update->execute([$code, $expiry, $row['id']]); + NewsletterService::sendVerificationEmail($email, $code); + } + + if ($type === 'contact') { + $stmt = $db->prepare("SELECT id, is_verified FROM contact_messages WHERE email = ? ORDER BY created_at DESC LIMIT 1"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if (!$row || (int)$row['is_verified'] === 1) { + View::render('pages/verify_failed', ['reason' => 'Email is already verified or not found.']); + return; + } + + $update = $db->prepare("UPDATE contact_messages SET verification_code = ?, is_verified = 0, verification_expires_at = ? WHERE id = ?"); + $update->execute([$code, $expiry, $row['id']]); + ContactService::sendVerificationEmail($email, $code); + } + + View::render('pages/verify_success', [ + 'type' => $type, + 'message' => 'We just sent you a new verification link.' + ]); + } catch (\Throwable $e) { + Logger::error("Resend verification failed: " . $e->getMessage()); + View::render('pages/verify_failed', ['reason' => 'Unexpected error occurred.']); + } + } +} \ No newline at end of file diff --git a/app/Controllers/SubscriberController.php b/app/Controllers/SubscriberController.php new file mode 100644 index 0000000..6f8affb --- /dev/null +++ b/app/Controllers/SubscriberController.php @@ -0,0 +1,68 @@ + 'Missing email address.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Subscriber update failed: not found [$email]."); + View::render('pages/verify_failed', ['reason' => 'Subscriber not found.']); + return; + } + + $stmt = $db->prepare("UPDATE subscribers SET first_name = ?, last_name = ? WHERE id = ?"); + $stmt->execute([$firstName, $lastName, $subscriber['id']]); + + Logger::info("Subscriber updated: $email"); + $_SESSION['update_success'] = true; + $_SESSION['update_type'] = 'newsletter'; + + header("Location: /verify-success"); + exit; + } catch (\Throwable $e) { + Logger::error("Subscriber update error for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/verify_failed', ['reason' => 'An error occurred while updating your info.']); + } + } +} diff --git a/app/Controllers/UnsubscribeController.php b/app/Controllers/UnsubscribeController.php new file mode 100644 index 0000000..8d8d161 --- /dev/null +++ b/app/Controllers/UnsubscribeController.php @@ -0,0 +1,96 @@ + 'No email provided.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT is_verified, unsubscribed_at FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Unsubscribe: Subscriber not found [$email]"); + View::render('pages/unsubscribe_failed', ['reason' => 'Subscriber not found.']); + return; + } + + if ($subscriber['unsubscribed_at']) { + View::render('pages/unsubscribe_success', ['email' => $email, 'alreadyUnsubscribed' => true]); + return; + } + + View::render('pages/unsubscribe_confirm', ['email' => $email]); + } catch (\Throwable $e) { + Logger::error("Unsubscribe view error: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/unsubscribe_failed', ['reason' => 'An unexpected error occurred.']); + } + } + + /** + * POST /unsubscribe + * Perform the actual unsubscribe action. + */ + public function process(): void + { + try { + $email = trim($_POST['email'] ?? ''); + $reason = trim($_POST['unsubscribe_reason'] ?? ''); + + if (empty($email)) { + Logger::error("Unsubscribe form submitted without email."); + View::render('pages/unsubscribe_failed', ['reason' => 'No email address was provided.']); + return; + } + + $db = Database::getConnection(); + $stmt = $db->prepare("SELECT id FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $subscriber = $stmt->fetch(); + + if (!$subscriber) { + Logger::error("Unsubscribe: Subscriber not found during processing [$email]"); + View::render('pages/unsubscribe_failed', ['reason' => 'Subscriber not found.']); + return; + } + + $stmt = $db->prepare("UPDATE subscribers SET unsubscribed_at = NOW(), unsubscribe_reason = ? WHERE id = ?"); + $stmt->execute([$reason, $subscriber['id']]); + + Logger::info("Subscriber unsubscribed: $email"); + View::render('pages/unsubscribe_success', ['email' => $email]); + } catch (\Throwable $e) { + Logger::error("Unsubscribe processing error for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + View::render('pages/unsubscribe_failed', ['reason' => 'An error occurred while processing your unsubscribe.']); + } + } +} diff --git a/app/Controllers/VerificationController.php b/app/Controllers/VerificationController.php new file mode 100644 index 0000000..f372abc --- /dev/null +++ b/app/Controllers/VerificationController.php @@ -0,0 +1,71 @@ + 'No verification code provided.']); + return; + } + + $db = Database::getConnection(); + + // Check subscribers table + $stmt = $db->prepare("SELECT id, is_verified, email, verification_expires_at FROM subscribers WHERE verification_code = ?"); + $stmt->execute([$code]); + $subscriber = $stmt->fetch(); + + // Log verification attempt (even if failed) + $logAttempt = $db->prepare("INSERT INTO verification_attempts (email, type, attempted_at, ip_address, user_agent) VALUES (?, ?, NOW(), ?, ?)"); + $logAttempt->execute([ + $subscriber['email'] ?? '[unknown]', + 'newsletter', + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' + ]); + + if ($subscriber) { + if (!empty($subscriber['verification_expires_at']) && strtotime($subscriber['verification_expires_at']) < time()) { + View::render('pages/verify_failed', ['reason' => 'Your verification link has expired. Please request a new one.']); + return; + } + + if ((int) $subscriber['is_verified'] === 1) { + View::render('pages/verify_success', ['type' => 'newsletter', 'message' => 'This subscription has already been verified.']); + return; + } + + $update = $db->prepare("UPDATE subscribers SET is_verified = 1, verification_code = NULL WHERE id = ?"); + $update->execute([$subscriber['id']]); + + Logger::info("Subscriber verified: ID " . $subscriber['id']); + View::render('pages/verify_success', ['type' => 'newsletter']); + return; + } + + Logger::error("Invalid or expired verification code: $code"); + View::render('pages/verify_failed', ['reason' => 'Verification code is invalid or expired.']); + } catch (\Throwable $e) { + Logger::error("Verification exception: " . $e->getMessage()); + View::render('pages/verify_failed', ['reason' => 'An error occurred during verification.']); + } + } +} diff --git a/app/Core/Router.php b/app/Core/Router.php index 9f89ddd..f516352 100644 --- a/app/Core/Router.php +++ b/app/Core/Router.php @@ -3,10 +3,9 @@ * ============================================ * File: Router.php * Path: /app/Core/ - * Purpose: Core router handling HTTP method–specific route dispatching. - * Version: 1.1 + * Purpose: Core router handling HTTP method–specific route dispatching with dynamic path and closure support. + * Version: 1.3 * Author: Wizdom Networks - * Usage: Handles all GET/POST routing to controllers. * ============================================ */ @@ -22,16 +21,45 @@ class Router /** * Registers a new route. * - * @param string $path The URL path (e.g. /contact). + * @param string $path The URL path (e.g. /contact or /verify/{code}). * @param string $controller The fully qualified controller class. * @param string $method The method name in the controller. * @param string $httpMethod HTTP method (GET, POST, etc.), defaults to GET. */ public function add(string $path, string $controller, string $method, string $httpMethod = 'GET'): void { - $routeKey = strtoupper($httpMethod) . ':' . trim($path, '/'); + $normalizedPath = trim($path, '/'); + $routeKey = strtoupper($httpMethod) . ':' . $normalizedPath; + + // Convert path with {param} to regex and store original keys + $paramKeys = []; + $regexPath = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', function ($matches) use (&$paramKeys) { + $paramKeys[] = $matches[1]; + return '([^\/]+)'; + }, $normalizedPath); + + $this->routes[$routeKey] = [ + 'controller' => $controller, + 'method' => $method, + 'pattern' => "#^" . $regexPath . "$#", + 'params' => $paramKeys + ]; + Logger::debug("Registering route: [$httpMethod] $path -> $controller::$method"); - $this->routes[$routeKey] = [$controller, $method]; + } + + /** + * Registers a closure-based route. + * + * @param string $path + * @param \Closure $callback + * @param string $httpMethod + */ + public function addClosure(string $path, \Closure $callback, string $httpMethod = 'GET'): void + { + $routeKey = strtoupper($httpMethod) . ':' . trim($path, '/'); + Logger::debug("Registering closure route: [$httpMethod] $path"); + $this->routes[$routeKey] = $callback; } /** @@ -41,41 +69,61 @@ class Router */ public function dispatch($path) { - $httpMethod = $_SERVER['REQUEST_METHOD']; - $routeKey = $httpMethod . ':' . trim($path, '/'); + $httpMethod = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + $cleanPath = trim($path, '/'); + $routeKeyBase = $httpMethod . ':'; - Logger::debug("Dispatching [$httpMethod] $path"); - - if (isset($this->routes[$routeKey])) { - [$controllerName, $method] = $this->routes[$routeKey]; - Logger::debug("Matched route -> $controllerName::$method"); - - try { - if (!class_exists($controllerName)) { - throw new \Exception("Controller not found: $controllerName"); - } - - $controller = new $controllerName(); - - if (!method_exists($controller, $method)) { - throw new \Exception("Method $method not found in $controllerName"); - } - - Logger::info("Executing controller: $controllerName::$method"); - $controller->$method(); - } catch (\Throwable $e) { - echo "
"; - echo "Exception: " . $e->getMessage() . "\n"; - echo "File: " . $e->getFile() . "\n"; - echo "Line: " . $e->getLine() . "\n"; - echo "Trace:\n" . $e->getTraceAsString(); - echo ""; - exit; + foreach ($this->routes as $key => $route) { + if (strpos($key, $routeKeyBase) !== 0) { + continue; + } + + $routePattern = $route['pattern'] ?? null; + + // Handle Closure + if ($route instanceof \Closure && $key === $routeKeyBase . $cleanPath) { + Logger::info("Executing closure route: [$httpMethod] $cleanPath"); + $route(); + return; + } + + // Handle dynamic path matches + if ($routePattern && preg_match($routePattern, $cleanPath, $matches)) { + array_shift($matches); // first match is the whole path + + $params = array_combine($route['params'], $matches) ?: []; + $controllerName = $route['controller']; + $method = $route['method']; + + try { + if (!class_exists($controllerName)) { + throw new \Exception("Controller not found: $controllerName"); + } + + $controller = new $controllerName(); + + if (!method_exists($controller, $method)) { + throw new \Exception("Method $method not found in $controllerName"); + } + + Logger::info("Executing controller: $controllerName::$method with params: " . json_encode($params)); + call_user_func_array([$controller, $method], $params); + return; + + } catch (\Throwable $e) { + echo "
"; + echo "Exception: " . $e->getMessage() . "\n"; + echo "File: " . $e->getFile() . "\n"; + echo "Line: " . $e->getLine() . "\n"; + echo "Trace:\n" . $e->getTraceAsString(); + echo ""; + exit; + } } - } else { - Logger::error("Route not found: [$httpMethod] $path"); - http_response_code(404); - echo "404 Not Found"; } + + Logger::error("Route not found: [$httpMethod] $path"); + http_response_code(404); + echo "404 Not Found"; } } diff --git a/app/Core/View.php b/app/Core/View.php index 7288ef8..142ee2b 100644 --- a/app/Core/View.php +++ b/app/Core/View.php @@ -2,7 +2,7 @@ // ============================================ // File: View.php -// Version: 1.1 +// Version: 1.2 // Path: app/Core/View.php // Purpose: Handles dynamic view rendering with optional layout wrapping // Project: Wizdom Networks Website @@ -33,6 +33,10 @@ class View public static function render(string $view, array $data = [], ?string $layout = null): void { Logger::debug("Rendering view: $view"); + if (!class_exists('View')) { + class_alias(self::class, 'View'); + } + // Extract data to make variables available in the view extract($data); @@ -68,4 +72,39 @@ class View Logger::debug("Successfully rendered view: $view (no layout)"); } } + /** + * Renders a partial view without applying a layout. + * + * Use this for modular components like hero, services, faq, etc. + * Partial views must reside in: /resources/views/partials/ + * + * @param string $partial The name of the partial (e.g., 'hero', 'faq'). + * You may use dot notation for subdirectories (e.g., 'admin.nav'). + * @param array $data Optional associative array of data to be extracted into the view. + * + * @throws \Exception if the partial file does not exist. + * + * @return void + */ +public static function renderPartial(string $partial, array $data = []): void +{ + Logger::debug("Rendering partial: $partial"); + + // Convert dot notation to path and resolve to full filesystem path + $partialPath = realpath(__DIR__ . "/../../resources/views/partials/" . str_replace('.', '/', $partial) . ".php"); + + Logger::debug("Resolved partial path: $partialPath"); + + if (!$partialPath || !file_exists($partialPath)) { + Logger::error("Partial view not found: $partial | Resolved path: $partialPath"); + throw new \Exception("Partial view not found: $partial"); + } + + // Extract data and include partial + extract($data); + include $partialPath; + + Logger::debug("Successfully rendered partial: $partial"); +} + } diff --git a/app/Models/SubmissionLogModel.php b/app/Models/SubmissionLogModel.php new file mode 100644 index 0000000..1a39f8b --- /dev/null +++ b/app/Models/SubmissionLogModel.php @@ -0,0 +1,68 @@ +db = $db; + } + + /** + * Log a contact form submission attempt. + * + * @param array $data { + * @type string $email The submitted email address. + * @type string|null $phone The submitted phone number. + * @type string|null $ip_address The user's IP address. + * @type string|null $user_agent The user agent string. + * @type bool $was_saved True if saved to contact_messages. + * @type string $reason Classification reason (e.g. 'valid', 'blocked:honeypot'). + * } + * @return bool True on success, false on failure. + */ + public function logAttempt(array $data): bool + { + try { + $sql = "INSERT INTO submission_logs (email, phone, ip_address, user_agent, was_saved, reason) + VALUES (:email, :phone, :ip, :agent, :saved, :reason)"; + $stmt = $this->db->prepare($sql); + return $stmt->execute([ + ':email' => $data['email'], + ':phone' => $data['phone'] ?? null, + ':ip' => $data['ip_address'] ?? null, + ':agent' => $data['user_agent'] ?? null, + ':saved' => $data['was_saved'] ? 1 : 0, + ':reason' => $data['reason'] + ]); + } catch (\Throwable $e) { + Logger::error("Failed to log submission attempt."); + ErrorHandler::exception($e); + return false; + } + } +} diff --git a/app/Services/ContactService.php b/app/Services/ContactService.php new file mode 100644 index 0000000..e77052d --- /dev/null +++ b/app/Services/ContactService.php @@ -0,0 +1,42 @@ +Hello, +
Thank you for reaching out to Wizdom Networks. To complete your contact request, please verify your email address by clicking the link below:
+ +If you did not submit this request, you can safely ignore this message.
+– The Wizdom Networks Team
+ HTML; + + Logger::info("Sending contact verification email to: $email"); + return EmailHelper::send($email, $subject, $body); + } +} diff --git a/app/Services/NewsletterService.php b/app/Services/NewsletterService.php new file mode 100644 index 0000000..4446bf7 --- /dev/null +++ b/app/Services/NewsletterService.php @@ -0,0 +1,115 @@ +prepare("SELECT is_verified FROM subscribers WHERE email = ?"); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if ($row) { + if ((int) $row['is_verified'] === 1) { + Logger::info("Newsletter signup skipped (already verified): $email"); + return false; + } else { + Logger::info("Newsletter re-verification triggered for $email"); + // Optionally regenerate and resend code here + } + } + + $verificationCode = bin2hex(random_bytes(16)); + + if ($row) { + // Update existing unverified entry + $stmt = $db->prepare("UPDATE subscribers SET verification_code = ?, ip_address = ?, user_agent = ?, created_at = NOW() WHERE email = ?"); + $stmt->execute([$verificationCode, $ip, $userAgent, $email]); + } else { + // Insert new record + $stmt = $db->prepare(" + INSERT INTO subscribers (email, verification_code, is_verified, ip_address, user_agent, created_at) + VALUES (?, ?, 0, ?, ?, NOW()) + "); + $stmt->execute([$email, $verificationCode, $ip, $userAgent]); + } + + Logger::info("Newsletter subscription initiated for $email, verification code generated."); + + return self::sendVerificationEmail($email, $verificationCode); + } catch (\Throwable $e) { + Logger::error("Newsletter subscription failed for $email: " . $e->getMessage()); + ErrorHandler::exception($e); + return false; + } + } + + /** + * Sends the newsletter verification email. + * + * @param string $email + * @param string $code + * @return bool True if sent successfully, false otherwise + */ + private static function sendVerificationEmail(string $email, string $code): bool + { + try { + $verifyUrl = $_ENV['BASE_URL'] . "/verify?code=" . urlencode($code); + + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $_ENV['SMTP_HOST']; + $mail->SMTPAuth = true; + $mail->Username = $_ENV['SMTP_USER']; + $mail->Password = $_ENV['SMTP_PASS']; + $mail->SMTPSecure = $_ENV['SMTP_SECURE'] ?? 'tls'; + $mail->Port = $_ENV['SMTP_PORT'] ?? 587; + + $mail->setFrom($_ENV['MAIL_FROM'], $_ENV['MAIL_FROM_NAME']); + $mail->addAddress($email); + $mail->Subject = 'Confirm your subscription to Wizdom Networks'; + $mail->isHTML(true); + $mail->Body = " +Thank you for subscribing to the Wizdom Networks newsletter!
+Please click the link below to confirm your subscription:
+ +If you did not request this, you can safely ignore this email.
+ "; + + $mail->send(); + Logger::info("Verification email sent to $email"); + return true; + } catch (MailException $e) { + Logger::error("Failed to send verification email to $email: " . $e->getMessage()); + ErrorHandler::exception($e); + return false; + } + } +} diff --git a/app/Utilities/EmailHelper.php b/app/Utilities/EmailHelper.php index bb9e136..7ec3b39 100644 --- a/app/Utilities/EmailHelper.php +++ b/app/Utilities/EmailHelper.php @@ -1,7 +1,7 @@ addAddress($to); + $mail->Subject = $subject; + $mail->Body = $body; + $mail->isHTML(true); + + return $mail->send(); + } catch (\Throwable $e) { + Logger::error("Email send failed to $to: " . $e->getMessage()); + return false; + } +} + + private static function buildContactHtmlBody(array $data): string { return " diff --git a/app/Utilities/SessionHelper.php b/app/Utilities/SessionHelper.php index b956a8b..00bce6e 100644 --- a/app/Utilities/SessionHelper.php +++ b/app/Utilities/SessionHelper.php @@ -1,7 +1,7 @@ = NOW() - INTERVAL :days DAY) AS email_hits, + (SELECT COUNT(*) FROM submission_logs WHERE phone = :phone AND created_at >= NOW() - INTERVAL :days DAY) AS phone_hits, + (SELECT COUNT(*) FROM submission_logs WHERE ip_address = :ip AND created_at >= NOW() - INTERVAL :days DAY) AS ip_hits, + (SELECT COUNT(*) FROM submission_logs WHERE ip_address = :ip AND created_at >= NOW() - INTERVAL 1 HOUR) AS ip_hourly + "; - $sql = "SELECT $timestampField FROM $table WHERE $emailField = :email ORDER BY $timestampField DESC LIMIT 1"; - $stmt = $pdo->prepare($sql); - $stmt->execute(['email' => $email]); + $stmt = $pdo->prepare($query); + $stmt->bindValue(':email', $email); + $stmt->bindValue(':phone', $phone); + $stmt->bindValue(':ip', $ip); + $stmt->bindValue(':days', self::LOOKBACK_DAYS, PDO::PARAM_INT); + $stmt->execute(); + $data = $stmt->fetch(PDO::FETCH_ASSOC); - $row = $stmt->fetch(PDO::FETCH_ASSOC); + $emailHits = (int)($data['email_hits'] ?? 0); + $phoneHits = (int)($data['phone_hits'] ?? 0); + $ipHits = (int)($data['ip_hits'] ?? 0); + $ipHourly = (int)($data['ip_hourly'] ?? 0); - if ($row && isset($row[$timestampField])) { - $last = new \DateTime($row[$timestampField]); - $cutoff = (new \DateTime())->modify("-{$days} days"); + $totalScore = $emailHits + $phoneHits + $ipHits; - if ($last >= $cutoff) { - return ['submitted_at' => $last->format('Y-m-d H:i:s')]; + if ($emailHits >= 4 || $phoneHits >= 4 || $ipHits >= 5) { + return ['action' => 'block', 'reason' => 'IP/email/phone threshold exceeded', 'count' => $totalScore]; + } + if ($ipHourly >= 3) { + return ['action' => 'flag', 'reason' => 'Multiple submissions from IP in last hour', 'count' => $ipHourly]; + } + if ($totalScore >= 6) { + return ['action' => 'notify', 'reason' => 'Cumulative signal from all identifiers', 'count' => $totalScore]; + } + if ($emailHits >= 2 || $phoneHits >= 2 || $ipHits >= 2) { + return ['action' => 'flag', 'reason' => 'Repeated pattern detected', 'count' => $totalScore]; } - } - return null; + return ['action' => 'accept', 'reason' => 'accepted', 'count' => $totalScore]; + + } catch (\Throwable $e) { + Logger::error("SubmissionCheck evaluation failed: " . $e->getMessage()); + return ['action' => 'error', 'reason' => 'Evaluation error', 'count' => 0]; + } } } diff --git a/public/assets/images b/public/assets/images new file mode 120000 index 0000000..6aa9f65 --- /dev/null +++ b/public/assets/images @@ -0,0 +1 @@ +/var/www/html/dev.wizdom.ca/public/assets/img \ No newline at end of file diff --git a/public/assets/img/person/output_webp/sa-media-office.webp b/public/assets/img/person/output_webp/sa-media-office.webp new file mode 100644 index 0000000..0962a72 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-media-office.webp differ diff --git a/public/assets/img/person/output_webp/sa-polka-dot.webp b/public/assets/img/person/output_webp/sa-polka-dot.webp new file mode 100644 index 0000000..1335416 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-polka-dot.webp differ diff --git a/public/assets/img/person/output_webp/sa-shadows.webp b/public/assets/img/person/output_webp/sa-shadows.webp new file mode 100644 index 0000000..597ec51 Binary files /dev/null and b/public/assets/img/person/output_webp/sa-shadows.webp differ diff --git a/public/assets/img/person/output_webp/sb-linkedin2.webp b/public/assets/img/person/output_webp/sb-linkedin2.webp new file mode 100644 index 0000000..b48d9d1 Binary files /dev/null and b/public/assets/img/person/output_webp/sb-linkedin2.webp differ diff --git a/public/assets/img/person/sa-media-office.jpg b/public/assets/img/person/sa-media-office.jpg new file mode 100644 index 0000000..ef62b29 Binary files /dev/null and b/public/assets/img/person/sa-media-office.jpg differ diff --git a/public/assets/img/person/sa-media-office.webp b/public/assets/img/person/sa-media-office.webp new file mode 100644 index 0000000..0962a72 Binary files /dev/null and b/public/assets/img/person/sa-media-office.webp differ diff --git a/public/assets/img/person/sa-polka-dot.jpg b/public/assets/img/person/sa-polka-dot.jpg new file mode 100644 index 0000000..c722bce Binary files /dev/null and b/public/assets/img/person/sa-polka-dot.jpg differ diff --git a/public/assets/img/person/sa-polka-dot.webp b/public/assets/img/person/sa-polka-dot.webp new file mode 100644 index 0000000..1335416 Binary files /dev/null and b/public/assets/img/person/sa-polka-dot.webp differ diff --git a/public/assets/img/person/sa-shadows.webp b/public/assets/img/person/sa-shadows.webp new file mode 100644 index 0000000..597ec51 Binary files /dev/null and b/public/assets/img/person/sa-shadows.webp differ diff --git a/public/assets/img/person/sb-linkedin2.jpg b/public/assets/img/person/sb-linkedin2.jpg new file mode 100644 index 0000000..eff6625 Binary files /dev/null and b/public/assets/img/person/sb-linkedin2.jpg differ diff --git a/public/assets/img/person/sb-linkedin2.webp b/public/assets/img/person/sb-linkedin2.webp new file mode 100644 index 0000000..b48d9d1 Binary files /dev/null and b/public/assets/img/person/sb-linkedin2.webp differ diff --git a/public/assets/img/testimonials/Dionne.jpg b/public/assets/img/testimonials/Dionne.jpg new file mode 100644 index 0000000..9ef36a9 Binary files /dev/null and b/public/assets/img/testimonials/Dionne.jpg differ diff --git a/public/assets/img/testimonials/Dionne.webp b/public/assets/img/testimonials/Dionne.webp new file mode 100644 index 0000000..ecfa27d Binary files /dev/null and b/public/assets/img/testimonials/Dionne.webp differ diff --git a/public/assets/img/testimonials/dionne-bowers.webp b/public/assets/img/testimonials/dionne-bowers.webp new file mode 100644 index 0000000..cc47c80 Binary files /dev/null and b/public/assets/img/testimonials/dionne-bowers.webp differ diff --git a/public/assets/img/testimonials/richard-bailey.webp b/public/assets/img/testimonials/richard-bailey.webp new file mode 100644 index 0000000..271a714 Binary files /dev/null and b/public/assets/img/testimonials/richard-bailey.webp differ diff --git a/public/assets/img/testimonials/sheldon-williams.webp b/public/assets/img/testimonials/sheldon-williams.webp new file mode 100644 index 0000000..978e72e Binary files /dev/null and b/public/assets/img/testimonials/sheldon-williams.webp differ diff --git a/public/assets/js/contact-form.js b/public/assets/js/contact-form.js index 994359e..fffed69 100644 --- a/public/assets/js/contact-form.js +++ b/public/assets/js/contact-form.js @@ -1,26 +1,30 @@ +// File: public/assets/js/contact-form.js +// Version: 1.2 +// Purpose: Handles JS-based form submission with feedback and duplicate prevention + + document.addEventListener('DOMContentLoaded', function () { - const params = new URLSearchParams(window.location.search); - if (params.get('contact_submitted') || params.get('contact_error')) { - const contactSection = document.getElementById('contact'); - if (contactSection) { - contactSection.scrollIntoView({ behavior: 'smooth' }); - } - } - }); -document.addEventListener('DOMContentLoaded', function () { + const params = new URLSearchParams(window.location.search); + if (params.get('contact_submitted') || params.get('contact_error')) { + const contactSection = document.getElementById('contact'); + if (contactSection) { + contactSection.scrollIntoView({ behavior: 'smooth' }); + } + } + const form = document.querySelector('.php-email-form'); if (!form) return; + const submitButton = form.querySelector('button[type="submit"]'); + const loading = document.createElement('div'); const errorMsg = document.createElement('div'); const successMsg = document.createElement('div'); loading.className = 'loading mt-3'; loading.textContent = 'Sending message...'; - errorMsg.className = 'error-message mt-3'; errorMsg.style.display = 'none'; - successMsg.className = 'sent-message mt-3'; successMsg.style.display = 'none'; @@ -33,6 +37,9 @@ document.addEventListener('DOMContentLoaded', function () { form.addEventListener('submit', async function (e) { e.preventDefault(); + if (submitButton.disabled) return; // prevent double click + submitButton.disabled = true; + loading.style.display = 'block'; errorMsg.style.display = 'none'; successMsg.style.display = 'none'; @@ -47,6 +54,7 @@ document.addEventListener('DOMContentLoaded', function () { const result = await response.json(); loading.style.display = 'none'; + submitButton.disabled = false; if (response.ok && result.success) { successMsg.textContent = result.message || 'Your message was sent successfully.'; @@ -57,9 +65,17 @@ document.addEventListener('DOMContentLoaded', function () { } } catch (err) { loading.style.display = 'none'; + submitButton.disabled = false; errorMsg.textContent = err.message || 'An unexpected error occurred.'; errorMsg.style.display = 'block'; } }); }); + + window.prefillService = function (serviceName) { + const subjectField = document.getElementById('subject'); + if (subjectField) { + subjectField.value = `Inquiry about ${serviceName}`; + } + }; \ No newline at end of file diff --git a/public/index.php b/public/index.php index afaeb51..ab08efe 100644 --- a/public/index.php +++ b/public/index.php @@ -1,53 +1,89 @@ load(); -// Start session before any router logic +// ------------------------------------------------------------ +// Core Utilities & Session Boot +// ------------------------------------------------------------ use WizdomNetworks\WizeWeb\Utilities\SessionHelper; -SessionHelper::start(); - -// Import core system utilities -use WizdomNetworks\WizeWeb\Core\Router; use WizdomNetworks\WizeWeb\Utilities\Logger; use WizdomNetworks\WizeWeb\Utilities\ErrorHandler; +use WizdomNetworks\WizeWeb\Core\Router; +use WizdomNetworks\WizeWeb\Core\View; -// Import controllers use WizdomNetworks\WizeWeb\Controllers\LandingController; use WizdomNetworks\WizeWeb\Controllers\ContactController; +use WizdomNetworks\WizeWeb\Controllers\VerificationController; +use WizdomNetworks\WizeWeb\Controllers\ResendVerificationController; +use WizdomNetworks\WizeWeb\Controllers\SubscriberController; +use WizdomNetworks\WizeWeb\Controllers\UnsubscribeController; -// Boot the application +// Start session before routing +SessionHelper::start(); Logger::info("Bootstrapping application"); -// Initialize router and define routes +// ------------------------------------------------------------ +// Route Registration +// ------------------------------------------------------------ $router = new Router(); -// Landing page routes +// Landing Page Routes $router->add('', LandingController::class, 'index'); $router->add('/', LandingController::class, 'index'); $router->add('index.php', LandingController::class, 'index'); -// Contact form routes +// Contact Form Routes $router->add('/contact', ContactController::class, 'index', 'GET'); $router->add('/contact', ContactController::class, 'submit', 'POST'); -// Dispatch the incoming request +// Verification Routes +$router->add('/verify', VerificationController::class, 'verify', 'GET'); // e.g. /verify?email=... +$router->add('/verify/{code}', ContactController::class, 'verify', 'GET'); // e.g. /verify/abc123... + +// Resend / Newsletter / Unsubscribe +$router->add('/resend-verification', ResendVerificationController::class, 'handle', 'POST'); +$router->add('/subscriber/update', SubscriberController::class, 'update', 'POST'); +$router->add('/unsubscribe', UnsubscribeController::class, 'confirm', 'GET'); +$router->add('/unsubscribe', UnsubscribeController::class, 'process', 'POST'); + +// Post-Redirect-GET success confirmation +$router->addClosure('/verify-success', function () { + $type = $_SESSION['update_type'] ?? 'newsletter'; + $message = $_SESSION['update_success'] ? 'Thank you! Your information has been updated.' : null; + + View::render('pages/verify_success', [ + 'type' => $type, + 'message' => $message, + ]); + + unset($_SESSION['update_success'], $_SESSION['update_type']); +}, 'GET'); + +// ------------------------------------------------------------ +// Dispatch Request +// ------------------------------------------------------------ $requestedPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $router->dispatch($requestedPath); diff --git a/resources/views/layouts/arsha.php b/resources/views/layouts/arsha.php index b9b37db..eb269c7 100644 --- a/resources/views/layouts/arsha.php +++ b/resources/views/layouts/arsha.php @@ -1,75 +1,24 @@ - - - -