diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..fdaa7c4 --- /dev/null +++ b/config/routes.php @@ -0,0 +1,20 @@ + [ + 'get' => 'NOVACONIUM/init' + ], + '/novaconium/create_admin' => [ + 'post' => 'NOVACONIUM/create_admin' + ], + '/novaconium/login' => [ + 'post' => 'NOVACONIUM/authenticate', + 'get' => 'NOVACONIUM/login' + ], + '/novaconium/dashboard' => [ + 'get' => 'NOVACONIUM/dashboard' + ], + '/novaconium/logout' => [ + 'post' => 'NOVACONIUM/logout', + 'get' => 'NOVACONIUM/logout' + ] +]; \ No newline at end of file diff --git a/controllers/authenticate.php b/controllers/authenticate.php new file mode 100644 index 0000000..1317c8e --- /dev/null +++ b/controllers/authenticate.php @@ -0,0 +1,70 @@ +get('username')) ) { + $redirect->url($url_success); + makeitso(); +} + +// Make sure Session Token is correct +if ($session->get('token') != $post->get('token')) { + $messages->addMessage('error', "Invalid Session."); + $log->error("Login Authentication - Invalid Session Token"); +} + +// Handle Username +$rawUsername = $post->get('username', null); +$cleanUsername = $v->clean($rawUsername); // Clean the input +$username = strtolower($cleanUsername); // Convert to lowercase +if (!$username) { + $messages->addMessage('error', "No Username given."); +} + +// Handle Password +$password = $v->clean($post->get('password', null)); +if ( empty($password) ) { + $messages->addMessage('error', "Password Empty."); +} + +/************************************************************************************************************* + * Query Database + ************************************************************************************************************/ + +if ($messages->count('error') === 0) { + $query = "SELECT id, username, email, password, blocked FROM users WHERE username = ? OR email = ?"; + $matched = $db->getRow($query, [$username, $username]); + if (empty($matched)) { + $messages->addMessage('error', "User or Password incorrect."); + $log->warning("Login Authentication - Login Error, user doesn't exist"); + } +} + +if ($messages->count('error') === 0) { + // Re-apply pepper + $peppered = hash_hmac('sha3-512', $password, $config['secure_key']); + + // Verify hashed password + if (!password_verify($peppered, $matched['password'])) { + $messages->addMessage('error', "User or Password incorrect."); + $log->warning("Login Authentication - Login Error, password wrong"); + } +} + +// Process Login or Redirect +if ($messages->count('error') === 0) { + $query = "SELECT groupName FROM user_groups WHERE user_id = ?"; + $groups = $db->getRow($query, [$matched['id']]); + $session->set('username', $cleanUsername); + $session->set('group', $groups['groupName']); + $redirect->url($url_success); + $log->info("Login Authentication - Login Success); +} else { + $redirect->url($url_fail); +} diff --git a/controllers/create_admin.php b/controllers/create_admin.php new file mode 100644 index 0000000..3c18637 --- /dev/null +++ b/controllers/create_admin.php @@ -0,0 +1,58 @@ +all(); + +// Check secure key +if (empty($p['secure_key']) || $p['secure_key'] !== $config['secure_key']) { + $valid = false; +} + +// Username +$name = $validate->clean($p['username']); +if (!$validate->minLength($name, 1)) { + $valid = false; +} + +// Email +if (empty($p['email'])) { + $valid = false; +} elseif (!$validate->isEmail($p['email'])) { + $valid = false; +} + +// Password +if (empty($p['password'])) { + $valid = false; +} else { + // Use pepper + Argon2id + $peppered = hash_hmac('sha3-512', $p['password'], $config['secure_key']); + $hashed_password = password_hash($peppered, PASSWORD_ARGON2ID); +} + +if ($valid) { + // Insert user + $query = <<query($query, $params); + $userid = $db->lastid(); + + // Assign admin group + $groupInsertQuery = <<query($groupInsertQuery, [$userid, 'admin']); +} + +// Always redirect at end +$redirect->url('/novaconium'); diff --git a/controllers/dashboard.php b/controllers/dashboard.php new file mode 100644 index 0000000..affb905 --- /dev/null +++ b/controllers/dashboard.php @@ -0,0 +1,10 @@ +get('username'))) { + $redirect->url('/novaconium/login'); + $messages->error('You are not loggedin'); + makeitso(); +} + +view('@novacore/dashboard', $data); \ No newline at end of file diff --git a/controllers/init.php b/controllers/init.php new file mode 100644 index 0000000..55158df --- /dev/null +++ b/controllers/init.php @@ -0,0 +1,128 @@ + false, + 'gen_key' => NULL, + 'users_created' => false, + 'empty_users' => false, + 'show_login' => false, + 'token' => $session->get('token'), + 'title' => 'Novaconium Admin' +]; + +// Check if SECURE KEY is Set in +if ($config['secure_key'] !== null && strlen($config['secure_key']) === 64) { + $data['secure_key'] = true; +} else { + $data['gen_key'] = substr(bin2hex(random_bytes(32)), 0, 64); + $log->warn('secure_key not detected'); +} + +// Check if user table exists +$query = <<query($query); + +if ($result->num_rows === 0) { + $query = <<query($query); + $data['users_created'] = true; + $log->info('Users Table Created'); + +} + +// Check Usergroup +$query = <<query($query); + +if ($result->num_rows === 0) { + $query = <<query($query); + $log->info('User_groups Table Created'); + +} + +// Check Pages Table +$query = <<query($query); + +if ($result->num_rows === 0) { + $query = <<query($query); + $log->info('Pages Table Created'); + +} + +// Check if a user exists +$result = $db->query("SELECT COUNT(*) as total FROM users"); +$row = $result->fetch_assoc(); + +if ($row['total'] < 1) { + $data['empty_users'] = true; +} else { + $log->info('Init Run complete, all sql tables exist with a user.'); + // Everything is working, send them to login page + $redirect->url('/novaconium/login'); + makeitso(); +} + +view('@novacore/init', $data); diff --git a/controllers/login.php b/controllers/login.php new file mode 100644 index 0000000..8ed6c9e --- /dev/null +++ b/controllers/login.php @@ -0,0 +1,9 @@ +get('username')) { + $redirect->url('/novaconium/dashboard'); + makeitso(); +} +view('@novacore/login'); diff --git a/controllers/logout.php b/controllers/logout.php new file mode 100644 index 0000000..eaa74ce --- /dev/null +++ b/controllers/logout.php @@ -0,0 +1,5 @@ +kill(); +$log->info("Logout - Logout Success - " . $_SERVER['REMOTE_ADDR']); +$redirect->url('/'); +makeitso(); diff --git a/docs/Logs.md b/docs/Logs.md new file mode 100644 index 0000000..30fddbe --- /dev/null +++ b/docs/Logs.md @@ -0,0 +1,19 @@ +# Logging + +You can use the logging class to output to a file. + + +use ```$log->info(The Message');```1 + +Logging levels are: +``` +'DEBUG' => 0, + 'INFO' => 1, + 'WARNING' => 2, + 'ERROR' => 3, + ]; +``` + +It's recommended that production is set to ERROR. +You set the log level in /App/config.php under 'loglevel' => 'ERROR' +If you are using CORXN a health check is run every 30 seconds which would fill the log file with info. diff --git a/docs/Messages.md b/docs/Messages.md new file mode 100644 index 0000000..d92816a --- /dev/null +++ b/docs/Messages.md @@ -0,0 +1,3 @@ +# Messages + +Messages is $messages. diff --git a/docs/Post.md b/docs/Post.md new file mode 100644 index 0000000..7e629aa --- /dev/null +++ b/docs/Post.md @@ -0,0 +1,5 @@ +# Post + +There is a post class. +It cleans the post. +You can access it with $post. diff --git a/docs/Redirect.md b/docs/Redirect.md new file mode 100644 index 0000000..855c866 --- /dev/null +++ b/docs/Redirect.md @@ -0,0 +1,6 @@ +# Redirect + +How to use redirect class. + +$redirect->url; +it's called on every page, if you set it more than once the last one is used. \ No newline at end of file diff --git a/docs/Session.md b/docs/Session.md new file mode 100644 index 0000000..836ea6e --- /dev/null +++ b/docs/Session.md @@ -0,0 +1,5 @@ +# Sessions + +There is a sessions handler built into Novaconium. + +$session diff --git a/docs/Twig-Views.md b/docs/Twig-Views.md new file mode 100644 index 0000000..b76dc57 --- /dev/null +++ b/docs/Twig-Views.md @@ -0,0 +1,21 @@ +# Twig + +## Overrides + +You can override twig templates by creating the same file in the templates directory. + +## Calling View + +There is a $data that the system uses to store arrays for twig you can save to this array: + +``` +$data['newinfo'] = 'stuff'; +view('templatename'); +``` +and that will automotically go to twig. +or you can create a new array and pass it in: + +``` +$anotherArr['newinfo'] = 'stuff'; +view('templatename',$anotherArr); +``` diff --git a/docs/twig-overrides.md b/docs/twig-overrides.md deleted file mode 100644 index 2370074..0000000 --- a/docs/twig-overrides.md +++ /dev/null @@ -1,3 +0,0 @@ -# Twig Overrides - -You can override twig templates by creating the same file in the templates directory. diff --git a/services/Auth.php b/services/Auth.php new file mode 100644 index 0000000..bd4c9a0 --- /dev/null +++ b/services/Auth.php @@ -0,0 +1,9 @@ + '', 'port' => 3306 ], - 'base_url' => 'http://localhost:8000' + 'base_url' => 'http://localhost:8000', + 'secure_key' => '', //64 alphanumeric characters + 'logfile' => '/logs/novaconium.log', + 'loglevel' => 'ERROR' // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE' ]; diff --git a/skeleton/novaconium/public/css/novaconium.css b/skeleton/novaconium/public/css/novaconium.css index 3d089c2..edc69e5 100644 --- a/skeleton/novaconium/public/css/novaconium.css +++ b/skeleton/novaconium/public/css/novaconium.css @@ -30,4 +30,18 @@ code { h2 { margin-top: 40px; +} + +div.error, div#debug { + border: 1px solid red; + padding: 30px; + background-color: pink; + color: darkred; + margin: 0 auto; + width: 900px; +} + +div#debug { + margin-top: 100px; + margin-bottom: 100px; } \ No newline at end of file diff --git a/src/Database.php b/src/Database.php index 8aef4d3..3ee849e 100644 --- a/src/Database.php +++ b/src/Database.php @@ -3,6 +3,7 @@ class Database { private $conn; + public $lastid; public function __construct($dbinfo) { $this->conn = new mysqli($dbinfo['host'], $dbinfo['user'], $dbinfo['pass'], $dbinfo['name']); @@ -13,43 +14,90 @@ class Database { } public function query($query, $params = []) { - // Prepare the SQL query - if ($stmt = $this->conn->prepare($query)) { - // Bind parameters to the prepared statement (if any) - if (!empty($params)) { - $types = str_repeat('s', count($params)); // Assuming all params are strings - $stmt->bind_param($types, ...$params); + // Clean up pending results to avoid "commands out of sync" + while ($this->conn->more_results() && $this->conn->next_result()) { + if ($res = $this->conn->use_result()) { + $res->free(); } - - // Execute the statement - if (!$stmt->execute()) { - throw new Exception("Query execution failed: " . $stmt->error); - } - - // Return the statement result - return $stmt; - } else { + } + + // Prepare the SQL statement + $stmt = $this->conn->prepare($query); + if (!$stmt) { throw new Exception("Query preparation failed: " . $this->conn->error); } + + // Bind parameters if needed + if (!empty($params)) { + $types = str_repeat('s', count($params)); // Use 's' for all types, or detect types dynamically + $stmt->bind_param($types, ...$params); + } + + // Execute the statement + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception("Query execution failed: " . $stmt->error); + } + + // Save last insert id if it's an INSERT query + if (preg_match('/^\s*INSERT/i', $query)) { + $this->lastid = $this->conn->insert_id; + } else { + $this->lastid = 0; + } + + // Decide what to return + // For SELECT/SHOW etc., return result set + if (preg_match('/^\s*(SELECT|SHOW|DESCRIBE|EXPLAIN)/i', $query)) { + $result = $stmt->get_result(); + $stmt->close(); + return $result; + } + + // For INSERT/UPDATE/DELETE, return success status + $success = $stmt->affected_rows; + $stmt->close(); + return $success; + } + + public function lastid() { + return $this->lastid; } public function getRow($query, $params = []) { try { - // Perform the query using prepared statement - $stmt = $this->query($query, $params); - - // Get the result of the query + // Prepare the SQL statement + $stmt = $this->conn->prepare($query); + if (!$stmt) { + throw new Exception("Query preparation failed: " . $this->conn->error); + } + + // Bind parameters + if (!empty($params)) { + $types = str_repeat('s', count($params)); // You may improve this with actual type detection + $stmt->bind_param($types, ...$params); + } + + // Execute the statement + if (!$stmt->execute()) { + $stmt->close(); + throw new Exception("Query execution failed: " . $stmt->error); + } + + // Get result $result = $stmt->get_result(); - - // Fetch the first row from the result - return $result->fetch_assoc(); + $row = $result->fetch_assoc(); + + $stmt->close(); + return $row; + } catch (Exception $e) { - // Handle the exception (log it, display a message, etc.) echo "An error occurred: " . $e->getMessage(); return null; } } + public function getRows($query, $params = []) { $stmt = $this->conn->prepare($query); if (!$stmt) { diff --git a/src/Logger.php b/src/Logger.php new file mode 100644 index 0000000..b648084 --- /dev/null +++ b/src/Logger.php @@ -0,0 +1,48 @@ + 0, + 'INFO' => 1, + 'WARNING' => 2, + 'ERROR' => 3, + 'NONE' => 999 + ]; + + public function __construct(string $logFile, string $minLevel = 'DEBUG') { + $this->logFile = $logFile; + $minLevel = strtoupper($minLevel); + $this->logLevelThreshold = self::LEVELS[$minLevel] ?? 0; + } + + protected function log(string $level, string $message): void { + $level = strtoupper($level); + if (!isset(self::LEVELS[$level]) || self::LEVELS[$level] < $this->logLevelThreshold) { + return; + } + + $time = date('Y-m-d H:i:s'); + $ip = $_SERVER['REMOTE_ADDR']; + $logEntry = "[$time] [$ip] [$level] $message" . PHP_EOL; + + file_put_contents($this->logFile, $logEntry, FILE_APPEND | LOCK_EX); + } + + public function debug(string $msg): void { + $this->log('DEBUG', $msg); + } + + public function info(string $msg): void { + $this->log('INFO', $msg); + } + + public function warning(string $msg): void { + $this->log('WARNING', $msg); + } + + public function error(string $msg): void { + $this->log('ERROR', $msg); + } +} diff --git a/src/MessageHandler.php b/src/MessageHandler.php index c5e2b4b..f90d90e 100644 --- a/src/MessageHandler.php +++ b/src/MessageHandler.php @@ -8,6 +8,16 @@ class MessageHandler { 'success' => [] ]; + public function __construct(array $sessionMessages = []) + { + // Merge existing session messages into the default structure + foreach ($this->messages as $type => $_) { + if (isset($sessionMessages[$type]) && is_array($sessionMessages[$type])) { + $this->messages[$type] = $sessionMessages[$type]; + } + } + } + // Add a message of a specific type public function addMessage($type, $message) { if (!isset($this->messages[$type])) { @@ -16,11 +26,22 @@ class MessageHandler { $this->messages[$type][] = $message; } + public function error($message){ + $this->addMessage('error', $message); + } + // Get all messages of a specific type public function getMessages($type) { return $this->messages[$type] ?? []; } + // Get all messages of a specific type + public function showMessages($type) { + $result = $this->messages[$type] ?? []; + $this->messages[$type] = []; // Clear messages after showing + return $result; + } + // Get all messages of all types public function getAllMessages() { return $this->messages; diff --git a/src/Redirect.php b/src/Redirect.php index 1cf3561..6acb291 100644 --- a/src/Redirect.php +++ b/src/Redirect.php @@ -2,7 +2,7 @@ /** * Use - * $redirect->to('/login'); + * $redirect->url('/login'); * to trigger a redirect */ diff --git a/src/Router.php b/src/Router.php index 31d7f88..783e8cd 100644 --- a/src/Router.php +++ b/src/Router.php @@ -19,12 +19,13 @@ class Router { } private function loadRoutes() { + require_once(FRAMEWORKPATH . '/config/routes.php'); // Check if Path exists if (file_exists(BASEPATH . '/App/routes.php')) { require_once( BASEPATH . '/App/routes.php'); - } else { - require_once(FRAMEWORKPATH . '/defaults/App/routes.php'); } + $routes = array_merge((array)$routes, (array)$framework_routes); + return $routes; } @@ -67,7 +68,9 @@ class Router { // one to one match if (array_key_exists($this->path, $this->routes)) { - return $this->routes[$this->path][$this->requestType]; + if (!empty($this->routes[$this->path][$this->requestType])) { + return $this->routes[$this->path][$this->requestType]; + } } foreach ($this->routes as $key => $value) { @@ -103,8 +106,13 @@ class Router { // checks if the file exists, sets file path private function setRouteFile() { - - $cp = BASEPATH . '/App/controllers/' . $this->controller . '.php'; + + if (str_starts_with($this->controller, 'NOVACONIUM')) { + $trimmed = substr($this->controller, strlen('NOVACONIUM/')); + $cp = FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; + } else { + $cp = BASEPATH . '/App/controllers/' . $this->controller . '.php'; + } if (file_exists($cp)) { return $cp; diff --git a/src/Session.php b/src/Session.php index ade4399..3053e29 100644 --- a/src/Session.php +++ b/src/Session.php @@ -5,8 +5,7 @@ class Session { public function __construct() { session_start(); - if (!isset($_SESSION)) { - $this->session = $_SESSION; + if (!isset($_SESSION['token'])) { $this->setToken(); $this->session['messages'] = []; } else { @@ -51,6 +50,7 @@ class Session { } public function kill() { + $_SESSION = []; session_destroy(); } diff --git a/src/functions.php b/src/functions.php index bbf12b0..54f49c9 100644 --- a/src/functions.php +++ b/src/functions.php @@ -14,7 +14,7 @@ function dd(...$vars) { } function makeitso() { - global $session, $db, $redirect, $config, $messages; + global $session, $db, $redirect, $config, $messages, $log; if (!empty($config['database']['host'])) { $db->close(); diff --git a/src/novaconium.php b/src/novaconium.php index 54e4764..a0644a6 100644 --- a/src/novaconium.php +++ b/src/novaconium.php @@ -10,19 +10,32 @@ if (file_exists(BASEPATH . '/App/config.php')) { require_once(FRAMEWORKPATH . '/defaults/App/config.php'); } +// Logging +require_once(FRAMEWORKPATH . '/src/Logger.php'); +$log = new Logger(BASEPATH . $config['logfile'], $config['loglevel']); + // Global Functions require_once(FRAMEWORKPATH . '/src/functions.php'); // Creates the view() function using twig +$data = array(); require_once(FRAMEWORKPATH . '/src/twig.php'); -// Messages -require_once(FRAMEWORKPATH . '/src/MessageHandler.php'); -$messages = new MessageHandler; - // Start a Session require_once(FRAMEWORKPATH . '/src/Session.php'); $session = new Session(); +$data['token'] = $session->get('token'); +if ($config['loglevel'] == 'DEBUG') { + $data['debug'] = nl2br(print_r($session->debug(), true)); +} + +// Messages +require_once(FRAMEWORKPATH . '/src/MessageHandler.php'); +$messages = new MessageHandler($session->flash('messages')); + +foreach (['error','notice'] as $key){ + $data[$key] = $messages->showMessages($key); +} // Load Database Class if (!empty($config['database']['host'])) { diff --git a/src/twig.php b/src/twig.php index 8f3b278..62dd4f8 100644 --- a/src/twig.php +++ b/src/twig.php @@ -1,11 +1,16 @@ addPath(BASEPATH . '/vendor/4lt/novaconium/twig', 'novaconium'); + $loader->addPath(FRAMEWORKPATH . '/twig', 'novaconium'); + $loader->addPath(FRAMEWORKPATH . '/views', 'novacore'); $loader->addPath(BASEPATH . '/App/templates', 'override'); $twig = new Twig\Environment($loader); @@ -15,7 +20,10 @@ function view($name = '', $data = []) { // Check if the template exists if (file_exists(BASEPATH . '/App/views/' . $name . '.html.twig')) { - echo $twig->render("$name.html.twig", $data); + echo $twig->render($name . '.html.twig', $data); + return true; + } elseif (str_starts_with($name, '@')) { // Check if using framework + echo $twig->render($name . '.html.twig', $data); return true; } else { echo "Error: Twig Template ($name) Not Found."; diff --git a/twig/master.html.twig b/twig/master.html.twig index 144e94a..d7f516e 100644 --- a/twig/master.html.twig +++ b/twig/master.html.twig @@ -47,6 +47,13 @@ {% include ['@override/footer.html.twig', '@novaconium/footer.html.twig'] %} {% block footerafter %}{% endblock %} + + {% if debug is not empty %} +
+

