10 Commits

Author SHA1 Message Date
nick 4484f88d8d Changed variables. 2026-07-04 07:43:33 -07:00
nick 7ee47a0b1e moving away from gross globals 2026-07-03 21:05:39 -07:00
nick 230476e8e5 Claude redid the router 2026-06-30 01:35:55 -07:00
nick d5871ed8e7 minor updates 2026-06-16 19:00:30 -07:00
nick ad6e07c76d fixed css and changed timezone 2026-05-04 11:30:14 -07:00
nick 06310d9eb9 Updated readme and composer 2026-05-03 13:27:30 -07:00
nick f679d0b20e safety fix 2026-02-07 17:26:26 -08:00
nick 9feccf9eaa tabs and draft working 2026-01-26 21:55:22 -08:00
nick 14ec6b7e7a fixed matomo to be in the master twig template. 2026-01-22 15:40:46 -08:00
nick 42a828a778 edit page template 2026-01-12 19:00:03 -08:00
42 changed files with 847 additions and 335 deletions
+66
View File
@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Novaconium is a PHP 8.1+ micro-framework distributed as a Composer package (`4lt/novaconium`, PSR-4 autoloaded under the `Novaconium\` namespace from `src/`). This repo *is* the framework itself — there is no "app" here to run directly. Consuming projects `composer require` it and copy `skeleton/novaconium/` into their own project to get an `App/` directory (config, controllers, views, routes) plus a `public/` entrypoint.
Because of this, most of the interesting behavior lives in two places:
- `src/` — the framework runtime (bootstrap, router, session, db, etc.)
- `controllers/` + `views/` + `twig/` — the framework's own built-in admin panel ("NOVACONIUM" control panel routes), shipped so consuming apps get login/dashboard/page-editing/messages for free.
`skeleton/` is what gets copied into a new consuming project — treat it as a template, not live framework code.
## Development
There is no local PHP/Composer install expected — Composer, PHP/Apache, and Sass all run in Docker containers (see `docs/docker.md`, `docs/Composer.md`, `docs/Sass.md`). There is no test suite or linter configured in this repo.
Compiling Sass (from repo root):
```bash
docker run --rm -v "$(pwd):/usr/src/app" -w /usr/src/app sass-container --style=compressed sass/novaconium.sass skeleton/novaconium/public/css/novaconium.css
```
For iterating against a real consuming project without re-running `composer require` on every change, use the fake autoloader dev pattern in `docs/Dev-Fake_autoload.md` (registers a `spl_autoload_register` shim pointing at a manually cloned copy of this repo under the app's `vendor/4lt/novaconium`).
## Request lifecycle
Entry point in a consuming app is `App/public/index.php` (see `skeleton/novaconium/public/index.php`), which:
1. Defines `BASEPATH` (app root) and `FRAMEWORKPATH` (`BASEPATH/vendor/4lt/novaconium`) — these two constants are used everywhere in `src/` to resolve app vs. framework paths, and are assumed to already be defined by the time any framework code runs.
2. Requires Composer's autoloader, then `FRAMEWORKPATH/src/novaconium.php` (the bootstrap).
3. Calls the global `makeitso()` at the very end.
`src/novaconium.php` (the bootstrap) runs in this order and wires up several **globals** that framework code and app controllers rely on implicitly (`$config`, `$log`, `$data`, `$session`, `$messages`, `$db`, `$post`, `$redirect`, `$router`):
1. Loads `App/config.php` (falls back to the skeleton's default config if the app hasn't created one).
2. Loads `src/functions.php` (global helpers: `dd()`, `makeitso()`) and `src/twig.php` (global `view()` helper).
3. Creates `$log` (`Novaconium\Logger`), `$session` (`Novaconium\Session`), `$messages` (`Novaconium\MessageHandler`, seeded from flashed session messages), `$db` (`Novaconium\Database`, only if `config['database']['host']` is set), `$post` (`Novaconium\Post`, only if `$_POST` is non-empty), `$redirect` (`Novaconium\Redirect`).
4. Builds `$router` (`Novaconium\Router`), whose constructor resolves the matched controller file, then immediately `require`s it — so the matched controller file executes inline, still inside bootstrap, with all the globals above already in scope.
Controllers are plain PHP scripts (not classes) that use the ambient globals directly (e.g. `$session->get(...)`, `$messages->error(...)`, `$post->get(...)`, `$db->getRow(...)`) and typically end by calling the global `view($templateName, $data)` or setting `$redirect->url(...)` followed by `makeitso()` to end the request (writes session, closes DB, performs any pending redirect, flashes messages, then `exit`).
## Router (`src/Router.php`)
Full behavioral spec is in `docs/Router.md` — read it before touching routing logic, since several quirks are **intentionally preserved for backward compatibility**, not bugs:
- Only the *first* `{param}` in a route pattern is ever extracted; subsequent params in the same pattern are ignored.
- For parameterized routes, the first route whose static prefix + segment count matches wins, even if that route has no handler for the current HTTP method — the router does not keep searching for a better match.
- No wildcard/optional-segment support; segment counts must match exactly.
Routes are plain associative arrays keyed by path, mapping `get`/`post` to a controller string (see `config/routes.php` for the framework's own admin routes, `skeleton/novaconium/App/routes.php` for the app-level shape). `App/routes.php` routes are merged with (and can override) framework routes. Controller strings prefixed with `NOVACONIUM` resolve against `FRAMEWORKPATH/controllers/`; everything else resolves against `BASEPATH/App/controllers/`. Unresolved routes/files fall back to the app's `404.php`, then the framework's `controllers/404.php`.
## Views (Twig)
`view($name, $data)` (`src/twig.php`) loads templates from `App/views/` by default, with two additional Twig namespaces registered: `@novaconium``FRAMEWORKPATH/twig` (layout partials: `master.html.twig`, `nav.html.twig`, `head.html.twig`, etc.) and `@novacore``FRAMEWORKPATH/views` (the framework's built-in admin pages: dashboard, pages, messages, settings). Apps can override any framework template by placing a same-named file under `App/templates/` (`override` namespace). The framework's own controllers render via the `@novacore/...` namespace (e.g. `view('@novacore/dashboard', $data)`).
## Other framework primitives (all under `src/`, namespace `Novaconium`)
- `Database` — thin `mysqli` wrapper (`query`, `getRow`, `getRows`); always uses prepared statements but binds every parameter as a string type.
- `Session` — thin `$_SESSION` wrapper; auto-initializes `token`/`messages`/`formData`/`errors` keys; `flash()` reads-then-deletes.
- `MessageHandler` — categorized flash messages (`error`/`warning`/`notice`/`success`), persisted across requests via `$session`.
- `Post` — sanitizes `$_POST` via `FILTER_SANITIZE_FULL_SPECIAL_CHARS` on construction; access via `$post->get($key)`.
- `Redirect` — buffers a target URL/status; only the *last* `url()` call before `makeitso()` takes effect; actual `header()` redirect happens inside `makeitso()`.
- `Logger` — level-gated file logger (`DEBUG`/`INFO`/`WARNING`/`ERROR`/`NONE`), configured via `config['logfile']`/`config['loglevel']`.
- `src/Services/` — newer, thinner service classes (`Auth`, `TagManager`) alongside the older top-level `src/` classes; check here first before adding new cross-cutting logic.
## Configuration
App-level config lives in `App/config.php` (see `docs/ConfigurationFile.md` and `skeleton/novaconium/App/config.php` for the shape): `database` (mysqli connection), `base_url`, `secure_key` (used to pepper password hashes via `hash_hmac('sha3-512', ...)`, see `controllers/authenticate.php`), `logfile`, `loglevel`, plus optional `matomo_url`/`matomo_id`/`fonts` passed straight into Twig's global `$data`.
+14 -7
View File
@@ -6,13 +6,20 @@ NovaconiumPHP is a high-performance PHP framework designed with inspiration from
Pronounced: Noh-vah-koh-nee-um
Packagist: https://packagist.org/packages/4lt/novaconium
Master Repo: https://git.4lt.ca/4lt/novaconium
* Packagist: https://packagist.org/packages/4lt/novaconium
* Master Repo: https://git.4lt.ca/4lt/novaconium
## Getting Started
Novaconium is heavly influenced by docker, but you can use composer outside of docker.
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).
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:
* 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
PROJECTNAME=novaproject;
@@ -24,9 +31,9 @@ docker run --rm --interactive --tty --volume ./novaconium/:/app composer:latest
cp -R novaconium/vendor/4lt/novaconium/skeleton/. .;
# Edit .env
# pwgen -cnsB1v 12 root password
# pwgen -cnsB1v 12 mysql user password (need in both config and env)
# pwgen -cnsB1v 64 framework key (need in config)
# pwgen -cnsB1v 12 # root password
# pwgen -cnsB1v 12 # mysql user password (need in both config and env)
# pwgen -cnsB1v 64 # framework key (need in config)
# Edit novaconium/App/config.php
docker compose up -d
+3 -3
View File
@@ -1,11 +1,11 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Login Page',
'pageclass' => 'novaconium'
]);
// Don't come here if logged in
if ($session->get('username')) {
$redirect->url('/novaconium/dashboard');
if ($ctx->session->get('username')) {
$ctx->redirect->url('/novaconium/dashboard');
makeitso();
}
view('@novacore/auth/login');
+3 -3
View File
@@ -1,5 +1,5 @@
<?php
$session->kill();
$log->info("Logout - Logout Success - " . $_SERVER['REMOTE_ADDR']);
$redirect->url('/');
$ctx->session->kill();
$ctx->log->info("Logout - Logout Success - " . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
$ctx->redirect->url('/');
makeitso();
+24 -24
View File
@@ -8,63 +8,63 @@ $url_fail = '/novaconium/login';
// Don't go further if already logged in
if ( !empty($session->get('username')) ) {
$redirect->url($url_success);
if ( !empty($ctx->session->get('username')) ) {
$ctx->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");
if ($ctx->session->get('token') != $ctx->post->get('token')) {
$ctx->messages->addMessage('error', "Invalid Session.");
$ctx->log->error("Login Authentication - Invalid Session Token");
}
// Handle Username
$rawUsername = $post->get('username', null);
$rawUsername = $ctx->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.");
$ctx->messages->addMessage('error', "No Username given.");
}
// Handle Password
$password = $v->clean($post->get('password', null));
$password = $v->clean($ctx->post->get('password', null));
if ( empty($password) ) {
$messages->addMessage('error', "Password Empty.");
$ctx->messages->addMessage('error', "Password Empty.");
}
/*************************************************************************************************************
* Query Database
************************************************************************************************************/
if ($messages->count('error') === 0) {
if ($ctx->messages->count('error') === 0) {
$query = "SELECT id, username, email, password, blocked FROM users WHERE username = ? OR email = ?";
$matched = $db->getRow($query, [$username, $username]);
$matched = $ctx->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");
$ctx->messages->addMessage('error', "User or Password incorrect.");
$ctx->log->warning("Login Authentication - Login Error, user doesn't exist");
}
}
if ($messages->count('error') === 0) {
if ($ctx->messages->count('error') === 0) {
// Re-apply pepper
$peppered = hash_hmac('sha3-512', $password, $config['secure_key']);
$peppered = hash_hmac('sha3-512', $password, $ctx->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");
$ctx->messages->addMessage('error', "User or Password incorrect.");
$ctx->log->warning("Login Authentication - Login Error, password wrong");
}
}
// Process Login or Redirect
if ($messages->count('error') === 0) {
if ($ctx->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");
$groups = $ctx->db->getRow($query, [$matched['id']]);
$ctx->session->set('username', $cleanUsername);
$ctx->session->set('group', $groups['groupName']);
$ctx->redirect->url($url_success);
$ctx->log->info("Login Authentication - Login Success");
} else {
$redirect->url($url_fail);
$ctx->redirect->url($url_fail);
}
+2 -2
View File
@@ -1,9 +1,9 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Coming Soon',
'heading' => 'Coming Soon',
'countdown' => true,
'launch_date' => '2026-01-01T00:00:00'
]);
view('@novacore/coming-soon', $data);
view('@novacore/coming-soon', $ctx->data);
+7 -7
View File
@@ -6,10 +6,10 @@ use Nickyeoman\Validation;
$validate = new Validation\Validate();
$valid = true;
$p = $post->all();
$p = $ctx->post->all();
// Check secure key
if (empty($p['secure_key']) || $p['secure_key'] !== $config['secure_key']) {
if (empty($p['secure_key']) || $p['secure_key'] !== $ctx->config['secure_key']) {
$valid = false;
}
@@ -31,7 +31,7 @@ if (empty($p['password'])) {
$valid = false;
} else {
// Use pepper + Argon2id
$peppered = hash_hmac('sha3-512', $p['password'], $config['secure_key']);
$peppered = hash_hmac('sha3-512', $p['password'], $ctx->config['secure_key']);
$hashed_password = password_hash($peppered, PASSWORD_ARGON2ID);
}
@@ -45,16 +45,16 @@ if ($valid) {
EOSQL;
$params = [$name, $hashed_password, $p['email']];
$db->query($query, $params);
$userid = $db->lastid();
$ctx->db->query($query, $params);
$userid = $ctx->db->lastid();
// Assign admin group
$groupInsertQuery = <<<EOSQL
INSERT INTO `user_groups` (`user_id`, `groupName`) VALUES (?, ?);
EOSQL;
$db->query($groupInsertQuery, [$userid, 'admin']);
$ctx->db->query($groupInsertQuery, [$userid, 'admin']);
}
// Always redirect at end
$redirect->url('/novaconium');
$ctx->redirect->url('/novaconium');
+5 -5
View File
@@ -1,14 +1,14 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Dashboard Page',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
view('@novacore/dashboard', $data);
view('@novacore/dashboard', $ctx->data);
+10 -10
View File
@@ -1,6 +1,6 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Edit Page',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel',
@@ -8,14 +8,14 @@ $data = array_merge($data, [
]);
// Check if logged in
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
if (empty($ctx->session->get('username'))) {
$ctx->messages->error('You are not logged in');
$ctx->redirect->url('/novaconium/login');
makeitso();
}
// Get page ID from router parameters
$pageid = $router->parameters['id'] ?? null;
$pageid = $ctx->router->parameters['id'] ?? null;
if (!empty($pageid)) {
// Existing page: fetch from database
@@ -51,23 +51,23 @@ WHERE p.id = ?
GROUP BY p.id;
EOSQL;
$data['rows'] = $db->getRow($query, [$pageid]);
$ctx->data['rows'] = $ctx->db->getRow($query, [$pageid]);
// If no row is found, treat as new page
if (!$data['rows']) {
if (!$ctx->data['rows']) {
$pageid = null;
}
}
if (empty($pageid)) {
// New page: set default values for all fields
$data['rows'] = [
$ctx->data['rows'] = [
'id' => 'newpage',
'title' => '',
'heading' => '',
'description' => '',
'keywords' => '',
'author' => $session->get('username') ?? '',
'author' => $ctx->session->get('username') ?? '',
'slug' => '',
'path' => '',
'intro' => '',
@@ -82,4 +82,4 @@ if (empty($pageid)) {
}
// Render the edit page view
view('@novacore/editpage/index', $data);
view('@novacore/editpage/index', $ctx->data);
+30 -30
View File
@@ -1,22 +1,22 @@
<?php
$data = [
$ctx->data = [
'secure_key' => false,
'gen_key' => NULL,
'users_created' => false,
'empty_users' => false,
'show_login' => false,
'token' => $session->get('token'),
'token' => $ctx->session->get('token'),
'pageclass' => 'novaconium',
'title' => 'Novaconium Admin'
];
// Check if SECURE KEY is Set in
if ($config['secure_key'] !== null && strlen($config['secure_key']) === 64) {
$data['secure_key'] = true;
if ($ctx->config['secure_key'] !== null && strlen($ctx->config['secure_key']) === 64) {
$ctx->data['secure_key'] = true;
} else {
$data['gen_key'] = substr(bin2hex(random_bytes(32)), 0, 64);
$log->warn('secure_key not detected');
$ctx->data['gen_key'] = substr(bin2hex(random_bytes(32)), 0, 64);
$ctx->log->warn('secure_key not detected');
}
// Check if user table exists
@@ -26,7 +26,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'users';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
@@ -46,9 +46,9 @@ if ($result->num_rows === 0) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$data['users_created'] = true;
$log->info('Users Table Created');
$ctx->db->query($query);
$ctx->data['users_created'] = true;
$ctx->log->info('Users Table Created');
}
@@ -59,7 +59,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'user_groups';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
@@ -72,8 +72,8 @@ if ($result->num_rows === 0) {
EOSQL;
$db->query($query);
$log->info('User_groups Table Created');
$ctx->db->query($query);
$ctx->log->info('User_groups Table Created');
}
@@ -84,7 +84,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'pages';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
@@ -109,8 +109,8 @@ if ($result->num_rows === 0) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Pages Table Created');
$ctx->db->query($query);
$ctx->log->info('Pages Table Created');
}
// Check ContactForm Table
@@ -120,7 +120,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'contactForm';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
@@ -135,20 +135,20 @@ if ($result->num_rows === 0) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('ContactForm Table Created');
$ctx->db->query($query);
$ctx->log->info('ContactForm Table Created');
}
// Check if a user exists
$result = $db->query("SELECT COUNT(*) as total FROM users");
$result = $ctx->db->query("SELECT COUNT(*) as total FROM users");
$row = $result->fetch_assoc();
if ($row['total'] < 1) {
$data['empty_users'] = true;
$ctx->data['empty_users'] = true;
} else {
$log->info('Init Run complete, all sql tables exist with a user.');
$ctx->log->info('Init Run complete, all sql tables exist with a user.');
// Everything is working, send them to login page
$redirect->url('/novaconium/login');
$ctx->redirect->url('/novaconium/login');
makeitso();
}
@@ -159,7 +159,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'tags';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `tags` (
@@ -170,8 +170,8 @@ CREATE TABLE IF NOT EXISTS `tags` (
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Tags Table Created');
$ctx->db->query($query);
$ctx->log->info('Tags Table Created');
}
// Check Page Tags Junction Table (after tags table)
@@ -181,7 +181,7 @@ FROM information_schema.tables
WHERE table_schema = DATABASE()
AND TABLE_NAME = 'page_tags';
EOSQL;
$result = $db->query($query);
$result = $ctx->db->query($query);
if ($result->num_rows === 0) {
$query = <<<EOSQL
CREATE TABLE IF NOT EXISTS `page_tags` (
@@ -195,8 +195,8 @@ CREATE TABLE IF NOT EXISTS `page_tags` (
FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
EOSQL;
$db->query($query);
$log->info('Page Tags Junction Table Created');
$ctx->db->query($query);
$ctx->log->info('Page Tags Junction Table Created');
}
view('@novacore/init', $data);
view('@novacore/init', $ctx->data);
+7 -7
View File
@@ -1,15 +1,15 @@
<?php
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
$messageid = $router->parameters['id'];
$messageid = $ctx->router->parameters['id'];
$query="DELETE FROM contactForm WHERE `contactForm`.`id` = ?";
$db->query($query, [$messageid]);
$ctx->db->query($query, [$messageid]);
$redirect->url('/novaconium/messages');
$messages->notice("Removed Message $messageid");
$ctx->redirect->url('/novaconium/messages');
$ctx->messages->notice("Removed Message $messageid");
makeitso();
+8 -8
View File
@@ -1,19 +1,19 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Message Page',
'pageclass' => 'novaconium'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
$messageid = $router->parameters['id'];
$query = "SELECT id, name, email, message, created, unread FROM contactForm WHERE id = '$messageid'";
$messageid = $ctx->router->parameters['id'];
$query = "SELECT id, name, email, message, created, unread FROM contactForm WHERE id = ?";
$data['themessage'] = $db->getRow($query);
$ctx->withData(['themessage' => $ctx->db->getRow($query, [$messageid])]);
view('@novacore/editmessage', $data);
view('@novacore/editmessage', $ctx->data);
+20 -20
View File
@@ -5,33 +5,33 @@ use Nickyeoman\Validation;
$v = new Nickyeoman\Validation\Validate();
$url_success = '/novaconium/messages';
$url_error = '/novaconium/messages/edit/' . $post->get('id'); // Redirect back to the message edit form on error
$url_error = '/novaconium/messages/edit/' . $ctx->post->get('id'); // Redirect back to the message edit form on error
// Check if logged in
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
if (empty($ctx->session->get('username'))) {
$ctx->messages->error('You are not logged in');
$ctx->redirect->url('/novaconium/login');
makeitso();
}
// Check CSRF token
if ($session->get('token') != $post->get('token')) {
$messages->error('Invalid token');
$redirect->url($url_success);
if ($ctx->session->get('token') != $ctx->post->get('token')) {
$ctx->messages->error('Invalid token');
$ctx->redirect->url($url_success);
makeitso();
}
// Get POST data
$id = $post->get('id');
$name = $post->get('name');
$email = $post->get('email');
$message = $post->get('message');
$unread = !empty($post->get('unread')) ? 1 : 0;
$id = $ctx->post->get('id');
$name = $ctx->post->get('name');
$email = $ctx->post->get('email');
$message = $ctx->post->get('message');
$unread = !empty($ctx->post->get('unread')) ? 1 : 0;
// Validate required fields
if (empty($id) || empty($message) || empty($email)) {
$messages->error('One of the required fields was empty.');
$redirect->url($url_error);
$ctx->messages->error('One of the required fields was empty.');
$ctx->redirect->url($url_error);
makeitso();
}
@@ -43,15 +43,15 @@ try {
$params = [$name, $email, $message, $unread, $id];
$db->query($query, $params);
$ctx->db->query($query, $params);
$messages->notice('Message updated successfully');
$ctx->messages->notice('Message updated successfully');
} catch (Exception $e) {
$messages->error('Error updating message: ' . $e->getMessage());
$redirect->url($url_error);
} catch (\Exception $e) {
$ctx->messages->error('Error updating message: ' . $e->getMessage());
$ctx->redirect->url($url_error);
makeitso();
}
// Redirect to success page
$redirect->url($url_success);
$ctx->redirect->url($url_success);
+7 -7
View File
@@ -1,22 +1,22 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Messages',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
// Get the pages
$query = "SELECT id, name, email, LEFT(message, 40) AS message, created, unread FROM contactForm";
$matched = $db->getRows($query);
$matched = $ctx->db->getRows($query);
$data['messages'] = $matched;
$ctx->withData(['messages' => $matched]);
view('@novacore/messages', $data);
view('@novacore/messages', $ctx->data);
+7 -7
View File
@@ -1,21 +1,21 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Pages',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
// Get the pages
$query = "SELECT id, title, created, updated, draft FROM pages";
$matched = $db->getRows($query);
$matched = $ctx->db->getRows($query);
$data['pages'] = $matched;
$ctx->withData(['pages' => $matched]);
view('@novacore/pages', $data);
view('@novacore/pages', $ctx->data);
+2 -2
View File
@@ -10,7 +10,7 @@ $pt = '@novacore/samples'; //Define the view directory
//$pt = 'samples'; //drop the core for your project
//Grab the slug
$slug = $router->parameters['slug'];
$slug = $ctx->router->parameters['slug'];
//build path
$tmpl = $pt . '/' . $slug;
@@ -26,7 +26,7 @@ if (strpos($pt, '@novacore') !== false) {
$possibleFile = $baseDir . '/' . $slug . '.html.twig'; // add .twig extension if needed
if (is_file($possibleFile) && is_readable($possibleFile)) {
view($tmpl, $data);
view($tmpl, $ctx->data);
} else {
http_response_code('404');
header("Content-Type: text/html");
+34 -34
View File
@@ -5,44 +5,44 @@ use Novaconium\Services\TagManager;
$v = new Nickyeoman\Validation\Validate();
$url_error = '/novaconium/page/edit/' . $post->get('id'); // fallback for errors
$url_error = '/novaconium/page/edit/' . $ctx->post->get('id'); // fallback for errors
// -------------------------
// Check login
// -------------------------
if (empty($session->get('username'))) {
$messages->error('You are not logged in');
$redirect->url('/novaconium/login');
if (empty($ctx->session->get('username'))) {
$ctx->messages->error('You are not logged in');
$ctx->redirect->url('/novaconium/login');
makeitso();
}
// -------------------------
// Check CSRF token
// -------------------------
if ($session->get('token') != $post->get('token')) {
$messages->error('Invalid Token');
$redirect->url('/novaconium/pages');
if ($ctx->session->get('token') != $ctx->post->get('token')) {
$ctx->messages->error('Invalid Token');
$ctx->redirect->url('/novaconium/pages');
makeitso();
}
// -------------------------
// Gather POST data
// -------------------------
$id = $post->get('id');
$title = $_POST['title'] ?? '';
$heading = $_POST['heading'] ?? '';
$description = $_POST['description'] ?? '';
$keywords = $_POST['keywords'] ?? '';
$author = $_POST['author'] ?? '';
$slug = $_POST['slug'] ?? '';
$path = $_POST['path'] ?? null;
$intro = $_POST['intro'] ?? '';
$body = $_POST['body'] ?? '';
$notes = $_POST['notes'] ?? '';
$draft = !empty($post->get('draft')) ? 1 : 0;
$changefreq = $_POST['changefreq'] ?? 'monthly';
$priority = $_POST['priority'] ?? 0.0;
$tags_json = $_POST['tags_json'] ?? '[]';
$id = $ctx->post->get('id');
$title = $ctx->post->get('title', '');
$heading = $ctx->post->get('heading', '');
$description = $ctx->post->get('description', '');
$keywords = $ctx->post->get('keywords', '');
$author = $ctx->post->get('author', '');
$slug = $ctx->post->get('slug', '');
$path = $ctx->post->get('path');
$intro = $ctx->post->get('intro', '');
$body = $ctx->post->get('body', '');
$notes = $ctx->post->get('notes', '');
$draft = !empty($ctx->post->get('draft')) ? 1 : 0;
$changefreq = $ctx->post->get('changefreq', 'monthly');
$priority = $ctx->post->get('priority', 0.0);
$tags_json = $ctx->post->get('tags_json', '[]');
// -------------------------
// Decode & sanitize tags
@@ -57,13 +57,13 @@ $tags = array_unique($tags);
// Validate required fields
// -------------------------
if (empty($title) || empty($slug) || empty($body)) {
$messages->error('Title, Slug, and Body are required.');
$redirect->url($url_error);
$ctx->messages->error('Title, Slug, and Body are required.');
$ctx->redirect->url($url_error);
makeitso();
}
try {
$tagManager = new TagManager();
$tagManager = new TagManager($ctx->db);
if ($id == 'newpage') {
// -------------------------
@@ -79,9 +79,9 @@ try {
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority
];
$db->query($query, $params);
$id = $db->lastid;
$messages->notice('Page Created');
$ctx->db->query($query, $params);
$id = $ctx->db->lastid;
$ctx->messages->notice('Page Created');
} else {
// -------------------------
@@ -97,8 +97,8 @@ try {
$slug, $path, $intro, $body, $notes,
$draft, $changefreq, $priority, $id
];
$db->query($query, $params);
$messages->notice('Page Updated');
$ctx->db->query($query, $params);
$ctx->messages->notice('Page Updated');
}
// -------------------------
@@ -106,11 +106,11 @@ try {
// -------------------------
$tagManager->setTagsForPage($id, $tags);
} catch (Exception $e) {
$messages->error($e->getMessage());
$redirect->url($url_error);
} catch (\Exception $e) {
$ctx->messages->error($e->getMessage());
$ctx->redirect->url($url_error);
makeitso();
}
// Redirect back to edit page
$redirect->url('/novaconium/page/edit/' . $id);
$ctx->redirect->url('/novaconium/page/edit/' . $id);
+5 -5
View File
@@ -1,15 +1,15 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Novaconium Settings',
'pageclass' => 'novaconium',
'pageid' => 'controlPanel'
]);
if ( empty($session->get('username'))) {
$redirect->url('/novaconium/login');
$messages->error('You are not loggedin');
if ( empty($ctx->session->get('username'))) {
$ctx->redirect->url('/novaconium/login');
$ctx->messages->error('You are not loggedin');
makeitso();
}
view('@novacore/settings', $data);
view('@novacore/settings', $ctx->data);
+3 -3
View File
@@ -10,7 +10,7 @@ $query=<<<EOSQL
AND draft = 0
ORDER BY updated DESC;
EOSQL;
$thepages = $db->getRows($query);
$thepages = $ctx->db->getRows($query);
// Start the view
echo '<?xml version="1.0" encoding="UTF-8"?>';
@@ -25,9 +25,9 @@ if ( ! empty($thepages) ) {
echo "<url>";
if ( empty($v['path']) )
echo "<loc>" . $config['base_url'] . '/page/' . $v['slug'] . "</loc>";
echo "<loc>" . $ctx->config['base_url'] . '/page/' . $v['slug'] . "</loc>";
else
echo "<loc>" . $config['base_url'] . $v['path'] . "</loc>";
echo "<loc>" . $ctx->config['base_url'] . $v['path'] . "</loc>";
echo "<lastmod>" . $date . "</lastmod>";
echo "<changefreq>" . $v['changefreq'] . "</changefreq>";
+1 -1
View File
@@ -8,7 +8,7 @@ Update novaconium with composer in docker: ```docker run --rm --interactive --tt
## Install Composer natively on Debian
Assuming you have nala installed:
Assuming you have nala installed (otherwise use apt-get):
```bash
sudo nala install curl php-cli php-mbstring git unzip
+101
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'];
```
+1 -8
View File
@@ -1,12 +1,10 @@
# Sample Docker Compose
services:
corxn:
image: 4lights/corxn:6.0.0
image: 4lights/corxn:8.5.3
ports:
- "8000:80"
volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
- ./novaconium:/data
- ./data/logs:/var/log/apache2 # Optional Logs
- "./logs:/data/logs"
@@ -29,8 +27,6 @@ services:
MYSQL_USER: novaconium
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
- ./data/db:/var/lib/mysql
networks:
- internal
@@ -49,9 +45,6 @@ services:
- PMA_USER=root
- PMA_PASSWORD=${MYSQL_ROOT_PASSWORD}
- UPLOAD_LIMIT=200M
volumes:
- "/etc/timezone:/etc/timezone:ro"
- "/etc/localtime:/etc/localtime:ro"
networks:
proxy:
+2 -1
View File
@@ -11,6 +11,7 @@ $config = [
'secure_key' => '', //64 alphanumeric characters
'logfile' => '/logs/novaconium.log',
'loglevel' => 'ERROR', // 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'NONE',
'matomo' => '1',
'matomo_url' => 'matomo.4lt.ca',
'matomo_id' => '0',
'fonts' => 'https://fonts.googleapis.com/css2?family=VT323:wght@400&family=Fira+Code:wght@400;500&display=swap&family=Material+Icons:wght@400;500&display=swap'
];
@@ -1,5 +1,5 @@
<?php
$data = array_merge($data, [
$ctx->withData([
'title' => 'Welcome to Novaconium Index Page',
'pageclass' => 'novaconium'
]);
+8 -9
View File
@@ -1,5 +1,5 @@
<?php
$slug = $router->parameters['slug'];
$slug = $ctx->router->parameters['slug'];
$query=<<<EOSQL
SELECT
id,
@@ -12,22 +12,21 @@ $query=<<<EOSQL
created,
updated
FROM pages
WHERE slug = '$slug'
WHERE slug = ?
EOSQL;
//$db->debugGetRow($query);
$data = $db->getRow($query);
if(!$data) {
$page = $ctx->db->getRow($query, [$slug]);
if(!$page) {
http_response_code('404');
header("Content-Type: text/html");
view('404');
exit;
}
$data = array_merge($data, [
$ctx->withData(array_merge($page, [
'menuActive' => 'blog',
'pageid' => 'page',
'permissionsGroup' => $session->get('group')
]);
'permissionsGroup' => $ctx->session->get('group')
]));
view('page', $data);
view('page', $ctx->data);
@@ -6,7 +6,7 @@ header('Content-Type: text/plain; charset=UTF-8');
header('Cache-Control: public, max-age=604800');
// Use $config['base_url'] as-is
$baseUrl = $config['base_url'];
$baseUrl = $ctx->config['base_url'];
echo <<<TXT
# robots.txt for sites powered by Novaconium framework
+1 -1
View File
@@ -4,7 +4,7 @@
// ini_set('display_errors', 1);
// 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
require BASEPATH . '/vendor/autoload.php';
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Novaconium;
final class Context
{
public function __construct(
public array $config,
public Logger $log,
public Session $session,
public MessageHandler $messages,
public ?Database $db,
public ?Post $post,
public Redirect $redirect,
public Router $router,
public array $data = [],
) {}
/**
* Merge additional values into the Twig data array.
*/
public function withData(array $more): static
{
$this->data = array_merge($this->data, $more);
return $this;
}
}
+5 -5
View File
@@ -25,7 +25,7 @@ class Database {
// Prepare the SQL statement
$stmt = $this->conn->prepare($query);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $this->conn->error);
throw new \Exception("Query preparation failed: " . $this->conn->error);
}
// Bind parameters if needed
@@ -37,7 +37,7 @@ class Database {
// Execute the statement
if (!$stmt->execute()) {
$stmt->close();
throw new Exception("Query execution failed: " . $stmt->error);
throw new \Exception("Query execution failed: " . $stmt->error);
}
// Save last insert id if it's an INSERT query
@@ -70,7 +70,7 @@ class Database {
// Prepare the SQL statement
$stmt = $this->conn->prepare($query);
if (!$stmt) {
throw new Exception("Query preparation failed: " . $this->conn->error);
throw new \Exception("Query preparation failed: " . $this->conn->error);
}
// Bind parameters
@@ -82,7 +82,7 @@ class Database {
// Execute the statement
if (!$stmt->execute()) {
$stmt->close();
throw new Exception("Query execution failed: " . $stmt->error);
throw new \Exception("Query execution failed: " . $stmt->error);
}
// Get result
@@ -92,7 +92,7 @@ class Database {
$stmt->close();
return $row;
} catch (Exception $e) {
} catch (\Exception $e) {
echo "An error occurred: " . $e->getMessage();
return null;
}
+1 -1
View File
@@ -21,7 +21,7 @@ class MessageHandler {
// Add a message of a specific type
public function addMessage($type, $message) {
if (!isset($this->messages[$type])) {
throw new Exception("Invalid message type: $type");
throw new \Exception("Invalid message type: $type");
}
$this->messages[$type][] = $message;
}
+128 -64
View File
@@ -1,5 +1,6 @@
<?php
namespace Novaconium;
class Router {
public $routes = [];
public $query = [];
@@ -18,29 +19,30 @@ class Router {
$this->controllerPath = $this->setRouteFile();
}
/**
* Merge framework routes with app-defined routes (app routes can override framework routes).
*/
private function loadRoutes() {
require_once( \FRAMEWORKPATH . '/config/routes.php');
// Check if Path exists
require_once(\FRAMEWORKPATH . '/config/routes.php');
if (file_exists(\BASEPATH . '/App/routes.php')) {
require_once( \BASEPATH . '/App/routes.php');
}
$routes = array_merge((array)$routes, (array)$framework_routes);
return $routes;
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() {
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
//homepage
if ($path === '/') {
return $path;
}
// remove empty directory path
$path = rtrim($path, '/'); // remove trailing slash
//remove anything after and including ampersand
$path = rtrim($path, '/');
$path = preg_replace('/&.+$/', '', $path);
return $path;
@@ -49,82 +51,146 @@ class Router {
private function prepareQuery() {
$parsedUri = parse_url($_SERVER['REQUEST_URI']);
$queryArray = [];
if (isset($parsedUri['query'])) {
parse_str($parsedUri['query'], $queryArray);
}
return $queryArray;
}
private function getRequestType() {
// is the requewst a get or post?
if (empty($_POST)) {
return 'get';
} else {
return 'post';
}
return empty($_POST) ? 'get' : '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() {
// one to one match
if (array_key_exists($this->path, $this->routes)) {
if (!empty($this->routes[$this->path][$this->requestType])) {
return $this->routes[$this->path][$this->requestType];
}
$exactMatch = $this->findExactMatch();
if ($exactMatch !== null) {
return $exactMatch;
}
foreach ($this->routes as $key => $value) {
// Check if key contains a curly bracket, if not continue. We already checked above.
if (!strpos($key, '{')) continue;
// 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];
}
}
}
}
$paramMatch = $this->findParameterizedMatch();
if ($paramMatch !== null) {
return $paramMatch;
}
return '404';
}
// checks if the file exists, sets file path
private function setRouteFile() {
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() {
if (str_starts_with($this->controller, 'NOVACONIUM')) {
$trimmed = substr($this->controller, strlen('NOVACONIUM/'));
$cp = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
$controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
} else {
$cp = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
$controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
}
if (file_exists($cp)) {
return $cp;
} else {
//Check if 404 exits
if (file_exists( \BASEPATH . '/App/controllers/404.php')) {
if (file_exists($controllerPath)) {
return $controllerPath;
}
if (file_exists(\BASEPATH . '/App/controllers/404.php')) {
return \BASEPATH . '/App/controllers/404.php';
} else {
}
return \FRAMEWORKPATH . '/controllers/404.php';
}
}
}
public function debug() {
echo '<div id="router-debug-container" class="debug">';
@@ -137,6 +203,4 @@ class Router {
die();
}
}
+4 -3
View File
@@ -2,14 +2,15 @@
namespace Novaconium\Services;
use Novaconium\Database;
class TagManager
{
protected $db;
public function __construct()
public function __construct(?Database $db = null)
{
global $db;
$this->db = $db;
$this->db = $db ?? ($GLOBALS['ctx']->db ?? null) ?? ($GLOBALS['db'] ?? null);
}
/**
+12 -1
View File
@@ -24,7 +24,18 @@ function dd(...$vars): void {
* connection if configured, and performs a redirect.
*/
function makeitso(): void {
global $session, $db, $redirect, $config, $messages, $log;
global $session, $db, $redirect, $config, $messages, $log, $ctx;
// Prefer $ctx when available; its service properties are the same
// object instances as the bare globals, so this is behavior-neutral.
if (isset($ctx)) {
$session = $ctx->session;
$db = $ctx->db;
$redirect = $ctx->redirect;
$config = $ctx->config;
$messages = $ctx->messages;
$log = $ctx->log;
}
// ------------------------------
// Close database if configured
+31 -8
View File
@@ -1,5 +1,17 @@
<?php
use Novaconium\Logger;
use Novaconium\Session;
use Novaconium\MessageHandler;
use Novaconium\Database;
use Novaconium\Post;
use Novaconium\Redirect;
use Novaconium\Router;
use Novaconium\Context;
$db = null;
$post = null;
// --- Load Config ---
if (file_exists(\BASEPATH . '/App/config.php')) {
require_once \BASEPATH . '/App/config.php';
@@ -11,44 +23,55 @@ require_once \FRAMEWORKPATH . '/src/functions.php';
require_once \FRAMEWORKPATH . '/src/twig.php';
// --- Logging ---
use Novaconium\Logger;
$log = new Logger(\BASEPATH . $config['logfile'], $config['loglevel']);
// --- Twig Data Array ---
$data = [];
$data['fonts'] = $config['fonts'] ?? [];
$data['matomo'] = $config['matomo'] ?? 0;
$data['matomo_url'] = $config['matomo_url'] ?? '';
$data['matomo_id'] = $config['matomo_id'] ?? '0';
// --- Session ---
use Novaconium\Session;
$session = new Session();
$data['token'] = $session->get('token');
$data['username'] = $session->get('username');
// --- Messages ---
use Novaconium\MessageHandler;
$messages = new MessageHandler($session->flash('messages'));
foreach (['error', 'notice'] as $key) {
$data[$key] = $messages->showMessages($key);
}
// --- Database ---
use Novaconium\Database;
if (!empty($config['database']['host'])) {
$db = new Database($config['database']);
}
// --- POST Wrapper ---
use Novaconium\Post;
if (!empty($_POST)) {
$post = new Post($_POST);
}
// --- Redirect Handler ---
use Novaconium\Redirect;
$redirect = new Redirect();
// --- Router ---
use Novaconium\Router;
$router = new Router();
// --- Typed Context ---
// Bundles the globals above into one typed object. The bare globals are
// left in place (same object instances) so existing plain-script
// controllers keep working unmodified; new controllers can use $ctx.
$ctx = new Context(
config: $config,
log: $log,
session: $session,
messages: $messages,
db: $db,
post: $post,
redirect: $redirect,
router: $router,
data: $data,
);
require_once $router->controllerPath;
+9 -2
View File
@@ -15,13 +15,20 @@ use Twig\Loader\FilesystemLoader;
*/
function view(string $name = '', array $moreData = []): bool
{
global $config, $data;
global $config, $data, $ctx;
if (!is_array($data)) {
$data = [];
}
if (!empty($moreData)) {
// Reconcile the bare $data global with $ctx->data: either style of
// controller ($data = array_merge($data, [...]) or $ctx->withData([...]))
// ends up folded in here, regardless of which one was used.
if (isset($ctx)) {
$data = array_merge($data, $ctx->data, $moreData);
$ctx->data = $data;
$config = $ctx->config;
} elseif (!empty($moreData)) {
$data = array_merge($data, $moreData);
}
+2
View File
@@ -29,9 +29,11 @@
</footer>
{% if editor == 'ace' %}
{% include '@novaconium/javascript/page-edit.html.twig' %}
{% include '@novaconium/javascript/ace.html.twig' %}
{% endif %}
{% if debug is not empty %}
<div id="debug">
<h2>Debugging Information</h2>
+1 -24
View File
@@ -1,28 +1,5 @@
<!--
What goes very last on the page.
right before the /body
like javascript
or analytics
such as javascript
-->
{% include '@novaconium/javascript/page-edit.html.twig' %}
{% if editor == 'ace' %}
{% include '@novaconium/javascript/ace.html.twig' %}
{% endif %}
{% if matomo > 0 %}
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "//matomo.4lt.ca/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', {{ matomo }}]);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Matomo Code -->
{% endif %}
+1 -1
View File
@@ -39,7 +39,7 @@
>
{# STYLESHEET #}
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/novaconium.css">
{# highlight.js #}
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
+192
View File
@@ -0,0 +1,192 @@
<script>
// Tab switching JS (unchanged)
function switchTab(tabId, button) {
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach(b => b.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
button.classList.add('active');
if (tabId === 'content6') {
setTimeout(initTags, 0);
}
}
// Tags Init (with custom dropdown autocomplete)
let tagsListeners = [];
function initTags() {
const tagsInput = document.getElementById('tags-input');
const tagsField = document.getElementById('tags');
const hiddenTags = document.getElementById('tags_json');
const dropdown = document.getElementById('tags-dropdown');
if (!tagsInput || !tagsField || !hiddenTags || !dropdown) {
console.warn('Tags elements missing');
return;
}
// Clean old listeners
tagsListeners.forEach(ls => ls());
tagsListeners = [];
let tags = [];
let existingTags = [];
try {
tags = JSON.parse(tagsInput.dataset.tags || '[]');
existingTags = JSON.parse(tagsInput.dataset.existingTags || '[]');
} catch (e) {
console.warn('JSON error:', e);
tags = [];
existingTags = [];
}
let selectedIndex = -1; // For keyboard nav
// Render chips (unchanged)
function renderTags() {
tagsInput.innerHTML = '';
tags.forEach((tag, index) => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = `${tag} <button type="button" class="tag-remove">&times;</button>`;
chip.querySelector('.tag-remove').onclick = () => {
tags.splice(index, 1);
renderTags();
hiddenTags.value = JSON.stringify(tags);
};
tagsInput.appendChild(chip);
});
tagsInput.appendChild(tagsField);
tagsInput.appendChild(dropdown); // Re-add dropdown
hiddenTags.value = JSON.stringify(tags);
}
// Filter and render dropdown
function updateDropdown() {
const value = tagsField.value.toLowerCase().trim();
dropdown.innerHTML = '';
dropdown.setAttribute('aria-expanded', value ? 'true' : 'false');
selectedIndex = -1;
if (!value) return;
const matches = existingTags.filter(tag =>
tag.toLowerCase().startsWith(value) && !tags.includes(tag.toLowerCase())
).slice(0, 10); // Top 10 matches, exclude existing
matches.forEach((tag, index) => {
const li = document.createElement('li');
li.textContent = tag;
li.setAttribute('role', 'option');
li.onclick = () => selectTag(tag);
li.onmouseover = () => { selectedIndex = index; updateHighlight(); };
dropdown.appendChild(li);
});
if (matches.length) dropdown.parentElement.classList.add('has-dropdown');
else dropdown.parentElement.classList.remove('has-dropdown');
}
// Highlight selected in dropdown
function updateHighlight() {
dropdown.querySelectorAll('li').forEach((li, index) => {
li.classList.toggle('selected', index === selectedIndex);
});
}
// Select tag from dropdown
function selectTag(tag) {
addTag(tag, true); // Add as chip, refocus
tagsField.value = ''; // Clear input
dropdown.setAttribute('aria-expanded', 'false');
}
// Add tag (unchanged)
function addTag(inputValue, refocus = false) {
const tag = inputValue.trim().toLowerCase().replace(/[^\w-]/g, '');
if (tag && tag.length > 0 && tag.length <= 50 && !tags.includes(tag)) {
tags.push(tag);
renderTags();
if (refocus) tagsField.focus();
}
tagsField.value = '';
updateDropdown(); // Hide dropdown after add
}
// Events: Enter/comma/TAB adds tag (no form submit)
const keydownListener = (e) => {
console.log('Keydown:', e.key, 'Value:', tagsField.value, 'Selected:', selectedIndex);
if (dropdown.getAttribute('aria-expanded') === 'true') {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, dropdown.querySelectorAll('li').length - 1);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).scrollIntoView({ block: 'nearest' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
updateHighlight();
dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).scrollIntoView({ block: 'nearest' });
} else if ((e.key === 'Enter' || e.key === 'Tab') && selectedIndex >= 0) {
e.preventDefault();
const selected = dropdown.querySelector(`li:nth-child(${selectedIndex + 1})`).textContent;
selectTag(selected); // Adds chip, refocuses
} else if (e.key === 'Escape') {
e.preventDefault();
tagsField.blur();
}
}
// Fallback for no dropdown or non-selected Tab/Enter
if (!dropdown.getAttribute('aria-expanded') === 'true' || selectedIndex < 0) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(tagsField.value, true);
} else if (e.key === 'Tab') {
e.preventDefault();
if (tagsField.value.trim()) addTag(tagsField.value, true); // Add typed value, refocus
}
}
};
tagsField.addEventListener('keydown', keydownListener);
tagsListeners.push(() => tagsField.removeEventListener('keydown', keydownListener));
const inputListener = () => updateDropdown();
tagsField.addEventListener('input', inputListener);
tagsListeners.push(() => tagsField.removeEventListener('input', inputListener));
const blurListener = () => {
setTimeout(() => dropdown.setAttribute('aria-expanded', 'false'), 150); // Hide after blur
if (tagsField.value.trim()) addTag(tagsField.value);
};
tagsField.addEventListener('blur', blurListener);
tagsListeners.push(() => tagsField.removeEventListener('blur', blurListener));
// Initial render
renderTags();
console.log('Tags ready with autocomplete, loaded:', tags.length, 'tags');
}
// Init on load + observe (unchanged)
document.addEventListener('DOMContentLoaded', function() {
const tagsTab = document.getElementById('content6');
if (tagsTab && tagsTab.classList.contains('active')) {
initTags();
}
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.id === 'content6' && target.classList.contains('active')) {
initTags();
}
}
});
});
observer.observe(document.body, { subtree: true, attributes: true });
});
</script>
+28
View File
@@ -60,4 +60,32 @@
{% endif %}
{% include ['@override/foot.html.twig', '@novaconium/foot.html.twig'] %}
{% if matomo_id > 0 %}
<!-- Matomo -->
<script>
var _paq = window._paq = window._paq || [];
{% if is_404 %}
_paq.push([
'setDocumentTitle',
'404 / Not Found - ' + document.location.pathname
]);
{% endif %}
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u = "//{{ matomo_url|trim('/') }}/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', {{ matomo_id }}]);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true;
g.src = u + 'matomo.js';
s.parentNode.insertBefore(g, s);
})();
</script>
<!-- End Matomo Code -->
{% endif %}
</body></html>
+1
View File
@@ -1,3 +1,4 @@
{% set is_404 = status_code is defined and status_code == 404 %}
{% extends '@novaconium/master.html.twig' %}
{% block content %}
+13
View File
@@ -14,3 +14,16 @@
<div id="body-editor" class="ace-editor"></div> {# Ace mounts here #}
</div>
</div>
<div class="form-group">
<label for="draft">
<input
type="checkbox"
id="draft"
name="draft"
value="1"
{% if rows.draft|default(false) %}checked{% endif %}
>
Save as draft
</label>
</div>