From 1d3b0867497506a4bd703e34a7594cd18a002a49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 03:11:00 +0000 Subject: [PATCH 1/2] Add a menu builder and a page builder (reworked from an untested branch) Admin -> Menu builder builds the admin sidebar (sections and links up to three levels, drag or arrow buttons, per-item permission); Admin -> Pages builds public pages from blocks served at /page/{address}. Fixes over the code this starts from: link and image addresses are validated (no javascript:/data:/protocol-relative), the sidebar escapes them and supports external links; the text block is rebuilt from a DOM allow-list instead of strip_tags (which kept every attribute); relative addresses resolve against the site, not /page/; raw HTML needs its own pages.html permission that is not in the admin role or the '*' default; menu parents are validated (exists, is a link, no loops, depth); the list is a server-side DataTable; permissions for existing admin roles are granted by a migration; colour fields no longer default to white (white text on white CTA). Also fixes data-initial-order clashing with DataTables' own data-order and data-confirm on non-submit buttons. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EYbe92Bns1mKdy23b7txT8 --- README.md | 15 + application/config/menu.php | 3 + application/config/permissions.php | 6 + application/config/routes.php | 16 + application/helpers/datatable_helper.php | 2 +- application/helpers/theme_helper.php | 32 +- .../20261002000000_create_roles_tables.php | 3 +- .../20261006000000_create_menus_table.php | 40 ++ .../20261007000000_create_pages_table.php | 35 ++ ...09000000_grant_menus_pages_permissions.php | 34 ++ .../modules/menus/config/permissions.php | 23 ++ .../menus/controllers/Menu_builder.php | 266 ++++++++++++++ .../modules/menus/models/Menu_model.php | 294 +++++++++++++++ application/modules/menus/views/form.php | 122 +++++++ application/modules/menus/views/index.php | 68 ++++ .../modules/pages/config/permissions.php | 22 ++ .../modules/pages/controllers/Pages.php | 341 ++++++++++++++++++ .../modules/pages/models/Page_model.php | 264 ++++++++++++++ application/modules/pages/views/form.php | 143 ++++++++ application/modules/pages/views/index.php | 33 ++ application/modules/pages/views/view.php | 24 ++ application/src/Menus/MenuTree.php | 134 +++++++ application/src/Page/Html.php | 113 ++++++ application/src/Page/Links.php | 85 +++++ application/src/Page/SectionTypes.php | 320 ++++++++++++++++ application/views/layouts/admin.php | 4 +- assets/js/app.js | 2 +- assets/js/datatables-init.js | 2 +- assets/js/menus.js | 210 +++++++++++ assets/js/pages.js | 118 ++++++ tests-app/Unit/MenuTreeTest.php | 96 +++++ tests-app/Unit/PageSafetyTest.php | 112 ++++++ tests-app/Unit/PageSectionsTest.php | 157 ++++++++ 33 files changed, 3133 insertions(+), 6 deletions(-) create mode 100644 application/migrations/20261006000000_create_menus_table.php create mode 100644 application/migrations/20261007000000_create_pages_table.php create mode 100644 application/migrations/20261009000000_grant_menus_pages_permissions.php create mode 100644 application/modules/menus/config/permissions.php create mode 100644 application/modules/menus/controllers/Menu_builder.php create mode 100644 application/modules/menus/models/Menu_model.php create mode 100644 application/modules/menus/views/form.php create mode 100644 application/modules/menus/views/index.php create mode 100644 application/modules/pages/config/permissions.php create mode 100644 application/modules/pages/controllers/Pages.php create mode 100644 application/modules/pages/models/Page_model.php create mode 100644 application/modules/pages/views/form.php create mode 100644 application/modules/pages/views/index.php create mode 100644 application/modules/pages/views/view.php create mode 100644 application/src/Menus/MenuTree.php create mode 100644 application/src/Page/Html.php create mode 100644 application/src/Page/Links.php create mode 100644 application/src/Page/SectionTypes.php create mode 100644 assets/js/menus.js create mode 100644 assets/js/pages.js create mode 100644 tests-app/Unit/MenuTreeTest.php create mode 100644 tests-app/Unit/PageSafetyTest.php create mode 100644 tests-app/Unit/PageSectionsTest.php diff --git a/README.md b/README.md index a83506f..d351691 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ If you've ever done a fresh CodeIgniter 3 install on PHP 8.2+ and immediately se - [Template & theming](#template--theming) - [Authentication](#authentication) - [Roles & permissions](#roles--permissions) +- [Menu builder & page builder](#menu-builder--page-builder) - [Login security & audit log](#login-security--audit-log) - [Exports](#exports) - [Big data & several servers](#big-data--several-servers) @@ -542,6 +543,20 @@ The starter is meant to grow into a large application, so these are built in rat Not included (so you know): database read replicas / sharding, a job queue, S3 storage, and caching of settings/permissions (they are small queries on every request; role changes apply immediately because nothing is cached). +## Menu builder & page builder + +Both are under the **Content** section of the admin sidebar and need `php index.php console migrate`. The `admin` role receives their permissions (except `pages.html`, below); `super_admin` has everything. + +**Menu builder** (`admin/menus`, permissions `menus.*`). Builds the admin sidebar without editing `application/config/menu.php`: sections (headings) and links, nested up to three levels, ordered by dragging or with the arrow buttons, each optionally limited to a permission or to super admins. The items from `menu.php` always stay; the built ones are added after them. +- A link is a page of this site (`dashboard`, `page/about`) or an `http(s)://`, `mailto:` or `tel:` address. Anything else (`javascript:`, `data:`, `//host`, addresses with spaces) is refused, and it is checked again when the sidebar is drawn. +- A parent must exist, be a link, and not be the item itself or one of its own descendants; a section is always top level and cannot hold links. + +**Page builder** (`admin/pages`, permissions `pages.*`). Builds public pages from blocks (hero, text, features, call to action, image, custom HTML), served at `/page/{address}`. The list is a server-side DataTable. A draft is a 404 for the public and a preview (not cacheable) for editors. +- Text is escaped. The **Text block** may contain basic formatting; it is rebuilt from an allow-list (`App\Page\Html`): only `p br strong em b i u ul ol li h2 h3 h4 blockquote code pre span hr a`, no attributes except a safe `href` on links (which get `rel="noopener noreferrer"`), scripts and styles removed. +- Link and image addresses go through `App\Page\Links`: `javascript:`, `data:` and similar never become a link; pages of this site are turned into full addresses, so `register` works from `/page/about`. +- The **Custom HTML** block is raw on purpose and can run scripts for every visitor, so adding or changing one needs the separate permission **`pages.html`**. It is not in the `admin` role (not even on a fresh install); an editor without it can keep the HTML blocks a page already has, unchanged, but not add or edit one. Give it only to people you trust as much as a developer. +- Colours are optional `#hex` values; empty uses the theme colour. + ## One-click installer On a fresh checkout the home page shows a **Finish setting up** card with an **Install now** button (it leads to `/install`). The form asks for the application name and your administrator account (and, only when there is no working connection, the database server, user, password and name) and then, in one go: diff --git a/application/config/menu.php b/application/config/menu.php index c1fd250..07294b5 100644 --- a/application/config/menu.php +++ b/application/config/menu.php @@ -32,6 +32,9 @@ array('section' => 'Access'), array('label' => 'Users', 'icon' => 'bi-people', 'url' => 'admin/users', 'permission' => 'users.view'), array('label' => 'Roles', 'icon' => 'bi-shield-lock', 'url' => 'admin/roles', 'permission' => 'roles.view'), + array('section' => 'Content'), + array('label' => 'Pages', 'icon' => 'bi-file-earmark-richtext', 'url' => 'admin/pages', 'permission' => 'pages.view'), + array('label' => 'Menu builder', 'icon' => 'bi-list', 'url' => 'admin/menus', 'permission' => 'menus.view'), array('section' => 'System'), array('label' => 'Settings', 'icon' => 'bi-gear', 'url' => 'admin/settings', 'permission' => 'settings.manage'), array('label' => 'Login security', 'icon' => 'bi-shield-exclamation', 'url' => 'admin/security', 'permission' => 'security.view'), diff --git a/application/config/permissions.php b/application/config/permissions.php index 43bfa4c..d197117 100644 --- a/application/config/permissions.php +++ b/application/config/permissions.php @@ -88,3 +88,9 @@ 'admin' => array('*'), 'user' => array(), ); + +/* +| Permissions that '*' above does NOT include: they are as powerful as a developer's +| access, so only super_admin has them until somebody grants them on the Roles page. +*/ +$config['not_in_wildcard'] = array('pages.html'); diff --git a/application/config/routes.php b/application/config/routes.php index 2d4b0f3..69e84c7 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -98,3 +98,19 @@ $route['admin/audit'] = 'audit/audit_log/index'; $route['admin/audit/data'] = 'audit/audit_log/data'; $route['admin/audit/(\d+)'] = 'audit/audit_log/show/$1'; + +// Menu builder (application/modules/menus): the admin sidebar, built without touching menu.php. +$route['admin/menus'] = 'menus/menu_builder/index'; +$route['admin/menus/create'] = 'menus/menu_builder/create'; +$route['admin/menus/edit/(\d+)'] = 'menus/menu_builder/edit/$1'; +$route['admin/menus/delete/(\d+)'] = 'menus/menu_builder/delete/$1'; +$route['admin/menus/reorder'] = 'menus/menu_builder/reorder'; + +// Page builder (application/modules/pages): content pages served at /page/{address}. +$route['admin/pages'] = 'pages/pages/index'; +$route['admin/pages/data'] = 'pages/pages/data'; +$route['admin/pages/create'] = 'pages/pages/create'; +$route['admin/pages/edit/(\d+)'] = 'pages/pages/edit/$1'; +$route['admin/pages/delete/(\d+)'] = 'pages/pages/delete/$1'; +$route['page/([a-z0-9][a-z0-9-]{0,149})'] = 'pages/pages/view/$1'; + diff --git a/application/helpers/datatable_helper.php b/application/helpers/datatable_helper.php index 5de0bda..40c7f32 100644 --- a/application/helpers/datatable_helper.php +++ b/application/helpers/datatable_helper.php @@ -77,7 +77,7 @@ function datatable_table($id, $url, array $columns, array $options = []) $attrs['data-filters'] = (string) $options['filters']; } if (isset($options['order'])) { - $attrs['data-order'] = (int) $options['order'][0].','.(strtolower((string) ($options['order'][1] ?? 'asc')) === 'desc' ? 'desc' : 'asc'); + $attrs['data-initial-order'] = (int) $options['order'][0].','.(strtolower((string) ($options['order'][1] ?? 'asc')) === 'desc' ? 'desc' : 'asc'); } $html = 'config->load('menu', true, true); $items = $CI->config->item('menu', 'menu') ?: []; + + // Items built with Admin -> Menu builder (application/modules/menus), merged after the + // file-based ones. Quiet and guarded: a missing table or an unreadable row must never + // take the sidebar (or the page) down. + try { + if (!isset($CI->menu_model)) { + $CI->load->model('menus/menu_model', 'menu_model'); + } + $built = $CI->menu_model->sidebar_items(); + if ($built !== []) { + $items = array_merge($items, $built); + } + } catch (\Throwable $e) { + log_message('error', 'Menu builder items skipped: '.get_class($e).': '.$e->getMessage()); + } + // Modules made with the CRUD generator (each already checked against the user's permissions). $modules = $CI->crud_store->menu_entries(); if ($modules !== []) { @@ -369,3 +385,17 @@ function brand_mark() return ''; } } + +if (!function_exists('menu_href')) { + /** + * The address of a sidebar entry, ready to put inside href="...": a page of this site goes + * through site_url(), an http(s)/mailto/tel address stays as it is, and anything else + * (javascript:, data:, ...) becomes "#". Not escaped: wrap it in htmlspecialchars(). + */ + function menu_href($url) + { + $resolved = \App\Page\Links::resolve((string) $url, 'site_url'); + + return $resolved ?? '#'; + } +} diff --git a/application/migrations/20261002000000_create_roles_tables.php b/application/migrations/20261002000000_create_roles_tables.php index 4c5bab5..803a640 100644 --- a/application/migrations/20261002000000_create_roles_tables.php +++ b/application/migrations/20261002000000_create_roles_tables.php @@ -58,6 +58,7 @@ public function up() $config = array(); include APPPATH.'config/permissions.php'; $defaults = $config['default_role_permissions'] ?? array(); + $notInWildcard = $config['not_in_wildcard'] ?? array(); $files = array_merge(array(APPPATH.'config/permissions.php'), glob(APPPATH.'modules/*/config/permissions.php') ?: array()); $all = PermissionRegistry::fromFiles($files)->keys(); @@ -67,7 +68,7 @@ public function up() if (!$role) { continue; } - $granted = in_array('*', $permissions, true) ? $all : array_intersect($permissions, $all); + $granted = in_array('*', $permissions, true) ? array_diff($all, $notInWildcard) : array_intersect($permissions, $all); foreach ($granted as $permission) { $this->db->insert('role_permissions', array('role_id' => $role->id, 'permission' => $permission)); } diff --git a/application/migrations/20261006000000_create_menus_table.php b/application/migrations/20261006000000_create_menus_table.php new file mode 100644 index 0000000..764bc80 --- /dev/null +++ b/application/migrations/20261006000000_create_menus_table.php @@ -0,0 +1,40 @@ + Menus (application/modules/menus). + * + * Rows are merged with application/config/menu.php by menu_items(); a row + * with type "section" is a small heading, anything else is a link (which may + * have children through parent_id). + */ +class Migration_Create_menus_table extends CI_Migration +{ + public function up() + { + $this->dbforge->add_field(array( + 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true), + 'parent_id' => array('type' => 'INT', 'unsigned' => true, 'null' => true), + 'type' => array('type' => 'VARCHAR', 'constraint' => 20, 'default' => 'link'), // link | section + 'label' => array('type' => 'VARCHAR', 'constraint' => 120), + 'icon' => array('type' => 'VARCHAR', 'constraint' => 60, 'null' => true), + 'url' => array('type' => 'VARCHAR', 'constraint' => 255, 'null' => true), + 'permission' => array('type' => 'VARCHAR', 'constraint' => 100, 'null' => true), + 'super_admin' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 0), + 'position' => array('type' => 'INT', 'unsigned' => true, 'default' => 0), + 'is_active' => array('type' => 'TINYINT', 'constraint' => 1, 'default' => 1), + 'created_at' => array('type' => 'DATETIME', 'null' => true), + 'updated_at' => array('type' => 'DATETIME', 'null' => true), + )); + $this->dbforge->add_key('id', true); + $this->dbforge->create_table('menus'); + + $this->db->query('CREATE INDEX menus_parent_id ON menus (parent_id)'); + $this->db->query('CREATE INDEX menus_position ON menus (position)'); + } + + public function down() + { + $this->dbforge->drop_table('menus'); + } +} diff --git a/application/migrations/20261007000000_create_pages_table.php b/application/migrations/20261007000000_create_pages_table.php new file mode 100644 index 0000000..41a0f50 --- /dev/null +++ b/application/migrations/20261007000000_create_pages_table.php @@ -0,0 +1,35 @@ + Pages (application/modules/pages). + * + * The sections column holds the whole layout as JSON (see App\Page\SectionTypes) + * and is rendered by the pages module's view controller at /page/{slug}. + */ +class Migration_Create_pages_table extends CI_Migration +{ + public function up() + { + $this->dbforge->add_field(array( + 'id' => array('type' => 'INT', 'unsigned' => true, 'auto_increment' => true), + 'slug' => array('type' => 'VARCHAR', 'constraint' => 150), + 'title' => array('type' => 'VARCHAR', 'constraint' => 190), + 'status' => array('type' => 'VARCHAR', 'constraint' => 20, 'default' => 'draft'), // draft | published + 'sections' => array('type' => 'TEXT', 'null' => true), + 'meta_description' => array('type' => 'VARCHAR', 'constraint' => 300, 'null' => true), + 'created_at' => array('type' => 'DATETIME', 'null' => true), + 'updated_at' => array('type' => 'DATETIME', 'null' => true), + )); + $this->dbforge->add_key('id', true); + $this->dbforge->add_key('slug'); + $this->dbforge->create_table('pages'); + + $this->db->query('CREATE UNIQUE INDEX pages_slug ON pages (slug)'); + } + + public function down() + { + $this->dbforge->drop_table('pages'); + } +} diff --git a/application/migrations/20261009000000_grant_menus_pages_permissions.php b/application/migrations/20261009000000_grant_menus_pages_permissions.php new file mode 100644 index 0000000..973ca8d --- /dev/null +++ b/application/migrations/20261009000000_grant_menus_pages_permissions.php @@ -0,0 +1,34 @@ +db->get_where('roles', array('slug' => 'admin'))->row(); + if (!$admin) { + return; + } + + foreach ($this->permissions as $permission) { + $exists = $this->db->where(array('role_id' => $admin->id, 'permission' => $permission))->count_all_results('role_permissions') > 0; + if (!$exists) { + $this->db->insert('role_permissions', array('role_id' => $admin->id, 'permission' => $permission)); + } + } + } + + public function down() + { + $this->db->where_in('permission', array_merge($this->permissions, array('pages.html')))->delete('role_permissions'); + } +} diff --git a/application/modules/menus/config/permissions.php b/application/modules/menus/config/permissions.php new file mode 100644 index 0000000..cf5a236 --- /dev/null +++ b/application/modules/menus/config/permissions.php @@ -0,0 +1,23 @@ + Menus edits the admin sidebar. The built-in items in +| application/config/menu.php are always shown; these permissions only +| decide who may change the menu. +*/ + +$config['permissions']['menus'] = [ + 'label' => 'Menu builder', + 'icon' => 'bi-list', + 'permissions' => [ + 'view' => 'See the menu builder', + 'create' => 'Add menu items', + 'edit' => 'Edit and reorder menu items', + 'delete' => 'Delete menu items', + ], +]; diff --git a/application/modules/menus/controllers/Menu_builder.php b/application/modules/menus/controllers/Menu_builder.php new file mode 100644 index 0000000..580f444 --- /dev/null +++ b/application/modules/menus/controllers/Menu_builder.php @@ -0,0 +1,266 @@ + Menus: build the admin sidebar without touching menu.php. + * + * Routes (application/config/routes.php): + * GET /admin/menus menus.view the tree, drag to reorder + * GET|POST /admin/menus/create menus.create + * GET|POST /admin/menus/edit/{id} menus.edit + * POST /admin/menus/delete/{id} menus.delete + * POST /admin/menus/reorder menus.edit new order (JSON) + * + * The items in application/config/menu.php stay; what is built here is merged + * into the sidebar by menu_items() (application/helpers/theme_helper.php). + * A section row is a heading, a link row is an entry (links may be nested + * one level, depth 2 in the builder). + * + * @property Template $template + * @property Menu_model $menu_model + * @property Audit $audit + * @property Rbac $rbac + * @property CI_Input $input + * @property CI_Output $output + * @property CI_Form_validation $form_validation + */ +class Menu_builder extends MX_Controller +{ + public function __construct() + { + parent::__construct(); + require_login(); + $this->load->helper('form'); + $this->load->model('menus/menu_model', 'menu_model'); + } + + public function index() + { + require_permission('menus.view'); + + $this->template + ->set_title('Menu builder') + ->set_subtitle('Drag an item onto another to nest it. Changes appear in the sidebar straight away.') + ->set_breadcrumbs(['Home' => '', 'Menu builder' => null]) + ->add_foot('') + ->render('index', [ + 'items' => $this->menu_model->all(), + 'available' => $this->menu_model->available(), + 'can_create' => can('menus.create'), + 'can_edit' => can('menus.edit'), + 'can_delete' => can('menus.delete'), + 'csrf_name' => $this->security->get_csrf_token_name(), + 'csrf_hash' => $this->security->get_csrf_hash(), + ], 'admin'); + } + + public function create() + { + require_permission('menus.create'); + + if ($this->input->method() === 'post') { + $this->save(null); + + return; + } + + $this->form(null, $this->menu_model->defaults(), []); + } + + public function edit($id = 0) + { + require_permission('menus.edit'); + + $item = $this->menu_model->find((int) $id); + if (!$item) { + show_404(); + } + + if ($this->input->method() === 'post') { + $this->save($item); + + return; + } + + $this->form($item, $item, []); + } + + public function delete($id = 0) + { + if ($this->input->method() !== 'post') { + show_404(); + } + require_permission('menus.delete'); + + $item = $this->menu_model->find((int) $id); + if (!$item) { + show_404(); + } + + if ($this->menu_model->delete((int) $id)) { + $this->audit->deleted('menus', $item['id'], $item['label'], $item); + flash('success', 'Menu item deleted. Its children moved up one level.'); + } else { + flash('error', 'The menu could not be changed. Run the migrations first: php index.php console migrate'); + } + + redirect('admin/menus'); + } + + /** + * The order the builder dropped things into: [[id, depth], ...]. + */ + public function reorder() + { + if ($this->input->method() !== 'post') { + $this->json(['ok' => false, 'message' => 'POST only.'], 405); + + return; + } + require_permission('menus.edit'); + + $raw = json_decode((string) $this->input->post('order'), true); + $order = []; + foreach ((array) $raw as $pair) { + if (is_array($pair) && isset($pair[0], $pair[1])) { + $order[] = [(int) $pair[0], (int) $pair[1]]; + } + } + + $ok = $this->menu_model->reorder($order); + if ($ok) { + $this->audit->event('reorder', 'menus', null, 'Sidebar menu', ['order' => implode(',', array_map(static function ($p) { + return $p[0].':'.$p[1]; + }, $order))]); + } + + $this->json(['ok' => $ok, 'message' => $ok ? 'Menu saved.' : 'The order could not be saved.'], $ok ? 200 : 500); + } + + /** + * @param array|null $item + * @param array $values + * @param array $errors + */ + protected function form($item, array $values, array $errors) + { + $parents = array_values(array_filter($this->menu_model->all(), static function ($row) use ($item) { + // A link may be nested under another link, never under itself. + return $row['type'] === Menu_model::TYPE_LINK && (int) $row['id'] !== (int) ($item['id'] ?? 0); + })); + + $this->template + ->set_title($item === null ? 'New menu item' : 'Edit '.$item['label']) + ->set_breadcrumbs(['Home' => '', 'Menu builder' => 'admin/menus', ($item === null ? 'New item' : 'Edit') => null]) + ->render('form', [ + 'item' => $item, + 'values' => $values, + 'errors' => $errors, + 'parents' => $parents, + 'permissions' => $this->rbac->registry()->keys(), + ], 'admin'); + } + + /** + * @param array|null $item + */ + protected function save($item) + { + $input = (array) $this->input->post(null, false); + $values = [ + 'type' => ($input['type'] ?? '') === Menu_model::TYPE_SECTION ? Menu_model::TYPE_SECTION : Menu_model::TYPE_LINK, + 'label' => trim((string) ($input['label'] ?? '')), + 'icon' => preg_replace('/[^a-z0-9 \-]/i', '', (string) ($input['icon'] ?? '')), + 'url' => trim((string) ($input['url'] ?? '')), + 'permission' => trim((string) ($input['permission'] ?? '')), + 'super_admin' => isset($input['super_admin']) ? 1 : 0, + 'is_active' => isset($input['is_active']) ? 1 : 0, + 'parent_id' => (int) ($input['parent_id'] ?? 0), + ]; + + $errors = []; + if ($values['label'] === '' || mb_strlen($values['label']) > 120) { + $errors['label'] = 'Enter a label of up to 120 characters.'; + } + if ($values['type'] === Menu_model::TYPE_LINK) { + if ($values['url'] === '' || mb_strlen($values['url']) > 255) { + $errors['url'] = 'A link needs a URL of up to 255 characters (e.g. dashboard or https://…).'; + } elseif (Links::safe($values['url']) === null) { + $errors['url'] = 'That address is not allowed. Use a page of this site (dashboard, page/about) or an http(s):// address.'; + } + } else { + $values['url'] = ''; + } + if ($values['permission'] !== '' && !in_array($values['permission'], $this->rbac->registry()->keys(), true)) { + $errors['permission'] = 'Unknown permission "'.mb_substr($values['permission'], 0, 40).'". Pick one from the list.'; + } + // A section is a heading: always top level, and it cannot hold links. + $parents = []; + foreach ($this->menu_model->all() as $row) { + $parents[(int) $row['id']] = (int) ($row['parent_id'] ?? 0); + } + $itemId = (int) ($item['id'] ?? 0); + $hasChildren = $itemId > 0 && in_array($itemId, $parents, true); + + if ($values['type'] === Menu_model::TYPE_SECTION) { + $values['parent_id'] = 0; + if ($hasChildren) { + $errors['type'] = 'This item has links under it. Move or delete them before turning it into a section.'; + } + } elseif ($values['parent_id'] !== 0) { + $parent = $this->menu_model->find($values['parent_id']); + if ($parent === null || $parent['type'] !== Menu_model::TYPE_LINK) { + $errors['parent_id'] = 'Pick a link from the list as the parent.'; + } elseif (!MenuTree::canNest($parents, $itemId, $values['parent_id'], Menu_model::MAX_DEPTH)) { + $errors['parent_id'] = 'That parent would put the item inside itself or nest the menu too deep (at most '.(Menu_model::MAX_DEPTH + 1).' levels).'; + } + } + + if ($errors !== []) { + if ($item === null) { + $this->form(null, $values + $this->menu_model->defaults(), $errors); + } else { + $this->form($item, $values + $item, $errors); + } + + return; + } + + if ($item === null) { + $id = $this->menu_model->create($values); + if ($id === null) { + flash('error', 'The item could not be saved. Run the migrations first: php index.php console migrate'); + redirect('admin/menus'); + } + $this->audit->created('menus', $id, $values['label'], $values); + flash('success', 'Menu item added.'); + } else { + if (!$this->menu_model->update((int) $item['id'], $values)) { + flash('error', 'The item could not be saved. Run the migrations first: php index.php console migrate'); + redirect('admin/menus'); + } + $this->audit->updated('menus', $item['id'], $values['label'], $item, $values + $item); + flash('success', 'Menu item saved.'); + } + + redirect('admin/menus'); + } + + /** + * @param array $payload + */ + protected function json(array $payload, $status = 200) + { + $this->output + ->set_status_header($status) + ->set_header('Cache-Control: no-store') + ->set_content_type('application/json', 'utf-8') + ->set_output(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)); + } +} diff --git a/application/modules/menus/models/Menu_model.php b/application/modules/menus/models/Menu_model.php new file mode 100644 index 0000000..0bd43d2 --- /dev/null +++ b/application/modules/menus/models/Menu_model.php @@ -0,0 +1,294 @@ + Menus (application/modules/menus). + * + * Rows are stored flat: parent_id + position. menu_items() in + * application/helpers/theme_helper.php asks for sidebar_items() and merges the + * result with application/config/menu.php, so a menu built here appears in the + * sidebar next to the built-in entries. + * + * Every query is quiet: before the migration has run — or with no database at + * all — the menu simply stays config-only and nothing breaks. + * + * @property CI_DB_query_builder $db + */ +class Menu_model extends CI_Model +{ + public const TYPE_LINK = 'link'; + public const TYPE_SECTION = 'section'; + + /** Deepest nesting the builder allows (keeps the sidebar readable). */ + public const MAX_DEPTH = 2; + + /** @var bool|null table existence, checked once per request */ + protected $available; + + /** + * Every row, ordered for display: top level first, children under their parent. + * + * @return array> + */ + public function all() + { + $rows = $this->rows(); + $out = []; + + $byParent = []; + foreach ($rows as $row) { + $byParent[(int) ($row['parent_id'] ?? 0)][] = $row; + } + + $walk = function ($parentId, $depth) use (&$walk, &$out, $byParent) { + foreach ($byParent[$parentId] ?? [] as $row) { + $row['depth'] = $depth; + $out[] = $row; + $walk((int) $row['id'], $depth + 1); + } + }; + $walk(0, 0); + + return $out; + } + + /** + * @param int $id + * + * @return array|null + */ + public function find($id) + { + foreach ($this->rows() as $row) { + if ((int) $row['id'] === (int) $id) { + return $row; + } + } + + return null; + } + + /** + * The items a new row starts out with (used for defaults and validation). + * + * @return array + */ + public function defaults() + { + return ['type' => self::TYPE_LINK, 'label' => '', 'icon' => '', 'url' => '', 'permission' => '', 'super_admin' => 0, 'is_active' => 1]; + } + + /** + * @param array $data + * + * @return int|null the new id, null on failure + */ + public function create(array $data) + { + if (!$this->available()) { + return null; + } + + $data['position'] = (int) $this->db->select_max('position')->get('menus')->row()->position + 1; + $data['created_at'] = date('Y-m-d H:i:s'); + $data['updated_at'] = date('Y-m-d H:i:s'); + + return $this->db->insert('menus', $data) ? (int) $this->db->insert_id() : null; + } + + /** + * @param int $id + * @param array $data + */ + public function update($id, array $data) + { + if (!$this->available()) { + return false; + } + + $data['updated_at'] = date('Y-m-d H:i:s'); + + return (bool) $this->db->where('id', (int) $id)->update('menus', $data); + } + + /** + * Removes an item; its children move up to its parent, so the sidebar + * never shows an orphan. + */ + public function delete($id) + { + if (!$this->available()) { + return false; + } + + $row = $this->find($id); + if ($row === null) { + return false; + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + $this->db->trans_start(); + $this->db->where('parent_id', (int) $id)->update('menus', ['parent_id' => (int) ($row['parent_id'] ?? 0)]); + $this->db->where('id', (int) $id)->delete('menus'); + $this->db->trans_complete(); + $this->db->db_debug = $debug; + + return $this->db->trans_status() !== false; + } + + /** + * Rebuilds parent_id / position from the order the builder posted: + * a flat list of [id, depth] pairs, depth 0 = top level. + * + * @param array $order + * + * @return bool + */ + public function reorder(array $order) + { + if (!$this->available() || $order === []) { + return false; + } + + $known = []; + foreach ($this->rows() as $row) { + $known[(int) $row['id']] = (int) ($row['parent_id'] ?? 0); + } + + $parents = MenuTree::parents($order, $known, self::MAX_DEPTH); + if ($parents === false) { + return false; + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + $this->db->trans_start(); + $position = 0; + foreach ($parents as $id => $parent) { + $position++; + $this->db->where('id', $id)->update('menus', ['parent_id' => $parent, 'position' => $position, 'updated_at' => date('Y-m-d H:i:s')]); + } + $this->db->trans_complete(); + $this->db->db_debug = $debug; + + return $this->db->trans_status() !== false; + } + + /** + * The rows as menu_items() wants them (label / icon / url / permission / + * children), ready to merge with the config menu. Inactive items, sections + * with nothing under them and links without a URL are skipped. + * + * @return array> + */ + public function sidebar_items() + { + $rows = array_values(array_filter($this->rows(), static function ($row) { + return !empty($row['is_active']); + })); + if ($rows === []) { + return []; + } + + $children = []; + $top = []; + foreach ($rows as $row) { + if ((int) ($row['parent_id'] ?? 0) === 0) { + $top[] = $row; + } else { + $children[(int) $row['parent_id']][] = $row; + } + } + + usort($top, static function ($a, $b) { + return [(int) $a['position'], (int) $a['id']] <=> [(int) $b['position'], (int) $b['id']]; + }); + + $build = function ($rows) use (&$build, $children) { + $out = []; + foreach ($rows as $row) { + if ($row['type'] === self::TYPE_SECTION) { + $out[] = ['section' => (string) $row['label']]; + + continue; + } + if (trim((string) ($row['url'] ?? '')) === '') { + continue; + } + + $item = [ + 'label' => (string) $row['label'], + 'url' => (string) $row['url'], + ]; + if (trim((string) ($row['icon'] ?? '')) !== '') { + $item['icon'] = (string) $row['icon']; + } + if (trim((string) ($row['permission'] ?? '')) !== '') { + $item['permission'] = (string) $row['permission']; + } + if (!empty($row['super_admin'])) { + $item['super_admin'] = true; + } + + $kids = $build($children[(int) $row['id']] ?? []); + if ($kids !== []) { + $item['children'] = $kids; + } + + $out[] = $item; + } + + return $out; + }; + + return $build($top); + } + + /** + * Whether the table exists (checked once; false before the migration). + */ + public function available() + { + if ($this->available !== null) { + return $this->available; + } + + if (!isset($this->db) || !is_object($this->db) || empty($this->db->conn_id)) { + $file = is_file(APPPATH.'config/'.ENVIRONMENT.'/database.php') || is_file(APPPATH.'config/database.php'); + if (!$file) { + return $this->available = false; + } + $this->load->database(); + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + $this->available = (bool) $this->db->table_exists('menus'); + $this->db->db_debug = $debug; + + return $this->available; + } + + /** + * @return array> + */ + protected function rows() + { + if (!$this->available()) { + return []; + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + $result = $this->db->order_by('position ASC, id ASC')->get('menus'); + $this->db->db_debug = $debug; + + return $result === false ? [] : $result->result_array(); + } +} diff --git a/application/modules/menus/views/form.php b/application/modules/menus/views/form.php new file mode 100644 index 0000000..023ae00 --- /dev/null +++ b/application/modules/menus/views/form.php @@ -0,0 +1,122 @@ +|null $item + * @var array $values + * @var array $errors + * @var array> $parents + * @var array $permissions + */ +$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); }; +$invalid = static function ($key) use ($errors) { return isset($errors[$key]) ? ' is-invalid' : ''; }; +$error = static function ($key) use ($errors, $e) { return isset($errors[$key]) ? '
'.$e($errors[$key]).'
' : ''; }; +$type = (string) ($values['type'] ?? 'link'); +?> +
+
+
+
+ + +
+ + + +
+ +
+ + + +
+ +
+
+ + + +
Relative URLs stay on this site; page/your-slug opens a page made in Admin → Pages.
+
+
+ + + + + + + +
A Bootstrap Icons class: icons.getbootstrap.com.
+
+
+ +
+
+ + + +
Up to three levels deep. A heading (section) is always top level.
+
+
+ + + +
+
+ +
+
+
+ > + +
+
+
+
+ > + +
+
+
+ +
+ + Cancel +
+ +
+
+
+
+> +// A section is only a heading: hide everything a link needs. +(function () { + var type = document.getElementById('m_type'); + if (!type) return; + var toggle = function () { + var link = type.value !== 'section'; + document.querySelectorAll('[data-link-only]').forEach(function (el) { + el.classList.toggle('d-none', !link); + }); + }; + type.addEventListener('change', toggle); + toggle(); +})(); + diff --git a/application/modules/menus/views/index.php b/application/modules/menus/views/index.php new file mode 100644 index 0000000..ddcc9d2 --- /dev/null +++ b/application/modules/menus/views/index.php @@ -0,0 +1,68 @@ + Menus: the tree, dragged into shape with assets/js/menus.js. + * + * @var array> $items flat rows, each with 'depth' + * @var bool $available the menus table exists (migrations run) + * @var bool $can_create, $can_edit, $can_delete + * @var string $csrf_name, $csrf_hash + */ +$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); }; +?> + +
+ The menu table does not exist yet. Run php index.php console migrate to build it — the menu below is the file-based one. +
+ + + +
+

Items from application/config/menu.php are always shown; these are added on top.

+ New item +
+ + +
+
+ +

No menu items yet

+ + + + +
+
+ + diff --git a/application/modules/pages/config/permissions.php b/application/modules/pages/config/permissions.php new file mode 100644 index 0000000..0075871 --- /dev/null +++ b/application/modules/pages/config/permissions.php @@ -0,0 +1,22 @@ + Pages builds the content pages served at /page/{slug}. +*/ + +$config['permissions']['pages'] = [ + 'label' => 'Page builder', + 'icon' => 'bi-file-earmark-richtext', + 'permissions' => [ + 'view' => 'See the list of pages', + 'create' => 'Create pages', + 'edit' => 'Edit pages and their layout', + 'delete' => 'Delete pages', + 'html' => 'Write raw HTML in pages (can run scripts for every visitor: give it only to people you trust as much as a developer)', + ], +]; diff --git a/application/modules/pages/controllers/Pages.php b/application/modules/pages/controllers/Pages.php new file mode 100644 index 0000000..6e03690 --- /dev/null +++ b/application/modules/pages/controllers/Pages.php @@ -0,0 +1,341 @@ + Pages builds them, /page/{slug} serves them. + * + * Routes (application/config/routes.php): + * GET /admin/pages pages.view the list (server-side DataTable) + * GET /admin/pages/data pages.view its JSON rows + * GET|POST /admin/pages/create pages.create + * GET|POST /admin/pages/edit/{id} pages.edit + * POST /admin/pages/delete/{id} pages.delete + * GET /page/{slug} public published pages (a draft + * is a preview for editors) + * + * A page is a list of sections (hero, text, features, cta, image, html) stored + * as JSON; see App\Page\SectionTypes for the blocks and their rendering. + * + * Who may write what: pages.create / pages.edit build pages out of the safe blocks + * (text is escaped or rebuilt from an allow-list, addresses must pass Links::safe()). + * The raw "Custom HTML" block can run anything in a visitor's browser, so adding or + * changing one needs pages.html, which only super_admin has until it is granted. + * + * @property Template $template + * @property Page_model $page_model + * @property Audit $audit + * @property Datatable $datatable + * @property CI_Input $input + * @property CI_Output $output + */ +class Pages extends MX_Controller +{ + public function __construct() + { + parent::__construct(); + $this->load->helper('form'); + $this->load->model('pages/page_model', 'page_model'); + } + + // ------------------------------------------------------------------ Admin + + public function index() + { + require_permission('pages.view'); + + $this->template + ->set_title('Pages') + ->set_subtitle('Pages built here are served at /page/{address}. A draft is only visible to editors.') + ->set_breadcrumbs(['Home' => '', 'Pages' => null]) + ->render('index', [ + 'available' => $this->page_model->available(), + 'can_create' => can('pages.create'), + ], 'admin'); + } + + /** + * JSON rows for the list. Only the page being shown is read (never the whole table). + */ + public function data() + { + require_permission('pages.view'); + + $canEdit = can('pages.edit'); + $canDelete = can('pages.delete'); + $e = static function ($v) { + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); + }; + + $this->datatable + ->from(static function ($db) { + $db->from('pages p'); + }) + ->select('p.id, p.title, p.slug, p.status, p.meta_description, p.updated_at, p.sections') + ->column('title', 'p.title') + ->column('slug', 'p.slug') + ->column('sections', null) + ->column('status', 'p.status') + ->column('updated', 'p.updated_at', false) + ->column('actions', null) + ->default_order(['p.updated_at DESC', 'p.id DESC']) + ->respond(function ($r) use ($canEdit, $canDelete, $e) { + $actions = ''; + if ($canEdit) { + $actions .= ' '; + } + if ($canDelete) { + $actions .= ' '.form_open('admin/pages/delete/'.(int) $r['id'], ['class' => 'd-inline', 'data-confirm' => 'Delete the page "'.$r['title'].'"?']) + .''.form_close(); + } + + return [ + 'title' => ''.$e($r['title']).'' + .(trim((string) $r['meta_description']) !== '' ? '
'.$e($r['meta_description']).'
' : ''), + 'slug' => '/page/'.$e($r['slug']).'', + 'sections' => (string) count(SectionTypes::toArray($r['sections'] ?? '')), + 'status' => $r['status'] === Page_model::STATUS_PUBLISHED ? 'published' : 'draft', + 'updated' => $r['updated_at'] ? ''.$e(date('M j, Y H:i', strtotime($r['updated_at']))).'' : '', + 'actions' => $actions, + ]; + }); + } + + public function create() + { + require_permission('pages.create'); + + if ($this->input->method() === 'post') { + $this->save(null); + + return; + } + + $this->form(null, ['title' => '', 'slug' => '', 'status' => Page_model::STATUS_DRAFT, 'meta_description' => '', 'sections' => []], []); + } + + public function edit($id = 0) + { + require_permission('pages.edit'); + + $page = $this->page_model->find((int) $id); + if (!$page) { + show_404(); + } + + if ($this->input->method() === 'post') { + $this->save($page); + + return; + } + + $this->form($page, $page, []); + } + + public function delete($id = 0) + { + if ($this->input->method() !== 'post') { + show_404(); + } + require_permission('pages.delete'); + + $page = $this->page_model->find((int) $id); + if (!$page) { + show_404(); + } + + if ($this->page_model->delete((int) $id)) { + $this->audit->deleted('pages', $page['id'], $page['title'], $page); + flash('success', 'Page deleted.'); + } else { + flash('error', 'The page could not be deleted. Run the migrations first: php index.php console migrate'); + } + + redirect('admin/pages'); + } + + // ----------------------------------------------------------------- Public + + /** + * GET /page/{slug} — published pages for everyone; a draft is a preview + * for whoever may edit pages. + */ + public function view($slug = '') + { + $page = $this->page_model->find_by_slug($slug, true); + + if ($page === null) { + // Not published: editors may preview it, everyone else gets a 404. + $preview = is_logged_in() && can('pages.edit') ? $this->page_model->find_by_slug($slug, false) : null; + if ($preview === null) { + show_404(); + + return; + } + $page = $preview; + $preview_note = true; + } else { + $preview_note = false; + } + + if (!empty($preview_note)) { + $this->output->set_header('Cache-Control: private, no-store'); + } + + $description = trim((string) ($page['meta_description'] ?? '')); + if ($description !== '') { + $this->template->add_head(''); + } + + $this->template + ->set_title($page['title']) + ->set_breadcrumbs(['Home' => '', $page['title'] => null]) + ->render('view', [ + 'page' => $page, + 'content' => SectionTypes::renderAll($page['sections'], static function ($address) { + return Links::resolve($address, 'site_url'); + }), + 'preview' => !empty($preview_note), + 'edit_url' => can('pages.edit') ? site_url('admin/pages/edit/'.(int) $page['id']) : null, + ], 'public'); + } + + // ------------------------------------------------------------- Form & save + + /** + * @param array $page + * @param array $values + * @param array $errors + */ + protected function form($page, array $values, array $errors) + { + $this->template + ->set_title($page === null ? 'New page' : 'Edit '.$page['title']) + ->set_breadcrumbs(['Home' => '', 'Pages' => 'admin/pages', ($page === null ? 'New page' : 'Edit') => null]) + ->add_foot('') + ->render('form', [ + 'page' => $page, + 'values' => $values, + 'errors' => $errors, + 'catalog' => [ + 'labels' => SectionTypes::labels(), + 'fields' => array_combine(array_keys(SectionTypes::labels()), array_map([SectionTypes::class, 'fields'], array_keys(SectionTypes::labels()))), + ], + ], 'admin'); + } + + /** + * Without pages.html a page may keep the Custom HTML blocks it already has, exactly as + * they are, but not add or change one. + * + * @param array> $sections + * @param array|null $page + */ + protected function html_blocks_unchanged(array $sections, $page) + { + $existing = []; + foreach ((array) ($page['sections'] ?? []) as $old) { + if (($old['type'] ?? '') === SectionTypes::HTML) { + $existing[] = (string) ($old['settings']['html'] ?? ''); + } + } + + foreach ($sections as $section) { + if ($section['type'] === SectionTypes::HTML && !in_array((string) ($section['settings']['html'] ?? ''), $existing, true)) { + return false; + } + } + + return true; + } + + /** + * @param array|null $page + */ + protected function save($page) + { + $input = (array) $this->input->post(null, false); + + $title = trim((string) ($input['title'] ?? '')); + $status = ($input['status'] ?? '') === Page_model::STATUS_PUBLISHED ? Page_model::STATUS_PUBLISHED : Page_model::STATUS_DRAFT; + + // The layout arrives as indexed form fields (sections[0][settings][heading]) + // when JS renumbers them; sections_json is the fallback written by the + // editor when it only changed the JSON (or before JS ran). + $sections = null; + if (array_key_exists('sections', $input) && is_array($input['sections'])) { + $sections = array_values($input['sections']); + } + if ($sections === null) { + $sections = json_decode((string) ($input['sections_json'] ?? ''), true); + } + $sections = SectionTypes::normalize(is_array($sections) ? $sections : []); + + $slug = $page === null ? trim((string) ($input['slug'] ?? '')) : trim((string) ($input['slug'] ?? ($page['slug'] ?? ''))); + if ($slug !== '') { + $slug = Page_model::slugify($slug); + } + + $values = [ + 'title' => $title, + 'slug' => $slug, + 'status' => $status, + 'meta_description' => trim((string) ($input['meta_description'] ?? '')), + 'sections' => $sections, + ]; + + $errors = []; + if ($title === '' || mb_strlen($title) > 190) { + $errors['title'] = 'Enter a title of up to 190 characters.'; + } + if (mb_strlen($values['meta_description']) > 300) { + $errors['meta_description'] = 'Keep the description under 300 characters.'; + } + if ($sections === []) { + $errors['sections'] = 'Add at least one section to the layout.'; + } elseif (!can('pages.html') && !$this->html_blocks_unchanged($sections, $page)) { + $errors['sections'] = 'Custom HTML blocks can only be added or changed by someone with the "Write raw HTML in pages" permission (pages.html).'; + } + + if ($errors === []) { + if ($slug === '') { + $slug = $this->page_model->free_slug($title, $page['id'] ?? null); + } + if ($this->page_model->slug_taken($slug, $page['id'] ?? null)) { + $errors['slug'] = 'That address is already used by another page.'; + } else { + $values['slug'] = $slug; + } + } + + if ($errors !== []) { + $this->form($page, $values, $errors); + + return; + } + + if ($page === null) { + $id = $this->page_model->create($values); + if ($id === null) { + flash('error', 'The page could not be saved. Run the migrations first: php index.php console migrate'); + redirect('admin/pages'); + } + $this->audit->created('pages', $id, $values['title'], $values + ['slug' => $slug]); + flash('success', 'Page created. '.site_url('page/'.$slug)); + } else { + if (!$this->page_model->update((int) $page['id'], $values)) { + flash('error', 'The page could not be saved. Run the migrations first: php index.php console migrate'); + redirect('admin/pages'); + } + $this->audit->updated('pages', $page['id'], $values['title'], $page + ['slug' => $page['slug']], $values + ['slug' => $slug]); + flash('success', 'Page saved.'); + } + + redirect('admin/pages'); + } +} diff --git a/application/modules/pages/models/Page_model.php b/application/modules/pages/models/Page_model.php new file mode 100644 index 0000000..ffac2bb --- /dev/null +++ b/application/modules/pages/models/Page_model.php @@ -0,0 +1,264 @@ + Pages; served at /page/{slug}. + * + * The layout lives in `sections` as JSON (App\Page\SectionTypes). Every query + * is quiet: before the migration has run — or with no database at all — pages + * simply do not exist and the routes fall through to 404. + * + * @property CI_DB_query_builder $db + */ +class Page_model extends CI_Model +{ + public const STATUS_DRAFT = 'draft'; + public const STATUS_PUBLISHED = 'published'; + + /** @var bool|null table existence, checked once per request */ + protected $available; + + /** + * @param int $id + * + * @return array|null + */ + public function find($id) + { + return $this->one('id', (int) $id, false); + } + + /** + * A page by its public slug; only published ones (or any, for a draft + * preview by someone who may edit pages). + * + * @param string $slug + * @param bool $publishedOnly + * + * @return array|null + */ + public function find_by_slug($slug, $publishedOnly = true) + { + $row = $this->one('slug', (string) $slug, $publishedOnly); + + return $row; + } + + /** + * @param array $data (slug, title, status, sections, meta_description) + * + * @return int|null new id + */ + public function create(array $data) + { + if (!$this->available()) { + return null; + } + + $now = date('Y-m-d H:i:s'); + $row = $this->clean($data) + ['created_at' => $now, 'updated_at' => $now]; + + return $this->quiet(function ($db) use ($row) { + return $db->insert('pages', $row) ? (int) $db->insert_id() : null; + }) ?: null; + } + + /** + * @param int $id + * @param array $data + */ + public function update($id, array $data) + { + if (!$this->available()) { + return false; + } + + $row = $this->clean($data) + ['updated_at' => date('Y-m-d H:i:s')]; + + return (bool) $this->quiet(function ($db) use ($id, $row) { + return $db->where('id', (int) $id)->update('pages', $row); + }); + } + + /** + * @param int $id + */ + public function delete($id) + { + if (!$this->available()) { + return false; + } + + return (bool) $this->quiet(function ($db) use ($id) { + return $db->where('id', (int) $id)->delete('pages'); + }); + } + + /** + * A slug that is free (used by the admin form when it makes one up). + * + * @param string $wanted + * @param int|null $ignoreId + * + * @return string + */ + public function free_slug($wanted, $ignoreId = null) + { + $slug = $this->slugify($wanted); + if ($slug === '') { + $slug = 'page'; + } + if (!$this->available()) { + return $slug; + } + + $candidate = $slug; + $i = 2; + while ($this->slug_exists($candidate, $ignoreId)) { + $candidate = $slug.'-'.$i++; + } + + return $candidate; + } + + /** + * Whether another page already uses this slug. + * + * @param string $slug + * @param int|null $ignoreId + */ + public function slug_taken($slug, $ignoreId = null) + { + return $this->slug_exists($slug, $ignoreId); + } + + public static function slugify($text) + { + $text = strtolower(trim((string) $text)); + $text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? ''; + $text = trim($text, '-'); + + return mb_substr($text, 0, 150); + } + + public function available() + { + if ($this->available !== null) { + return $this->available; + } + + if (!isset($this->db) || !is_object($this->db) || empty($this->db->conn_id)) { + $file = is_file(APPPATH.'config/'.ENVIRONMENT.'/database.php') || is_file(APPPATH.'config/database.php'); + if (!$file) { + return $this->available = false; + } + $this->load->database(); + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + $this->available = (bool) $this->db->table_exists('pages'); + $this->db->db_debug = $debug; + + return $this->available; + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + /** + * @param string $column + * @param mixed $value + * @param bool $publishedOnly + * + * @return array|null + */ + protected function one($column, $value, $publishedOnly) + { + if (!$this->available()) { + return null; + } + + $row = $this->quiet(function ($db) use ($column, $value, $publishedOnly) { + if ($publishedOnly) { + $db->where('status', self::STATUS_PUBLISHED); + } + + return $db->where($column, $value)->limit(1)->get('pages')->row_array(); + }); + + if (!is_array($row) || $row === []) { + return null; + } + $row['sections'] = SectionTypes::toArray($row['sections'] ?? ''); + + return $row; + } + + /** + * @param array $data + * + * @return array + */ + protected function clean(array $data) + { + $status = ($data['status'] ?? '') === self::STATUS_PUBLISHED ? self::STATUS_PUBLISHED : self::STATUS_DRAFT; + + return [ + 'slug' => mb_substr((string) ($data['slug'] ?? ''), 0, 150), + 'title' => mb_substr(trim((string) ($data['title'] ?? '')), 0, 190), + 'status' => $status, + 'sections' => SectionTypes::encode($data['sections'] ?? []), + 'meta_description' => mb_substr(trim((string) ($data['meta_description'] ?? '')), 0, 300), + ]; + } + + /** + * @param string $slug + * @param int|null $ignoreId + */ + protected function slug_exists($slug, $ignoreId = null) + { + $found = $this->quiet(function ($db) use ($slug, $ignoreId) { + if ($ignoreId) { + $db->where('id !=', (int) $ignoreId); + } + + return $db->where('slug', $slug)->limit(1)->count_all_results('pages') > 0; + }); + + return (bool) $found; + } + + /** + * Runs $fn with db_debug off, so a missing table or unreachable server + * returns instead of taking the page down. + * + * @param callable $fn + * + * @return mixed + */ + protected function quiet($fn) + { + if (!$this->available()) { + return null; + } + + $debug = $this->db->db_debug; + $this->db->db_debug = false; + + try { + return $fn($this->db); + } catch (\Throwable $e) { + return null; + } finally { + $this->db->db_debug = $debug; + } + } +} diff --git a/application/modules/pages/views/form.php b/application/modules/pages/views/form.php new file mode 100644 index 0000000..c102916 --- /dev/null +++ b/application/modules/pages/views/form.php @@ -0,0 +1,143 @@ +|null $page + * @var array $values + * @var array $errors + * @var array{labels: array, fields: array>>} $catalog + */ +$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); }; +$invalid = static function ($key) use ($errors) { return isset($errors[$key]) ? ' is-invalid' : ''; }; +$error = static function ($key) use ($errors, $e) { return isset($errors[$key]) ? '
'.$e($errors[$key]).'
' : ''; }; + +/** One setting as a form field. */ +$field = static function (array $f, $index, $value, $e) { + $name = 'sections['.(int) $index.'][settings]['.$f['name'].']'; + $help = !empty($f['help']) ? '
'.$e($f['help']).'
' : ''; + $label = ''; + + if ($f['type'] === 'textarea') { + $rows = (int) ($f['rows'] ?? 4); + + return '
'.$label + .''.$help.'
'; + } + if ($f['type'] === 'color') { + $v = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string) $value) ? (string) $value : ''; + + return '
'.$label + .''.$help.'
'; + } + + return '
'.$label + .''.$help.'
'; +}; + +$section_card = static function ($index, $type, array $settings, $labels, $fields, $field, $e) { + $out = '
'; + $out .= '
'; + $out .= ''; + $out .= ''.$e(isset($labels[$type]) ? $labels[$type] : $type).''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= ''; + $out .= '
'; + foreach ($fields[$type] ?? [] as $f) { + $out .= $field($f, $index, (string) ($settings[$f['name']] ?? ''), $e); + } + $out .= '
'; + + return $out; +}; +?> + +
Please fix the following: +
+
+ + + 'page-form']) ?> + +
+
+
+
+ + + +
+
+ + +
+
+ +
+ /page/ + +
+ +
Leave empty to build one from the title.
+
+
+ + + +
Shown by Google below the link. Optional.
+
+
+
+
+ +
+
+ Layout + Sections run top to bottom. + + + + +
+
+ +
+ $section) { + echo $section_card($i, (string) ($section['type'] ?? ''), (array) ($section['settings'] ?? []), $catalog['labels'], $catalog['fields'], $field, $e); + } + ?> +
+

