Initial commit

This commit is contained in:
Kwesi Banson Jnr
2026-04-08 05:53:02 +00:00
commit 592a161ee6
63 changed files with 4105 additions and 0 deletions

37
app/Core/Auth.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Core;
class Auth {
public static function login($user) {
session_start();
session_regenerate_id(true); // Prevents session hijacking
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['username'];
}
public static function check() {
if (session_status() === PHP_SESSION_NONE) session_start();
return isset($_SESSION['user_id']);
}
public static function user() {
return $_SESSION['user_name'] ?? null;
}
public static function logout() {
session_start();
session_destroy();
header('Location: /login');
exit;
}
public static function getBearerToken(): ?string {
$headers = $_SERVER['Authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? null;
if (!$headers && function_exists('apache_request_headers')) {
$req = apache_request_headers();
$headers = $req['Authorization'] ?? $req['authorization'] ?? null;
}
return ($headers && preg_match('/Bearer\s(\S+)/', $headers, $matches)) ? $matches[1] : null;
}
}
?>

51
app/Core/Config.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace App\Core;
class Config {
private static $instance = null;
private array $settings = [];
private function __construct() {
// Map environment variables to internal keys with optional defaults
$this->settings = [
'db' => [
'host' => $_ENV['DB_HOST'] ?? 'localhost',
'name' => $_ENV['DB_NAME'] ?? 'my_app',
'user' => $_ENV['DB_USER'] ?? 'root',
'pass' => $_ENV['DB_PASS'] ?? '',
],
'app' => [
'name' => $_ENV['APP_NAME'] ?? 'Vanilla PHP App',
'env' => $_ENV['APP_ENV'] ?? 'production',
'debug' => ($_ENV['APP_DEBUG'] ?? 'false') === 'true',
'url' => $_ENV['APP_URL'] ?? 'http://localhost',
],
'mail' => [
'host' => $_ENV['MAIL_HOST'] ?? 'smtp.mailtrap.io',
'port' => $_ENV['MAIL_PORT'] ?? 2525,
]
];
}
public static function get(string $key, $default = null) {
if (self::$instance === null) {
self::$instance = new self();
}
// Support dot notation (e.g., 'db.host')
$keys = explode('.', $key);
$value = self::$instance->settings;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
}
?>

87
app/Core/Controller.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Core;
abstract class Controller {
/**
* Helper to render a view file with data
*/
protected function renderOld(string $view, array $data = []) {
// Find the view file in the app/Views folder
$viewFile = __DIR__ . "/../Views/{$view}.phtml";
if (!file_exists($viewFile)) {
die("View file $view not found.");
}
// Extract data array into local variables for the view
extract($data);
// Start output buffering to capture the template content
ob_start();
include $viewFile;
echo ob_get_clean();
}
protected function render(string $view, array $data = []) {
extract($data);
// 1. Render the specific page view into a variable
ob_start();
include __DIR__ . "/../Views/{$view}.phtml";
$content = ob_get_clean();
// 2. Render the main layout and pass the $content into it
include __DIR__ . "/../Views/layouts/main.phtml";
}
protected function json(array $data, int $code = 200) {
// Set header to JSON
header('Content-Type: application/json; charset=utf-8');
// Set HTTP status code (200, 201, 400, 404, etc.)
http_response_code($code);
// Output the data
echo json_encode($data);
exit; // End execution to prevent accidental HTML output
}
protected function getNetwork($msisdn){
$phone = str_replace(' ', '', $msisdn);
$phone = str_replace('+', '', $msisdn);
$pattern = "/^\+?(265|0)?[89]\d{8}$/i";
$retval = preg_match($pattern, $phone, $matches, PREG_OFFSET_CAPTURE);
if (count($matches) < 1) {
return false;
}
elseif (strlen($phone) == 9) {
$msisdn = "265" . $phone;
}
elseif (strlen($phone) == 10) {
$phone = ltrim($phone, 0);
$msisdn = "265" . $phone;
}
elseif (strlen($phone) == 12) {
$msisdn = $phone;
}
if (!isset($msisdn)) {
return FALSE;
}
$prefix = substr($msisdn, 0, 4);
if ($prefix == '2658') {
$network = 'tnm';
}
elseif ($prefix == '2659' ) {
$network = 'airtel';
}
else{
return FALSE;
}
return $network;
}
}
?>

17
app/Core/Csrf.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Core;
class Csrf {
public static function generate() {
Session::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
public static function verify($token) {
Session::start();
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
}

33
app/Core/Database.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Core;
use PDO;
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$config = require __DIR__ . '/../Config/database.php';
$dsn = "mysql:host={$config['host']};dbname={$config['db']};charset=utf8mb4";
$this->connection = new PDO($dsn, $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
}
public static function getInstance() {
if (!self::$instance) {
self::$instance = new self();
}
return self::$instance->connection;
}
}
// Usage in a Model:
// $db = \App\Core\Database::getInstance();
// $stmt = $db->query("SELECT * FROM users");
?>

60
app/Core/Logger.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
namespace App\Core;
class Logger {
private string $logFile;
private int $maxSize = 5242880;
private int $maxFiles = 5;
public function __construct(string $filename = 'app.log') {
// Place logs in storage/logs/ at the project root
$this->logFile = __DIR__ . "/../../storage/logs/" . $filename;
}
public function log(string $message, string $level = 'INFO'): void {
$this->checkRotation();
$timestamp = date('Y-m-d H:i:s');
$formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL;
file_put_contents($this->logFile, $formattedMessage, FILE_APPEND | LOCK_EX);
}
private function checkRotation(): void {
if (file_exists($this->logFile) && filesize($this->logFile) >= $this->maxSize) {
$this->rotate();
}
}
private function rotate(): void {
// Delete the oldest file if it exists (e.g., app.log.5)
$oldestFile = $this->logFile . '.' . $this->maxFiles;
if (file_exists($oldestFile)) {
unlink($oldestFile);
}
// Shift existing files up (4 becomes 5, 3 becomes 4, etc.)
for ($i = $this->maxFiles - 1; $i >= 1; $i--) {
$currentFile = $this->logFile . '.' . $i;
$nextFile = $this->logFile . '.' . ($i + 1);
if (file_exists($currentFile)) {
rename($currentFile, $nextFile);
}
}
// Move the main log file to .1
rename($this->logFile, $this->logFile . '.1');
// Create a new empty log file
touch($this->logFile);
chmod($this->logFile, 0664);
}
// Shorthand helpers
public function info($msg) { $this->log($msg, 'INFO'); }
public function error($msg) { $this->log($msg, 'ERROR'); }
}
?>

33
app/Core/Model.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Core;
abstract class Model {
protected static $table;
protected static function builder() {
$pdo = Database::getInstance();
return (new QueryBuilder($pdo))->table(static::$table);
}
public static function all() {
return self::builder()->get();
}
public static function find($id) {
return self::builder()->where('id', $id)->get()[0] ?? null;
}
public static function create(array $data) {
return static::builder()->insert($data);
}
public static function updateById($id, array $data) {
return static::builder()->where('id', $id)->update($data);
}
public static function deleteById($id) {
return static::builder()->where('id', $id)->delete();
}
}
?>

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Core;
use PDO;
class OldQueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
public function where($column, $value) {
$this->where[] = "{$column} = :{$column}";
$this->params[$column] = $value;
return $this;
}
public function get() {
$sql = "SELECT * FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
// Merge update data with where parameters
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
}
?>

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Core;
use PDO;
class QueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
public function where($column, $operator, $value = null) {
if ($value === null) {
$value = $operator;
$operator = '=';
}
$paramName = 'where_' . str_replace('.', '_', $column) . '_' . count($this->params);
$this->where[] = "{$column} {$operator} :{$paramName}";
$this->params[$paramName] = $value;
return $this;
}
public function get() {
$sql = "SELECT * FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
// echo "<b>SQL:</b> " . $sql . "<br><br>";
// echo "<b>Params:</b><pre>"; print_r($this->params); echo "</pre>";
// // die();
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' AND ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
}
?>

163
app/Core/QueryBuilder.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace App\Core;
use PDO;
class QueryBuilder {
protected $pdo;
protected $table;
protected $where = [];
protected $params = [];
protected $orderBy = [];
protected $limit = null;
protected $selects = '*'; // Defaults to everything
protected $joins = [];
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function table($table) {
$this->table = $table;
return $this;
}
// Notice the new $boolean parameter (defaults to 'AND')
public function where($column, $operator, $value = null, $boolean = 'AND') {
if ($value === null) {
$value = $operator;
$operator = '=';
}
$paramName = 'where_' . str_replace('.', '_', $column) . '_' . count($this->params);
// If this is the first where clause, we don't need 'AND' or 'OR'
$prefix = empty($this->where) ? '' : strtoupper($boolean) . ' ';
$this->where[] = "{$prefix}{$column} {$operator} :{$paramName}";
$this->params[$paramName] = $value;
return $this;
}
// orWhere simply calls where() but passes 'OR'
public function orWhere($column, $operator, $value = null) {
return $this->where($column, $operator, $value, 'OR');
}
// Sort the results
public function orderBy($column, $direction = 'ASC') {
$direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
$this->orderBy[] = "{$column} {$direction}";
return $this;
}
// Limit the number of results
public function limit(int $limit) {
$this->limit = $limit;
return $this;
}
// Fetch a single record instead of an array of arrays
public function first() {
$this->limit(1);
$result = $this->get();
return $result ? $result[0] : null;
}
public function getOld() {
$sql = "SELECT * FROM {$this->table}";
// Changed implode from ' AND ' to ' ' because the prefix handles it now
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
if (!empty($this->orderBy)) {
$sql .= " ORDER BY " . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function get() {
// Use the new selects property instead of hardcoded '*'
$sql = "SELECT {$this->selects} FROM {$this->table}";
// Add joins immediately after the FROM clause
if (!empty($this->joins)) {
$sql .= " " . implode(' ', $this->joins);
}
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
if (!empty($this->orderBy)) {
$sql .= " ORDER BY " . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->params);
return $stmt->fetchAll();
}
public function insert(array $data) {
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
return $this->pdo->prepare($sql)->execute($data);
}
public function update(array $data) {
$fields = "";
foreach ($data as $key => $value) {
$fields .= "{$key} = :{$key}, ";
}
$fields = rtrim($fields, ', ');
$sql = "UPDATE {$this->table} SET {$fields}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
return $this->pdo->prepare($sql)->execute(array_merge($data, $this->params));
}
public function delete() {
$sql = "DELETE FROM {$this->table}";
if (!empty($this->where)) {
$sql .= " WHERE " . implode(' ', $this->where);
}
return $this->pdo->prepare($sql)->execute($this->params);
}
// Specify exactly which columns to return
public function select($columns) {
// Allow passing an array ['id', 'name'] or a raw string 'id, name'
$this->selects = is_array($columns) ? implode(', ', $columns) : $columns;
return $this;
}
// The main join method
public function join($table, $firstColumn, $operator, $secondColumn, $type = 'INNER') {
$this->joins[] = "{$type} JOIN {$table} ON {$firstColumn} {$operator} {$secondColumn}";
return $this;
}
// A helpful shortcut for LEFT JOINS
public function leftJoin($table, $firstColumn, $operator, $secondColumn) {
return $this->join($table, $firstColumn, $operator, $secondColumn, 'LEFT');
}
}
?>

66
app/Core/Router.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
namespace App\Core;
class Router {
protected $routes = [];
public function get($path, $callback) {
$this->routes['GET'][$path] = $callback;
}
// Registers POST routes
public function post($path, $callback) {
$this->routes['POST'][$path] = $callback;
}
public function resolve($uri, $method) {
// Strip query strings (e.g., /users?id=1 becomes /users)
// In public/index.php
$path = parse_url($uri, PHP_URL_PATH);
// var_dump($path);
$callback = $this->routes[$method][$path] ?? null;
if (!$callback) {
http_response_code(404);
// echo "404 Not Found";
$this->handleNotFound($path, $method);
return;
}
// Handle 'Controller@method' strings
if (is_string($callback)) {
[$controllerName, $methodName] = explode('@', $callback);
$controllerClass = "\\App\\Controllers\\" . $controllerName;
$controller = new $controllerClass();
$controller->$methodName();
}
}
private function handleNotFound($path, $method) {
$logger = new Logger();
$isDebug = ($_ENV['APP_DEBUG'] ?? 'false') === 'true';
// 1. Log the failure for the developer
$logMessage = "404 Not Found | Method: $method | URI: $path";
$logger->error($logMessage);
http_response_code(404);
// 2. If Debug is ON, show detailed info in the browser
if ($isDebug) {
// echo "<h1>404 Not Found (Debug Mode)</h1>";
// echo "<p><strong>Method:</strong> $method</p>";
// echo "<p><strong>Attempted URI:</strong> $path</p>";
// echo "<p><strong>Defined Routes:</strong></p><pre>";
// print_r($this->routes[$method] ?? []);
// echo "</pre>";
echo json_encode([ 'message' => 'Not Found', 'method' => $method, 'path' => $path]);
} else {
// Simple message for production
echo "404 Not Found";
}
exit;
}
}
?>

22
app/Core/Session.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace App\Core;
class Session {
public static function start() {
if (session_status() === PHP_SESSION_NONE) session_start();
}
public static function setFlash($key, $message) {
self::start();
$_SESSION['flash'][$key] = $message;
}
public static function getFlash($key) {
self::start();
$message = $_SESSION['flash'][$key] ?? null;
unset($_SESSION['flash'][$key]); // Delete after retrieval
return $message;
}
}
?>

24
app/Core/Validator.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Core;
class Validator {
public static function validate(array $data, array $rules) {
$errors = [];
foreach ($rules as $field => $rule) {
if (strpos($rule, 'required') !== false && empty($data[$field])) {
$errors[$field] = ucfirst($field) . " is required.";
}
if (strpos($rule, 'email') !== false && !filter_var($data[$field], FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "Invalid email format.";
}
if (strpos($rule, 'min:8') !== false && strlen($data[$field] ?? '') < 8) {
$errors[$field] = ucfirst($field) . " must be at least 8 characters.";
}
}
return $errors;
}
}
?>