Compare commits
10 Commits
1.0.9
..
4484f88d8d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4484f88d8d | |||
| 7ee47a0b1e | |||
| 230476e8e5 | |||
| d5871ed8e7 | |||
| ad6e07c76d | |||
| 06310d9eb9 | |||
| f679d0b20e | |||
| 9feccf9eaa | |||
| 14ec6b7e7a | |||
| 42a828a778 |
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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,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:
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+139
-75
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
namespace Novaconium;
|
||||
|
||||
class Router {
|
||||
public $routes = [];
|
||||
public $query = [];
|
||||
@@ -10,120 +11,185 @@ class Router {
|
||||
public $requestType = 'get';
|
||||
|
||||
public function __construct() {
|
||||
$this->routes = $this->loadRoutes();
|
||||
$this->path = $this->preparePath();
|
||||
$this->query = $this->prepareQuery();
|
||||
$this->requestType = $this->getRequestType();
|
||||
$this->controller = $this->findController();
|
||||
$this->controllerPath = $this->setRouteFile();
|
||||
$this->routes = $this->loadRoutes();
|
||||
$this->path = $this->preparePath();
|
||||
$this->query = $this->prepareQuery();
|
||||
$this->requestType = $this->getRequestType();
|
||||
$this->controller = $this->findController();
|
||||
$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
|
||||
if (file_exists(\BASEPATH . '/App/routes.php')) {
|
||||
require_once( \BASEPATH . '/App/routes.php');
|
||||
}
|
||||
$routes = array_merge((array)$routes, (array)$framework_routes);
|
||||
require_once(\FRAMEWORKPATH . '/config/routes.php');
|
||||
|
||||
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() {
|
||||
$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;
|
||||
}
|
||||
|
||||
private function prepareQuery() {
|
||||
$parsedUri = parse_url($_SERVER['REQUEST_URI']);
|
||||
$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() {
|
||||
|
||||
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';
|
||||
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];
|
||||
}
|
||||
|
||||
if (file_exists($cp)) {
|
||||
return $cp;
|
||||
} 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';
|
||||
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/'));
|
||||
$controllerPath = \FRAMEWORKPATH . '/controllers/' . $trimmed . '.php';
|
||||
} else {
|
||||
$controllerPath = \BASEPATH . '/App/controllers/' . $this->controller . '.php';
|
||||
}
|
||||
|
||||
if (file_exists($controllerPath)) {
|
||||
return $controllerPath;
|
||||
}
|
||||
|
||||
if (file_exists(\BASEPATH . '/App/controllers/404.php')) {
|
||||
return \BASEPATH . '/App/controllers/404.php';
|
||||
}
|
||||
|
||||
return \FRAMEWORKPATH . '/controllers/404.php';
|
||||
}
|
||||
|
||||
public function debug() {
|
||||
@@ -137,6 +203,4 @@ class Router {
|
||||
|
||||
die();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
</footer>
|
||||
|
||||
{% if editor == 'ace' %}
|
||||
{% include '@novaconium/javascript/ace.html.twig' %}
|
||||
{% 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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,3 +1,4 @@
|
||||
{% set is_404 = status_code is defined and status_code == 404 %}
|
||||
{% extends '@novaconium/master.html.twig' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user