> + No sections yet — pick a type above and press “Add section”. +

+ +
+
+ +
+ + Cancel + + Open page + +
+ + + diff --git a/application/modules/pages/views/index.php b/application/modules/pages/views/index.php new file mode 100644 index 0000000..5ebb658 --- /dev/null +++ b/application/modules/pages/views/index.php @@ -0,0 +1,33 @@ + Pages: the list (a server-side DataTable, see Pages::data()). + * + * @var bool $available the pages table exists (migrations run) + * @var bool $can_create + */ +$columns = [ + ['Title', ['priority' => 1]], + ['Address', ['priority' => 3]], + ['Sections', ['priority' => 5, 'orderable' => false, 'class' => 'text-center']], + ['Status', ['priority' => 2]], + ['Updated', ['priority' => 4, 'orderable' => true]], + ['', ['priority' => 1, 'orderable' => false, 'class' => 'text-end text-nowrap']], +]; +?> + +
+ The pages table does not exist yet. Run php index.php console migrate to build it. +
+ + +
+

Every page lives at /page/{address} and can be linked from Admin → Menu builder.

+ + New page + +
+ +
+
+ [4, 'desc'], 'empty' => 'No pages yet.']) ?> +
+
diff --git a/application/modules/pages/views/view.php b/application/modules/pages/views/view.php new file mode 100644 index 0000000..3711c23 --- /dev/null +++ b/application/modules/pages/views/view.php @@ -0,0 +1,24 @@ + $page + * @var string $content SectionTypes::renderAll() + * @var bool $preview a draft being previewed by an editor + * @var string|null $edit_url + */ +$e = static function ($v) { return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); }; +?> + +
+ Draft preview — only editors can see this. + Edit this page +
+ + +
+ +
+ + +

