5 Commits

Author SHA1 Message Date
230476e8e5 Claude redid the router 2026-06-30 01:35:55 -07:00
d5871ed8e7 minor updates 2026-06-16 19:00:30 -07:00
ad6e07c76d fixed css and changed timezone 2026-05-04 11:30:14 -07:00
06310d9eb9 Updated readme and composer 2026-05-03 13:27:30 -07:00
f679d0b20e safety fix 2026-02-07 17:26:26 -08:00
8 changed files with 270 additions and 101 deletions

View File

@@ -6,13 +6,20 @@ NovaconiumPHP is a high-performance PHP framework designed with inspiration from
Pronounced: Noh-vah-koh-nee-um Pronounced: Noh-vah-koh-nee-um
Packagist: https://packagist.org/packages/4lt/novaconium * Packagist: https://packagist.org/packages/4lt/novaconium
Master Repo: https://git.4lt.ca/4lt/novaconium * Master Repo: https://git.4lt.ca/4lt/novaconium
## Getting Started ## Getting Started
Novaconium is heavly influenced by docker, but you can use composer outside of docker. Novaconium is designed to be developed primarily using Docker. Instead of relying on tools installed directly on your system, common development tasks are executed inside containers:
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Install-Composer-On-Debian.md).
* Composer runs inside a Docker container rather than on your host machine
* Apache / PHP are served from containers instead of your local environment
* Sass compilation is also handled within a container
As long as you have Docker installed, you can use the full development environment without installing additional dependencies on your system.
You can [learn more about how novaconium works with composer](https://git.4lt.ca/4lt/novaconium/src/branch/master/docs/Composer.md).
```bash ```bash
PROJECTNAME=novaproject; PROJECTNAME=novaproject;
@@ -24,9 +31,9 @@ docker run --rm --interactive --tty --volume ./novaconium/:/app composer:latest
cp -R novaconium/vendor/4lt/novaconium/skeleton/. .; cp -R novaconium/vendor/4lt/novaconium/skeleton/. .;
# Edit .env # Edit .env
# pwgen -cnsB1v 12 root password # pwgen -cnsB1v 12 # root password
# pwgen -cnsB1v 12 mysql user password (need in both config and env) # pwgen -cnsB1v 12 # mysql user password (need in both config and env)
# pwgen -cnsB1v 64 framework key (need in config) # pwgen -cnsB1v 64 # framework key (need in config)
# Edit novaconium/App/config.php # Edit novaconium/App/config.php
docker compose up -d docker compose up -d

View File

@@ -8,7 +8,7 @@ Update novaconium with composer in docker: ```docker run --rm --interactive --tt
## Install Composer natively on Debian ## Install Composer natively on Debian
Assuming you have nala installed: Assuming you have nala installed (otherwise use apt-get):
```bash ```bash
sudo nala install curl php-cli php-mbstring git unzip sudo nala install curl php-cli php-mbstring git unzip

101
docs/Router.md Normal file
View File

@@ -0,0 +1,101 @@
# Router
`Novaconium\Router` resolves the current HTTP request to a controller file, based on a route table defined in PHP config files. It supports exact-match routes and simple parameterized routes (e.g. `/users/{id}`).
## How it works
On construction, the router runs through this pipeline:
1. **Load routes** — merges framework-level routes with app-level routes.
2. **Prepare path** — normalizes `REQUEST_URI` into a clean path string.
3. **Prepare query** — parses the query string into an array.
4. **Determine request type**`get` or `post`.
5. **Find controller** — matches the path against the route table.
6. **Resolve controller file** — turns the matched controller string into an actual file path, falling back to a 404 controller if needed.
## Properties
| Property | Type | Description |
|---|---|---|
| `$routes` | array | Combined route table (framework + app routes), keyed by path pattern. |
| `$query` | array | Parsed query string parameters. |
| `$path` | string | Normalized request path. |
| `$controller` | string | Resolved controller identifier (e.g. `Users/show` or `NOVACONIUM/Errors`). |
| `$controllerPath` | string | Absolute file path to the controller. |
| `$parameters` | array | Named parameters extracted from a parameterized route match. |
| `$requestType` | string | `'get'` or `'post'`. |
## Route table format
Routes are defined as an associative array, keyed by URL path. Each entry maps HTTP methods to a controller string:
```php
$routes = [
'/' => [
'get' => 'Home/index',
],
'/users/{id}' => [
'get' => 'Users/show',
],
'/login' => [
'get' => 'Auth/loginForm',
'post' => 'Auth/login',
],
];
```
Controller strings starting with `NOVACONIUM` are resolved against the framework's controller directory (e.g. `NOVACONIUM/Errors``FRAMEWORKPATH/controllers/Errors.php`); all other controller strings are resolved against the app's controller directory (`BASEPATH/App/controllers/`).
## Matching behavior
### 1. Exact match
The router first checks for an exact match between `$this->path` and a key in `$this->routes`. If found, and the current request type has a handler defined, that controller is returned immediately.
### 2. Parameterized match
If no exact match is found, the router scans the route table for patterns containing `{`. For each candidate:
- The **static prefix** of the pattern (everything before the first `{`) must prefix-match the current path.
- The pattern and the path must have the **same number of `/`-separated segments**.
The first route pattern satisfying both conditions is treated as the match — the router does not keep searching for a "better" match after this point, even if the matched route turns out to have no handler for the current request method (see [Known quirks](#known-quirks) below).
Once a candidate route is selected, the router walks its segments looking for the first `{param}` segment, extracts the corresponding path segment into `$this->parameters[paramName]`, and returns immediately with the controller for the current request type.
### 3. No match
If neither an exact nor a parameterized match is found, `$this->controller` is set to `'404'`, and `setRouteFile()` will fail to find a corresponding controller file, triggering the 404 fallback described below.
## Controller file resolution (`setRouteFile`)
Given the resolved `$this->controller` string:
1. If it starts with `NOVACONIUM`, look in `FRAMEWORKPATH/controllers/`.
2. Otherwise, look in `BASEPATH/App/controllers/`.
3. If the file exists, use it.
4. If not, fall back to `BASEPATH/App/controllers/404.php` if it exists.
5. Otherwise, fall back to the framework's built-in `FRAMEWORKPATH/controllers/404.php`.
## Known quirks
These behaviors exist in the current implementation and are preserved intentionally for backward compatibility — they're documented here so they aren't mistaken for bugs during future changes.
- **Only the first `{param}` in a route pattern is captured.** If a route pattern has multiple parameters (e.g. `/users/{id}/posts/{postId}`), only `id` is extracted into `$this->parameters`; `postId` is never processed, because the matching loop returns as soon as it finds the first parameter segment.
- **The first prefix+segment-count match wins, even without a method handler.** If a parameterized route's static prefix and segment count match the request, but that route has no handler defined for the current request type (e.g. the route only defines `get` but the request is `post`), the router still commits to that route and returns `null` as the controller (which then falls through to the 404 controller). It does **not** continue searching for another route that might have a valid handler.
- **No support for trailing wildcard or optional segments.** Segment counts must match exactly between the pattern and the path; there's no support for `*` or optional `{param?}` style segments.
## Debugging
Call `$router->debug()` to dump the resolved path, controller path, extracted parameters, and full route table as an HTML table, then halt execution via `die()`. Intended for development use only.
## Example
```php
$router = new \Novaconium\Router();
include $router->controllerPath;
// Inside the controller, access route parameters via:
$router->parameters['id'];
```

View File

@@ -1,12 +1,10 @@
# Sample Docker Compose # Sample Docker Compose
services: services:
corxn: corxn:
image: 4lights/corxn:6.0.0 image: 4lights/corxn:8.5.3
ports: ports:
- "8000:80" - "8000:80"
volumes: volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
- ./novaconium:/data - ./novaconium:/data
- ./data/logs:/var/log/apache2 # Optional Logs - ./data/logs:/var/log/apache2 # Optional Logs
- "./logs:/data/logs" - "./logs:/data/logs"
@@ -29,8 +27,6 @@ services:
MYSQL_USER: novaconium MYSQL_USER: novaconium
MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes: volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
- ./data/db:/var/lib/mysql - ./data/db:/var/lib/mysql
networks: networks:
- internal - internal
@@ -49,9 +45,6 @@ services:
- PMA_USER=root - PMA_USER=root
- PMA_PASSWORD=${MYSQL_ROOT_PASSWORD} - PMA_PASSWORD=${MYSQL_ROOT_PASSWORD}
- UPLOAD_LIMIT=200M - UPLOAD_LIMIT=200M
volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
networks: networks:
proxy: proxy:

View File

@@ -4,7 +4,7 @@
// ini_set('display_errors', 1); // ini_set('display_errors', 1);
// Define the base path where the website is running from // Define the base path where the website is running from
define('BASEPATH', dirname(__DIR__, 1)); define('BASEPATH', dirname(__DIR__));
// Load Composer's autoload file to handle class autoloading // Load Composer's autoload file to handle class autoloading
require BASEPATH . '/vendor/autoload.php'; require BASEPATH . '/vendor/autoload.php';

View File

@@ -1,5 +1,6 @@
<?php <?php
namespace Novaconium; namespace Novaconium;
class Router { class Router {
public $routes = []; public $routes = [];
public $query = []; public $query = [];
@@ -10,120 +11,185 @@ class Router {
public $requestType = 'get'; public $requestType = 'get';
public function __construct() { public function __construct() {
$this->routes = $this->loadRoutes(); $this->routes = $this->loadRoutes();
$this->path = $this->preparePath(); $this->path = $this->preparePath();
$this->query = $this->prepareQuery(); $this->query = $this->prepareQuery();
$this->requestType = $this->getRequestType(); $this->requestType = $this->getRequestType();
$this->controller = $this->findController(); $this->controller = $this->findController();
$this->controllerPath = $this->setRouteFile(); $this->controllerPath = $this->setRouteFile();
} }
/**
* Merge framework routes with app-defined routes (app routes can override framework routes).
*/
private function loadRoutes() { private function loadRoutes() {
require_once( \FRAMEWORKPATH . '/config/routes.php'); require_once(\FRAMEWORKPATH . '/config/routes.php');
// Check if Path exists
if (file_exists(\BASEPATH . '/App/routes.php')) {
require_once( \BASEPATH . '/App/routes.php');
}
$routes = array_merge((array)$routes, (array)$framework_routes);
return $routes; if (file_exists(\BASEPATH . '/App/routes.php')) {
require_once(\BASEPATH . '/App/routes.php');
}
return array_merge((array)$routes, (array)$framework_routes);
} }
/**
* Normalize the request path: strip query string, trailing slash, and anything after "&".
*/
private function preparePath() { private function preparePath() {
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
//homepage
if ($path === '/') { if ($path === '/') {
return $path; return $path;
} }
// remove empty directory path $path = rtrim($path, '/');
$path = rtrim($path, '/'); // remove trailing slash
//remove anything after and including ampersand
$path = preg_replace('/&.+$/', '', $path); $path = preg_replace('/&.+$/', '', $path);
return $path; return $path;
} }
private function prepareQuery() { private function prepareQuery() {
$parsedUri = parse_url($_SERVER['REQUEST_URI']); $parsedUri = parse_url($_SERVER['REQUEST_URI']);
$queryArray = []; $queryArray = [];
if (isset($parsedUri['query'])) { if (isset($parsedUri['query'])) {
parse_str($parsedUri['query'], $queryArray); parse_str($parsedUri['query'], $queryArray);
} }
return $queryArray; return $queryArray;
} }
private function getRequestType() { private function getRequestType() {
// is the requewst a get or post? return empty($_POST) ? 'get' : 'post';
if (empty($_POST)) {
return 'get';
} else {
return 'post';
}
} }
/**
* Resolve the current path to a controller string.
* First tries an exact match, then falls back to matching parameterized
* routes like "/users/{id}".
*/
private function findController() { private function findController() {
$exactMatch = $this->findExactMatch();
// one to one match if ($exactMatch !== null) {
if (array_key_exists($this->path, $this->routes)) { return $exactMatch;
if (!empty($this->routes[$this->path][$this->requestType])) {
return $this->routes[$this->path][$this->requestType];
}
} }
foreach ($this->routes as $key => $value) { $paramMatch = $this->findParameterizedMatch();
// Check if key contains a curly bracket, if not continue. We already checked above. if ($paramMatch !== null) {
if (!strpos($key, '{')) continue; return $paramMatch;
// Remove everything after the curly bracket, from key
$keyPath = substr($key, 0, strpos($key, '{'));
//see if keyPath matches the first characters of $this->path, only the first characters have to match
if (strpos($this->path, $keyPath) === 0) {
// We have a potential match. Now check if the parameter count is equal
$keyParams = explode('/', $key);
$pathParams = explode('/', $this->path);
$keyParamCount = count($keyParams);
$pathParamCount = count($pathParams);
if ($keyParamCount === $pathParamCount) {
for ($i=0; $i < $pathParamCount; $i++) {
if (strpos($keyParams[$i], '{') !== false) {
$keyParams[$i] = substr($keyParams[$i], 1, -1);
$this->parameters[$keyParams[$i]] = $pathParams[$i];
return $this->routes[$key][$this->requestType];
}
}
}
}
} }
return '404'; return '404';
} }
// checks if the file exists, sets file path private function findExactMatch() {
if (array_key_exists($this->path, $this->routes)
&& !empty($this->routes[$this->path][$this->requestType])
) {
return $this->routes[$this->path][$this->requestType];
}
return null;
}
/**
* Loop over parameterized routes (those containing "{") looking for the
* first one whose static prefix matches the path AND whose segment count
* matches. As soon as such a route is found, control is handed off to
* extractControllerFromMatch(), which returns immediately (even with a
* `null` controller) -- this mirrors the original code's behavior of
* stopping the search on the first prefix+segment-count match, rather
* than continuing on to try other candidate routes.
*/
private function findParameterizedMatch() {
foreach ($this->routes as $routePattern => $methods) {
// Only parameterized routes (containing "{") are relevant here;
// plain routes were already checked in findExactMatch().
if (strpos($routePattern, '{') === false) {
continue;
}
if (!$this->routeMatchesPath($routePattern)) {
continue;
}
if (!$this->segmentCountsMatch($routePattern)) {
continue;
}
// Original behavior: once we find a route with a matching prefix
// and segment count, we commit to it -- even if it turns out
// there's no handler for the current request method.
return $this->extractControllerFromMatch($routePattern, $methods);
}
return null;
}
/**
* Quick check: does the static portion of the route pattern (everything
* before the first "{") prefix-match the current path?
*/
private function routeMatchesPath($routePattern) {
$staticPrefix = substr($routePattern, 0, strpos($routePattern, '{'));
return strpos($this->path, $staticPrefix) === 0;
}
private function segmentCountsMatch($routePattern) {
$patternSegments = explode('/', $routePattern);
$pathSegments = explode('/', $this->path);
return count($patternSegments) === count($pathSegments);
}
/**
* Extracts named parameters into $this->parameters and returns the
* controller for the current request type (or null if that method
* isn't defined for this route).
*
* Matches the original quirk: only the FIRST "{param}" segment found
* gets added to $this->parameters, and the method returns right away --
* any additional "{param}" segments later in the pattern are never
* processed. This is preserved as-is rather than "fixed", since
* behavior should stay unchanged.
*/
private function extractControllerFromMatch($routePattern, $methods) {
$patternSegments = explode('/', $routePattern);
$pathSegments = explode('/', $this->path);
foreach ($patternSegments as $i => $segment) {
if (strpos($segment, '{') !== false) {
$paramName = substr($segment, 1, -1); // strip { }
$this->parameters[$paramName] = $pathSegments[$i];
return $methods[$this->requestType] ?? null;
}
}
return null;
}
/**
* Resolve the controller string to an actual file path, falling back to a 404 controller.
*/
private function setRouteFile() { private function setRouteFile() {
if (str_starts_with($this->controller, 'NOVACONIUM')) { if (str_starts_with($this->controller, 'NOVACONIUM')) {
$trimmed = substr($this->controller, strlen('NOVACONIUM/')); $trimmed = substr($this->controller, strlen('NOVACONIUM/'));
$cp = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php'; $controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
} else { } else {
$cp = \BASEPATH . '/App/controllers/' . $this->controller . '.php'; $controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
} }
if (file_exists($cp)) { if (file_exists($controllerPath)) {
return $cp; return $controllerPath;
} else {
//Check if 404 exits
if (file_exists( \BASEPATH . '/App/controllers/404.php')) {
return \BASEPATH . '/App/controllers/404.php';
} else {
return \FRAMEWORKPATH . '/controllers/404.php';
}
} }
if (file_exists(\BASEPATH . '/App/controllers/404.php')) {
return \BASEPATH . '/App/controllers/404.php';
}
return \FRAMEWORKPATH . '/controllers/404.php';
} }
public function debug() { public function debug() {
@@ -134,9 +200,7 @@ class Router {
echo '<tr><th>Parameters</th><td><pre>' . print_r($this->parameters, true) . '</pre></td></tr>'; echo '<tr><th>Parameters</th><td><pre>' . print_r($this->parameters, true) . '</pre></td></tr>';
echo '<tr><th>Routes</th><td><pre>' . print_r($this->routes, true) . '</pre></td></tr>'; echo '<tr><th>Routes</th><td><pre>' . print_r($this->routes, true) . '</pre></td></tr>';
echo '</table></div>'; echo '</table></div>';
die(); die();
} }
}
}

View File

@@ -1,5 +1,16 @@
<?php <?php
use Novaconium\Logger;
use Novaconium\Session;
use Novaconium\MessageHandler;
use Novaconium\Database;
use Novaconium\Post;
use Novaconium\Redirect;
use Novaconium\Router;
$db = null;
$post = null;
// --- Load Config --- // --- Load Config ---
if (file_exists(\BASEPATH . '/App/config.php')) { if (file_exists(\BASEPATH . '/App/config.php')) {
require_once \BASEPATH . '/App/config.php'; require_once \BASEPATH . '/App/config.php';
@@ -11,7 +22,6 @@ require_once \FRAMEWORKPATH . '/src/functions.php';
require_once \FRAMEWORKPATH . '/src/twig.php'; require_once \FRAMEWORKPATH . '/src/twig.php';
// --- Logging --- // --- Logging ---
use Novaconium\Logger;
$log = new Logger(\BASEPATH . $config['logfile'], $config['loglevel']); $log = new Logger(\BASEPATH . $config['logfile'], $config['loglevel']);
// --- Twig Data Array --- // --- Twig Data Array ---
@@ -21,35 +31,29 @@ $data['matomo_url'] = $config['matomo_url'] ?? '';
$data['matomo_id'] = $config['matomo_id'] ?? '0'; $data['matomo_id'] = $config['matomo_id'] ?? '0';
// --- Session --- // --- Session ---
use Novaconium\Session;
$session = new Session(); $session = new Session();
$data['token'] = $session->get('token'); $data['token'] = $session->get('token');
$data['username'] = $session->get('username'); $data['username'] = $session->get('username');
// --- Messages --- // --- Messages ---
use Novaconium\MessageHandler;
$messages = new MessageHandler($session->flash('messages')); $messages = new MessageHandler($session->flash('messages'));
foreach (['error', 'notice'] as $key) { foreach (['error', 'notice'] as $key) {
$data[$key] = $messages->showMessages($key); $data[$key] = $messages->showMessages($key);
} }
// --- Database --- // --- Database ---
use Novaconium\Database;
if (!empty($config['database']['host'])) { if (!empty($config['database']['host'])) {
$db = new Database($config['database']); $db = new Database($config['database']);
} }
// --- POST Wrapper --- // --- POST Wrapper ---
use Novaconium\Post;
if (!empty($_POST)) { if (!empty($_POST)) {
$post = new Post($_POST); $post = new Post($_POST);
} }
// --- Redirect Handler --- // --- Redirect Handler ---
use Novaconium\Redirect;
$redirect = new Redirect(); $redirect = new Redirect();
// --- Router --- // --- Router ---
use Novaconium\Router;
$router = new Router(); $router = new Router();
require_once $router->controllerPath; require_once $router->controllerPath;

View File

@@ -39,7 +39,7 @@
> >
{# STYLESHEET #} {# STYLESHEET #}
<link rel="stylesheet" href="/css/main.css"> <link rel="stylesheet" href="/css/novaconium.css">
{# highlight.js #} {# highlight.js #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>