Debugging Information

+ {{ debug|raw }} +
+ {% endif %} {% include ['@override/foot.html.twig', '@novaconium/foot.html.twig'] %} diff --git a/views/dashboard.html.twig b/views/dashboard.html.twig new file mode 100644 index 0000000..89b2f2d --- /dev/null +++ b/views/dashboard.html.twig @@ -0,0 +1,9 @@ +{% extends '@novaconium/master.html.twig' %} + +{% block content %} +

{{title}}

+

Dashboard page

+

Homepage

+

logout

+ +{% endblock %} diff --git a/views/init.html.twig b/views/init.html.twig new file mode 100644 index 0000000..c2e763e --- /dev/null +++ b/views/init.html.twig @@ -0,0 +1,67 @@ +{% extends '@novaconium/master.html.twig' %} + +{% block content %} +

{{title}}

+ + {% if not secure_key %} +
+

Secure Key

+

Please set the secure_key in App/config.php to an alphanumeric code that is 64 characters in length.

+

You can generate a secure key like this:

+
pwgen -sB 64 1
+

Or use this one:

+
{{gen_key}}
+
+ {% endif %} + + {% if users_created %} +
+

Users Table Created

+

There was no users table in the database. One was created.

+
+ {% endif %} + + {% if empty_users and secure_key %} +
+

Create Admin

+

No admin users exist, make an admin user now.

+ +
+ + +
+

+ +
+

+ +
+

+ +
+

+ + +
+
+ {% endif %} + + {% if show_login %} +
+

Administrator Login

+ +
+ + +
+

+ +
+

+ + +
+
+ {% endif %} + +{% endblock %} diff --git a/views/login.html.twig b/views/login.html.twig new file mode 100644 index 0000000..3a4a969 --- /dev/null +++ b/views/login.html.twig @@ -0,0 +1,23 @@ +{% extends '@novaconium/master.html.twig' %} + +{% block content %} +

{{title}}

+ + +
+ +
+ + +
+

+ +
+

+ + +
+
+ + +{% endblock %}