This page has no sections yet.

+ diff --git a/application/src/Menus/MenuTree.php b/application/src/Menus/MenuTree.php new file mode 100644 index 0000000..284fed0 --- /dev/null +++ b/application/src/Menus/MenuTree.php @@ -0,0 +1,134 @@ + $order posted as [id, depth] + * @param array $known id => current parent_id, i.e. every row that must appear + * @param int $maxDepth deepest nesting allowed + * + * @return array|false id => parent id, or false when the order is unusable + * (unknown/duplicate id, missing row, impossible depth) + */ + public static function parents($order, array $known, $maxDepth = 2) + { + if (!is_array($order) || $order === [] || $known === []) { + return false; + } + + $parents = []; + $stack = []; // depth => id: the ancestor on duty at each level + + foreach ($order as $pair) { + if (!is_array($pair) || !isset($pair[0], $pair[1])) { + return false; + } + + $id = (int) $pair[0]; + $depth = (int) $pair[1]; + + if (!isset($known[$id]) || array_key_exists($id, $parents)) { + return false; // unknown or repeated + } + if ($depth < 0 || $depth > $maxDepth) { + return false; + } + + $parent = 0; + for ($d = $depth - 1; $d >= 0; $d--) { + if (isset($stack[$d])) { + $parent = (int) $stack[$d]; + break; + } + } + if ($parent === $id) { + return false; + } + + $parents[$id] = $parent; + $stack[$depth] = $id; + unset($stack[$depth + 1], $stack[$depth + 2]); + } + + if (count($parents) !== count($known)) { + return false; // only part of the menu was sent + } + + // No item may end up inside its own subtree (a cycle would hide it). + foreach ($parents as $id => $parent) { + $seen = [$id]; + while ($parent > 0) { + if (in_array($parent, $seen, true)) { + return false; + } + $seen[] = $parent; + $parent = (int) ($parents[$parent] ?? 0); + } + } + + return $parents; + } + + /** + * Whether an item may be given a new parent without breaking the menu. + * + * Refused when the parent is the item itself or one of its own descendants (a loop + * would hide them), does not exist, or would push the item's subtree below $maxDepth. + * + * @param array $parents id => parent id (0 = top level) of every item + * @param int $id the item being moved/edited + * @param int $newParent 0 for the top level + */ + public static function canNest(array $parents, $id, $newParent, $maxDepth = 2) + { + $id = (int) $id; + $newParent = (int) $newParent; + + if ($newParent === 0) { + return true; + } + if ($newParent === $id || !isset($parents[$newParent])) { + return false; + } + + // Depth of the new parent (top level = 0); a loop in the stored data counts as unusable. + $depth = 0; + $seen = [$newParent]; + for ($p = (int) $parents[$newParent]; $p > 0; $p = (int) ($parents[$p] ?? 0)) { + if ($p === $id || in_array($p, $seen, true)) { + return false; // the parent sits inside the item's own subtree + } + $seen[] = $p; + ++$depth; + } + + // Levels below the item itself (a new item, id 0, has none; top-level rows have parent 0 too). + $height = 0; + $level = $id > 0 ? [$id] : []; + while ($level !== []) { + $next = []; + foreach ($parents as $child => $parent) { + if (in_array((int) $parent, $level, true) && (int) $child !== $id) { + $next[] = (int) $child; + } + } + if ($next !== []) { + ++$height; + } + $level = count($next) > 50 ? [] : $next; // a menu is small; stop on nonsense + } + + return ($depth + 1) + $height <= $maxDepth; + } +} diff --git a/application/src/Page/Html.php b/application/src/Page/Html.php new file mode 100644 index 0000000..e6283ad --- /dev/null +++ b/application/src/Page/Html.php @@ -0,0 +1,113 @@ +hi` becomes `hi`); + * script, style, iframe, object, embed, svg, math, form, template and their kind + * are removed with everything inside; + * - no attribute survives except `href` on links, which must pass Links::safe(); + * links get rel="noopener noreferrer"; + * - comments, processing instructions and CDATA are dropped. + * + * (strip_tags() with an allow-list keeps every attribute of the allowed tags, + * so `` and `` would pass.) + */ +final class Html +{ + private const KEEP = ['p', 'br', 'strong', 'em', 'b', 'i', 'u', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span', 'hr', 'a']; + private const DROP_WITH_CONTENT = ['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'svg', 'math', 'form', 'template', 'noscript', 'textarea', 'select', 'option', 'button', 'input', 'link', 'meta', 'base', 'title', 'head', 'audio', 'video', 'canvas', 'dialog', 'portal', 'xmp', 'plaintext', 'noembed', 'noframes']; + + /** + * @param callable(string): ?string|null $href maps a link's address to the one to print, or null to drop it + */ + public static function sanitize($html, $href = null) + { + $html = (string) $html; + if (trim($html) === '') { + return ''; + } + + $doc = new DOMDocument('1.0', 'UTF-8'); + $previous = libxml_use_internal_errors(true); + $doc->loadHTML('
'.$html.'
', LIBXML_NONET | LIBXML_NOWARNING | LIBXML_NOERROR); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + $root = $doc->getElementById('root'); + if (!$root instanceof DOMElement) { + return htmlspecialchars(strip_tags($html), ENT_QUOTES, 'UTF-8'); + } + + $href = $href ?: [Links::class, 'safe']; + self::clean($root, $href); + + $out = ''; + foreach (iterator_to_array($root->childNodes) as $child) { + $out .= $doc->saveHTML($child); + } + + return trim($out); + } + + /** + * @param callable(string): ?string $href + */ + private static function clean(DOMNode $parent, $href) + { + foreach (iterator_to_array($parent->childNodes) as $node) { + if ($node->nodeType === XML_TEXT_NODE) { + continue; + } + + if (!$node instanceof DOMElement) { + $parent->removeChild($node); // comments, processing instructions, CDATA... + + continue; + } + + $name = strtolower($node->nodeName); + + if (in_array($name, self::DROP_WITH_CONTENT, true)) { + $parent->removeChild($node); + + continue; + } + + self::clean($node, $href); + + if (!in_array($name, self::KEEP, true)) { + // Unknown element: keep what is inside it, lose the element. + while ($node->firstChild) { + $parent->insertBefore($node->firstChild, $node); + } + $parent->removeChild($node); + + continue; + } + + $wanted = null; + if ($name === 'a' && $node->hasAttribute('href')) { + $wanted = $href(html_entity_decode($node->getAttribute('href'), ENT_QUOTES, 'UTF-8')); + } + + foreach (iterator_to_array($node->attributes) as $attribute) { + $node->removeAttribute($attribute->nodeName); + } + + if ($name === 'a' && is_string($wanted) && $wanted !== '') { + $node->setAttribute('href', $wanted); + $node->setAttribute('rel', 'noopener noreferrer'); + } + } + } +} diff --git a/application/src/Page/Links.php b/application/src/Page/Links.php new file mode 100644 index 0000000..4eb1251 --- /dev/null +++ b/application/src/Page/Links.php @@ -0,0 +1,85 @@ + 2000) { + return null; + } + // Whitespace or control characters inside an address are how filters get fooled + // (browsers ignore tabs and newlines in "java\tscript:"), and a backslash is read as a slash. + if (preg_match('/[\x00-\x20\x7f\\\\]/', $url)) { + return null; + } + // "//host/path" would leave this site without saying so. + if (strpos($url, '//') === 0) { + return null; + } + + if (preg_match('/^([a-z][a-z0-9+.\-]*):/i', $url, $m)) { + if (!in_array(strtolower($m[1]), self::SCHEMES, true)) { + return null; + } + if (in_array(strtolower($m[1]), ['http', 'https'], true)) { + $host = parse_url($url, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + return null; + } + } + + return $url; + } + + return $url; + } + + /** + * Whether a (safe) address already says where it goes, as opposed to a page of this site. + */ + public static function isAbsolute($url) + { + return (bool) preg_match('/^(https?|mailto|tel):/i', (string) $url); + } + + /** + * The address to put in the page: absolute ones as they are, pages of this site through $site + * (normally site_url). + * + * @param callable(string): string $site + * + * @return string|null null when the address must not be used + */ + public static function resolve($url, callable $site) + { + $safe = self::safe($url); + if ($safe === null) { + return null; + } + if (self::isAbsolute($safe)) { + return $safe; + } + + return $safe[0] === '#' ? $safe : $site(ltrim($safe, '/')); + } +} diff --git a/application/src/Page/SectionTypes.php b/application/src/Page/SectionTypes.php new file mode 100644 index 0000000..e536cfc --- /dev/null +++ b/application/src/Page/SectionTypes.php @@ -0,0 +1,320 @@ + Pages), as pure PHP: which types + * exist, what each one asks for, how a stored layout is cleaned up, and how it + * is rendered on the public page. Unit-tested in tests-app/Unit/PageSectionsTest.php. + * + * A page's `sections` column holds a JSON list: + * + * [{"type": "hero", "settings": {"heading": "Hello", ...}}, ...] + * + * Everything except the "html" block type escapes its values, so a layout + * authored in the admin cannot inject markup into the public page by accident: + * text is escaped, the body of a text block is rebuilt from an allow-list (Html), + * and every link or image address must pass Links::safe(). The "html" block is + * raw on purpose and is only for people with the `pages.html` permission. + */ +final class SectionTypes +{ + public const HERO = 'hero'; + public const TEXT = 'text'; + public const FEATURES = 'features'; + public const CTA = 'cta'; + public const IMAGE = 'image'; + public const HTML = 'html'; + + /** Most blocks a page may hold (keeps one page inside a TEXT column). */ + public const MAX_SECTIONS = 40; + + /** Longest single setting value; longer text is cut by normalize(). */ + public const MAX_VALUE = 20000; + + /** + * @return array type => label for the builder's dropdown + */ + public static function labels() + { + return [ + self::HERO => 'Hero (title + button)', + self::TEXT => 'Text block', + self::FEATURES => 'Features (cards)', + self::CTA => 'Call to action', + self::IMAGE => 'Image', + self::HTML => 'Custom HTML', + ]; + } + + public static function exists($type) + { + return isset(self::labels()[$type]); + } + + /** + * The settings one block type asks for, in order. + * + * Each entry: label, type (text | textarea | color), optional help. + * + * @return array> + */ + public static function fields($type) + { + switch ($type) { + case self::HERO: + return [ + ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'], + ['name' => 'subheading', 'label' => 'Description', 'type' => 'textarea', 'rows' => 3], + ['name' => 'button_label', 'label' => 'Button label', 'type' => 'text', 'help' => 'Leave empty for no button.'], + ['name' => 'button_url', 'label' => 'Button link', 'type' => 'text', 'help' => 'e.g. register, page/about or https://example.com'], + ['name' => 'background', 'label' => 'Background color', 'type' => 'color'], + ]; + + case self::TEXT: + return [ + ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'], + ['name' => 'body', 'label' => 'Body', 'type' => 'textarea', 'rows' => 8, + 'help' => 'One paragraph per line. Links, bold, lists and headings in the HTML are kept.'], + ]; + + case self::FEATURES: + return [ + ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'], + ['name' => 'items', 'label' => 'Cards', 'type' => 'textarea', 'rows' => 8, + 'help' => 'One card per line: Title | Short description'], + ]; + + case self::CTA: + return [ + ['name' => 'heading', 'label' => 'Heading', 'type' => 'text'], + ['name' => 'text', 'label' => 'Text', 'type' => 'textarea', 'rows' => 3], + ['name' => 'button_label', 'label' => 'Button label', 'type' => 'text'], + ['name' => 'button_url', 'label' => 'Button link', 'type' => 'text'], + ['name' => 'background', 'label' => 'Background color', 'type' => 'color'], + ]; + + case self::IMAGE: + return [ + ['name' => 'url', 'label' => 'Image URL or path', 'type' => 'text', + 'help' => 'e.g. uploads/hero.jpg or https://…'], + ['name' => 'alt', 'label' => 'Alt text', 'type' => 'text'], + ['name' => 'caption', 'label' => 'Caption', 'type' => 'text'], + ]; + + default: // self::HTML + return [ + ['name' => 'html', 'label' => 'HTML', 'type' => 'textarea', 'rows' => 10, + 'help' => 'Rendered as-is on the public page. Only give this to people who write HTML.'], + ]; + } + } + + /** + * Cleans what was posted in the builder: unknown blocks are dropped, + * values are trimmed strings, and an empty layout comes back empty. + * + * @param array $sections + * + * @return array}> + */ + public static function normalize($sections) + { + if (!is_array($sections)) { + return []; + } + + $out = []; + foreach (array_slice(array_values($sections), 0, self::MAX_SECTIONS) as $section) { + if (!is_array($section)) { + continue; + } + $type = (string) ($section['type'] ?? ''); + if (!self::exists($type)) { + continue; + } + + $known = array_column(self::fields($type), 'name'); + $settings = []; + foreach ((array) ($section['settings'] ?? []) as $key => $value) { + if (!in_array((string) $key, $known, true)) { + continue; + } + $value = is_scalar($value) ? trim((string) $value) : ''; + if ($value !== '') { + $settings[(string) $key] = self::cut($value); + } + } + + $out[] = ['type' => $type, 'settings' => $settings]; + } + + return $out; + } + + /** + * @param array $sections + */ + public static function toArray($sections) + { + return self::normalize(is_array($sections) ? $sections : json_decode((string) $sections, true)); + } + + /** + * @param array $sections + */ + public static function encode($sections) + { + return (string) json_encode(self::normalize($sections), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * One block as HTML for the public page. + * + * @param array $section + * @param callable(string): ?string|null $url turns a link/image address into the one to print (null = drop it); + * defaults to Links::safe() with pages of this site left relative + */ + public static function render(array $section, $url = null) + { + $url = $url ?: [Links::class, 'safe']; + $type = (string) ($section['type'] ?? ''); + $s = (array) ($section['settings'] ?? []); + + switch ($type) { + case self::HERO: + $style = self::background($s['background'] ?? ''); + + return '
' + .'
' + .(!empty($s['heading']) ? '

'.self::text($s['heading']).'

' : '') + .(!empty($s['subheading']) ? '

'.nl2br(self::text($s['subheading'])).'

' : '') + .self::button($s, 'btn btn-primary', $url) + .'
'; + + case self::TEXT: + return '
' + .(!empty($s['heading']) ? '

'.self::text($s['heading']).'

' : '') + .(!empty($s['body']) ? '
'.self::body($s['body'], $url).'
' : '') + .'
'; + + case self::FEATURES: + $items = self::pairs($s['items'] ?? ''); + $html = '
' + .(!empty($s['heading']) ? '

'.self::text($s['heading']).'

' : ''); + if ($items !== []) { + $html .= '
'; + foreach ($items as [$title, $text]) { + $html .= '
' + .'

'.self::text($title).'

' + .($text !== '' ? '

'.self::text($text).'

' : '') + .'
'; + } + $html .= '
'; + } + return $html.'
'; + + case self::CTA: + $style = self::background($s['background'] ?? '', 'var(--bs-primary)'); + + return '
' + .(!empty($s['heading']) ? '

'.self::text($s['heading']).'

' : '') + .(!empty($s['text']) ? '

'.self::text($s['text']).'

' : '') + .self::button($s, 'btn btn-light', $url) + .'
'; + + case self::IMAGE: + $src = empty($s['url']) ? null : $url((string) $s['url']); + if ($src === null || $src === '') { + return ''; + } + $img = ''.self::text($s['alt'] ?? '').''; + + return '
' + .(!empty($s['caption']) ? '
'.$img.'
'.self::text($s['caption']).'
' : $img) + .'
'; + + default: // self::HTML + // Deliberate: this block type is raw HTML, written by an admin. + return (string) ($s['html'] ?? ''); + } + } + + /** + * A whole layout as HTML. + * + * @param array $sections + * @param callable(string): ?string|null $url see render() + */ + public static function renderAll($sections, $url = null) + { + $html = ''; + foreach (self::normalize($sections) as $section) { + $html .= self::render($section, $url); + } + + return $html; + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static function text($value) + { + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + } + + /** Body text: rebuilt from an allow-list (see Html), paragraphs come from blank lines. */ + private static function body($value, $url) + { + $html = Html::sanitize((string) $value, $url); + $html = preg_replace('/\s*\R\s*\R\s*/', "

\n

", $html) ?? $html; + + return '

'.trim($html).'

'; + } + + private static function button(array $s, $class, $url) + { + if (empty($s['button_label']) || empty($s['button_url'])) { + return ''; + } + $href = $url((string) $s['button_url']); + if ($href === null || $href === '') { + return ''; + } + + return '
'.self::text($s['button_label']).''; + } + + /** "Title | description" lines as pairs. */ + private static function pairs($text) + { + $out = []; + foreach (preg_split('/\R/', (string) $text) ?: [] as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + $parts = array_map('trim', explode('|', $line, 2)); + $out[] = [$parts[0], $parts[1] ?? '']; + } + + return $out; + } + + private static function background($color, $fallback = '') + { + $color = (string) $color; + if (!preg_match('/^#[0-9a-fA-F]{3,8}$/', $color)) { + return $fallback !== '' ? 'background:'.$fallback : ''; + } + + return 'background:'.$color; + } + + private static function cut($value) + { + return mb_strlen($value) > self::MAX_VALUE ? mb_substr($value, 0, self::MAX_VALUE) : $value; + } +} diff --git a/application/views/layouts/admin.php b/application/views/layouts/admin.php index d70fea6..4cf7299 100644 --- a/application/views/layouts/admin.php +++ b/application/views/layouts/admin.php @@ -29,7 +29,9 @@ continue; } - echo ''; + $href = menu_href($item['url'] ?? ''); + $external = \App\Page\Links::isAbsolute($href); + echo ''; } }; ?> diff --git a/assets/js/app.js b/assets/js/app.js index 772e95d..4bdabca 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -51,7 +51,7 @@ var toast = el.getAttribute('data-confirm-toast'); if (toast) { var t = toast.split('|'); window.App.toast(t[0], t.slice(1).join('|')); } else if (el.tagName === 'A') { window.location.href = el.href; } - else if (el.form) { el.form.removeAttribute('data-confirm'); el.form.requestSubmit ? el.form.requestSubmit(el) : el.form.submit(); } + else if (el.form) { el.form.removeAttribute('data-confirm'); el.form.requestSubmit ? el.form.requestSubmit(el.type === 'submit' ? el : undefined) : el.form.submit(); } }); }); document.addEventListener('submit', function (e) { diff --git a/assets/js/datatables-init.js b/assets/js/datatables-init.js index bcc2219..a94caf6 100644 --- a/assets/js/datatables-init.js +++ b/assets/js/datatables-init.js @@ -14,7 +14,7 @@ var narrow = window.matchMedia('(max-width: 575.98px)').matches; document.querySelectorAll('table[data-datatable]').forEach(function (el) { - var order = (el.getAttribute('data-order') || '').split(','); + var order = (el.getAttribute('data-initial-order') || '').split(','); var columns = Array.prototype.map.call(el.querySelectorAll('thead th'), function (th) { return { orderable: th.getAttribute('data-orderable') !== 'false', diff --git a/assets/js/menus.js b/assets/js/menus.js new file mode 100644 index 0000000..8f3422b --- /dev/null +++ b/assets/js/menus.js @@ -0,0 +1,210 @@ +/* + * Admin -> Menus (assets/js/menus.js) + * + * Drags the sidebar rows into a new order, including nesting: dropping in the + * middle of a row makes the dragged item its child, dropping on the top or + * bottom third inserts it before/after that row at the same level. The ↑/↓ + * buttons do the same without dragging. Either way the finished order is sent + * as [[id, depth], ...] to admin/menus/reorder and saved server-side. + */ +(function () { + 'use strict'; + + var list = document.getElementById('menu-builder'); + if (!list) return; + + var status = document.getElementById('menu-builder-status'); + var readonly = list.hasAttribute('data-readonly'); + var indent = 24; + var maxDepth = 2; + var dragged = null; + var saved = null; + + function rows() { + return Array.prototype.slice.call(list.querySelectorAll('.menu-row')); + } + + function depthOf(row) { + return parseInt(row.getAttribute('data-depth') || '0', 10); + } + + function setDepth(row, depth) { + depth = Math.max(0, Math.min(maxDepth, depth)); + row.setAttribute('data-depth', depth); + row.style.marginInlineStart = (depth * indent) + 'px'; + } + + function paint() { + rows().forEach(function (row) { + setDepth(row, depthOf(row)); + }); + } + + /** The row plus everything nested under it. */ + function subtree(row) { + var out = [row]; + var depth = depthOf(row); + var next = row.nextElementSibling; + while (next && next.classList.contains('menu-row') && depthOf(next) > depth) { + out.push(next); + next = next.nextElementSibling; + } + return out; + } + + /** Whether `ancestor` contains `row` (dropping a parent into its own child). */ + function isAncestor(ancestor, row) { + var depth = depthOf(row); + var node = row; + while (node && node !== list) { + if (node === ancestor) return true; + node = node.previousElementSibling; + while (node && depthOf(node) >= depth && node !== ancestor) { + node = node.previousElementSibling; + } + depth = node ? depthOf(node) : -1; + if (node === ancestor) return true; + if (!node || depth < 0) return false; + } + return false; + } + + function message(text, isError) { + if (!status) return; + status.textContent = text; + status.classList.toggle('text-danger', !!isError); + status.classList.toggle('text-body-secondary', !isError); + } + + function order() { + return rows().map(function (row) { + return [parseInt(row.getAttribute('data-id'), 10), depthOf(row)]; + }); + } + + function save() { + var next = JSON.stringify(order()); + if (next === saved) return; + if (readonly) return; + saved = next; + + var token = document.getElementById('menu-csrf-hash'); + var name = document.getElementById('menu-csrf-name'); + var body = new URLSearchParams(); + body.set(name ? name.value : 'csrf_token', token ? token.value : ''); + body.set('order', next); + + message('Saving…'); + fetch(list.getAttribute('data-reorder-url'), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, + body: body.toString() + }).then(function (r) { + return r.json().then(function (data) { return { ok: r.ok && data.ok, data: data }; }); + }).then(function (res) { + message(res.data.message || (res.ok ? 'Menu saved.' : 'Could not save.'), !res.ok); + if (res.ok) saved = JSON.stringify(order()); + }).catch(function () { + message('Could not save the menu (network error).', true); + saved = null; + }); + } + + // ---------------------------------------------------------------- Drag + rows().forEach(function (row) { + row.setAttribute('draggable', readonly ? 'false' : 'true'); + + row.addEventListener('dragstart', function (e) { + dragged = row; + row.classList.add('dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', row.getAttribute('data-id')); } catch (err) {} + } + }); + + row.addEventListener('dragend', function () { + row.classList.remove('dragging'); + dragged = null; + save(); + }); + + row.addEventListener('dragover', function (e) { + if (!dragged || dragged === row || isAncestor(dragged, row)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + + var box = row.getBoundingClientRect(); + var y = (e.clientY - box.top) / box.height; + var depth = depthOf(row); + + if (y > 0.33 && y < 0.67) { + // Onto the row: it becomes the parent. + row.after(dragged); + setDepth(dragged, depth + 1); + } else if (y <= 0.33) { + row.before(dragged); + setDepth(dragged, depth); + } else { + var block = subtree(row); + block[block.length - 1].after(dragged); + setDepth(dragged, depth); + } + }); + + row.addEventListener('drop', function (e) { + e.preventDefault(); + }); + }); + + list.addEventListener('dragover', function (e) { + if (dragged && e.target === list) e.preventDefault(); + }); + list.addEventListener('drop', function (e) { + if (dragged && e.target === list) { + e.preventDefault(); + list.appendChild(dragged); + setDepth(dragged, 0); + save(); + } + }); + + // ------------------------------------------------------------- Buttons + list.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.menu-up, .menu-down') : null; + if (!btn || readonly) return; + + var row = btn.closest('.menu-row'); + if (!row) return; + + if (btn.classList.contains('menu-up')) { + var prev = row.previousElementSibling; + while (prev && !prev.classList.contains('menu-row')) prev = prev.previousElementSibling; + if (prev) { + var block = subtree(prev); + block[0].before.apply(block[0], subtree(row)); + if (depthOf(prev) !== depthOf(row)) setDepth(row, depthOf(prev)); + } + } else { + var next = row.nextElementSibling; + while (next && !next.classList.contains('menu-row')) next = next.nextElementSibling; + if (next) { + var mine = subtree(row); + // Nesting deeper with ↓ when the next row is deeper already. + if (depthOf(next) > depthOf(row)) { + setDepth(row, depthOf(next)); + next.after(mine[0]); + } else { + subtree(next)[subtree(next).length - 1].after.apply(mine[0], mine); + setDepth(row, depthOf(next)); + } + } + } + + paint(); + save(); + }); + + paint(); + saved = JSON.stringify(order()); +})(); diff --git a/assets/js/pages.js b/assets/js/pages.js new file mode 100644 index 0000000..d3fec6b --- /dev/null +++ b/assets/js/pages.js @@ -0,0 +1,118 @@ +/* + * Admin -> Pages (assets/js/pages.js) + * + * Adds, moves and removes the section cards in the layout editor. Every card + * is plain form fields (sections[0][settings][heading]); after any change the + * indexes are renumbered so the server receives an ordered list. The markup + * mirrors the server-rendered cards in application/modules/pages/views/form.php. + */ +(function () { + 'use strict'; + + var host = document.getElementById('page-sections'); + var form = document.getElementById('page-form'); + if (!host || !form) return; + + var catalogEl = document.getElementById('page-catalog'); + var catalog = { labels: {}, fields: {} }; + try { catalog = JSON.parse(catalogEl.textContent); } catch (e) {} + + var empty = document.getElementById('page-sections-empty'); + + function esc(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + function fieldHtml(f, index, value) { + var name = 'sections[' + index + '][settings][' + f.name + ']'; + var help = f.help ? '
' + esc(f.help) + '
' : ''; + var label = ''; + + if (f.type === 'textarea') { + return '
' + label + + '' + help + '
'; + } + if (f.type === 'color') { + return '
' + label + + '' + help + '
'; + } + return '
' + label + + '' + help + '
'; + } + + function cardHtml(type, index) { + var label = catalog.labels[type] || type; + var fields = (catalog.fields && catalog.fields[type]) || []; + + var html = '
' + + '
' + + '' + + '' + esc(label) + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
'; + + fields.forEach(function (f) { + html += fieldHtml(f, index, ''); + }); + + return html + '
'; + } + + function cards() { + return Array.prototype.slice.call(host.querySelectorAll('.page-section')); + } + + function renumber() { + cards().forEach(function (card, i) { + card.setAttribute('data-index', i); + var type = card.querySelector('[data-role="type"]'); + if (type) type.name = 'sections[' + i + '][type]'; + card.querySelectorAll('[data-field]').forEach(function (input) { + input.name = 'sections[' + i + '][settings][' + input.getAttribute('data-field') + ']'; + }); + }); + if (empty) empty.hidden = cards().length > 0; + } + + var add = document.getElementById('page-add'); + var typeSelect = document.getElementById('page-add-type'); + if (add && typeSelect) { + add.addEventListener('click', function () { + var type = typeSelect.value; + host.insertAdjacentHTML('beforeend', cardHtml(type, cards().length)); + renumber(); + var last = host.lastElementChild; + if (last) { + var first = last.querySelector('input, textarea'); + if (first) first.focus(); + } + }); + } + + host.addEventListener('click', function (e) { + var btn = e.target.closest ? e.target.closest('.section-up, .section-down, .section-remove') : null; + if (!btn) return; + var card = btn.closest('.page-section'); + if (!card) return; + + if (btn.classList.contains('section-remove')) { + card.remove(); + } else if (btn.classList.contains('section-up') && card.previousElementSibling) { + host.insertBefore(card, card.previousElementSibling); + } else if (btn.classList.contains('section-down') && card.nextElementSibling) { + host.insertBefore(card.nextElementSibling, card); + } + + renumber(); + }); + + form.addEventListener('submit', renumber); + renumber(); +})(); diff --git a/tests-app/Unit/MenuTreeTest.php b/tests-app/Unit/MenuTreeTest.php new file mode 100644 index 0000000..623b133 --- /dev/null +++ b/tests-app/Unit/MenuTreeTest.php @@ -0,0 +1,96 @@ + current parent, i.e. rows 1..4 exist. */ + private function known() + { + return [1 => 0, 2 => 0, 3 => 0, 4 => 0]; + } + + public function testFlatOrderStaysFlat(): void + { + $parents = MenuTree::parents([[1, 0], [2, 0], [3, 0]], [1 => 0, 2 => 0, 3 => 0]); + + $this->assertSame([1 => 0, 2 => 0, 3 => 0], $parents); + } + + public function testDepthBecomesAParent(): void + { + $parents = MenuTree::parents([[1, 0], [2, 1], [3, 1], [4, 0]], $this->known()); + + $this->assertSame([1 => 0, 2 => 1, 3 => 1, 4 => 0], $parents); + } + + public function testSecondLevelNestsUnderTheItemAboveIt(): void + { + $parents = MenuTree::parents([[1, 0], [2, 1], [3, 2], [4, 2]], $this->known()); + + $this->assertSame([1 => 0, 2 => 1, 3 => 2, 4 => 2], $parents); + } + + public function testARowThatSkipsALevelFallsBackToTheNearestAncestor(): void + { + // Depth 2 straight after a depth 0 item: the missing level is skipped. + $parents = MenuTree::parents([[1, 0], [2, 0], [3, 2]], [1 => 0, 2 => 0, 3 => 0]); + + $this->assertSame([1 => 0, 2 => 0, 3 => 2], $parents); + } + + public function testUnknownAndDuplicateIdsAreRejected(): void + { + $this->assertFalse(MenuTree::parents([[1, 0], [99, 0]], $this->known())); + $this->assertFalse(MenuTree::parents([[1, 0], [1, 1], [2, 0], [3, 0], [4, 0]], $this->known())); + } + + public function testAPartialOrderIsRejected(): void + { + $this->assertFalse(MenuTree::parents([[1, 0], [2, 0]], $this->known()), 'rows were left out'); + $this->assertFalse(MenuTree::parents([], $this->known())); + } + + public function testDepthsBeyondTheLimitAreRejected(): void + { + $this->assertFalse(MenuTree::parents([[1, 0], [2, 1], [3, 4]], [1 => 0, 2 => 0, 3 => 0])); + $this->assertFalse(MenuTree::parents([[1, -1], [2, 0]], [1 => 0, 2 => 0])); + } + + public function testCanNestRules(): void + { + // 1 (top) > 2 > 3, and 4 alone. ids => parent + $tree = [1 => 0, 2 => 1, 3 => 2, 4 => 0]; + + $this->assertTrue(MenuTree::canNest($tree, 4, 0), 'top level is always fine'); + $this->assertTrue(MenuTree::canNest($tree, 4, 1), 'a lone item under a top-level one'); + $this->assertFalse(MenuTree::canNest($tree, 4, 3), 'under the third level would be level four'); + $this->assertFalse(MenuTree::canNest($tree, 1, 1), 'never its own parent'); + $this->assertFalse(MenuTree::canNest($tree, 1, 3), 'never inside its own subtree'); + $this->assertFalse(MenuTree::canNest($tree, 2, 3), 'a child cannot become its own parent\'s child'); + $this->assertFalse(MenuTree::canNest($tree, 4, 99), 'the parent must exist'); + $this->assertFalse(MenuTree::canNest($tree, 1, 4), 'a subtree that is already three levels deep cannot move down'); + $this->assertTrue(MenuTree::canNest([1 => 0, 2 => 0, 3 => 2], 2, 1), 'a two-level subtree may go under a top-level item'); + } + + public function testCanNestForAnItemThatDoesNotExistYet(): void + { + // 1 (top) > 2 > 3 : a new item (id 0) has no subtree of its own + $tree = [1 => 0, 2 => 1, 3 => 2, 4 => 0]; + + $this->assertTrue(MenuTree::canNest($tree, 0, 0)); + $this->assertTrue(MenuTree::canNest($tree, 0, 1), 'a new item under a top-level one'); + $this->assertTrue(MenuTree::canNest($tree, 0, 2), 'a new item may be the third level'); + $this->assertFalse(MenuTree::canNest($tree, 0, 3), 'but not the fourth'); + $this->assertFalse(MenuTree::canNest($tree, 0, 99), 'and its parent must exist'); + } + + public function testCanNestSurvivesLoopsInStoredData(): void + { + $this->assertFalse(MenuTree::canNest([1 => 2, 2 => 1, 3 => 0], 3, 1), 'a loop above the new parent is unusable'); + } +} + diff --git a/tests-app/Unit/PageSafetyTest.php b/tests-app/Unit/PageSafetyTest.php new file mode 100644 index 0000000..77b8f5e --- /dev/null +++ b/tests-app/Unit/PageSafetyTest.php @@ -0,0 +1,112 @@ +assertSame($url, Links::safe($url), $url); + } + $this->assertSame('https://example.com', Links::safe(' https://example.com '), 'trimmed'); + } + + public function testRefusedAddresses(): void + { + $bad = [ + '', ' ', 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', "java\tscript:alert(1)", "java\nscript:alert(1)", " javascript:alert(1)", + 'data:text/html,', 'vbscript:x', 'file:///etc/passwd', 'blob:https://x', 'ftp://example.com', + '//evil.example/path', '\\\\evil.example', 'https:///nohost', 'http://', "https://exa mple.com", "https://exa\0mple.com", + str_repeat('a', 2001), + ]; + foreach ($bad as $url) { + $this->assertNull(Links::safe($url), json_encode($url)); + } + } + + public function testResolveKeepsAbsoluteAndPutsThisSitesPagesUnderTheSiteAddress(): void + { + $site = static function ($path) { + return 'https://app.test/index.php/'.$path; + }; + + $this->assertSame('https://example.com/x', Links::resolve('https://example.com/x', $site)); + $this->assertSame('mailto:a@b.c', Links::resolve('mailto:a@b.c', $site)); + $this->assertSame('https://app.test/index.php/page/about', Links::resolve('page/about', $site)); + $this->assertSame('https://app.test/index.php/register', Links::resolve('/register', $site)); + $this->assertSame('#top', Links::resolve('#top', $site)); + $this->assertNull(Links::resolve('javascript:alert(1)', $site)); + } + + // ---- Html ---------------------------------------------------------- + + public function testNormalMarkupSurvives(): void + { + $this->assertSame('

Hello big world

', Html::sanitize('

Hello big world

')); + $this->assertSame('
  • a
  • b
', Html::sanitize('
  • a
  • b
')); + $this->assertSame('', Html::sanitize(' ')); + $this->assertSame('Tom & Jerry <3', Html::sanitize('Tom & Jerry <3')); + } + + public function testLinksKeepOnlyASafeHrefAndGetRel(): void + { + $this->assertSame('x', Html::sanitize('x')); + $this->assertSame('x', Html::sanitize('x')); + $this->assertSame('x', Html::sanitize('x'), 'entity-encoded scheme'); + $this->assertSame('x', Html::sanitize("x"), 'tab inside the scheme'); + $this->assertSame('x', Html::sanitize('x')); + } + + /** + * @return array + */ + public static function payloads(): array + { + return [ + 'script' => [''], + 'script in p' => ['

hi

'], + 'img onerror' => [''], + 'svg onload' => [''], + 'svg script' => [''], + 'math' => [''], + 'span handler' => ['hover'], + 'p handler' => ['

x

'], + 'broken tag' => ['ipt>alert(1)ipt>'], + 'case' => [''], + 'comment' => [''], + 'noscript' => ['