Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ If you've ever done a fresh CodeIgniter 3 install on PHP 8.2+ and immediately se
- [Authentication](#authentication)
- [Accounts & e-mail (verification, password reset, Google)](#accounts--e-mail-verification-password-reset-google)
- [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)
Expand Down Expand Up @@ -565,6 +566,21 @@ 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`, stored in the database so changes show up at once. Three kinds of entry: a **link**, a **dropdown** (no address of its own; it opens the links put inside it, and an empty one is not shown) and a **section** heading. Entries nest up to three levels, are ordered by dragging or with the arrow buttons, and can each be limited to a permission or to super admins (items inside a dropdown follow their own permission). The items from `menu.php` always stay; the built ones are added after them.
- **Icon picker.** The icon field has a *Choose* button that opens a searchable grid of all ~2,050 Bootstrap Icons (full screen on phones); typing a class by hand still works. The same picker is on the module generator's menu-icon field. To use it on your own form: `<input type="text" name="icon" data-icon-picker="<?= asset_url('vendor/bootstrap-icons/icons.json') ?>">` and load `assets/js/icon-picker.js`. (`icons.json` is generated from the vendored CSS; regenerate it when you update the icon font.)
- 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 or a dropdown, and not be the item itself or one of its own descendants; a section is always top level and cannot hold items.

**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:
Expand Down
3 changes: 3 additions & 0 deletions application/config/menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 6 additions & 0 deletions application/config/permissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
16 changes: 16 additions & 0 deletions application/config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,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';

2 changes: 1 addition & 1 deletion application/helpers/datatable_helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<table';
Expand Down
32 changes: 31 additions & 1 deletion application/helpers/theme_helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,26 @@ function theme_toggle($class = '')
*/
function menu_items()
{
/** @var CI_Controller&object{crud_store: Crud_store} $CI */
/** @var CI_Controller&object{crud_store: Crud_store, menu_model: Menu_model} $CI */
$CI = &get_instance();
$CI->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 !== []) {
Expand Down Expand Up @@ -369,3 +385,17 @@ function brand_mark()
return '<span class="brand-mark" aria-hidden="true">'.htmlspecialchars(mb_strtoupper(mb_substr((string) app_setting('app_name'), 0, 1)), ENT_QUOTES, 'UTF-8').'</span>';
}
}

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 ?? '#';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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));
}
Expand Down
40 changes: 40 additions & 0 deletions application/migrations/20261009000000_create_menus_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/**
* Sidebar menu items built with Admin -> 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');
}
}
35 changes: 35 additions & 0 deletions application/migrations/20261010000000_create_pages_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/**
* Content pages built with Admin -> 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');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/**
* Gives the built-in `admin` role the menu builder and page builder permissions, like the
* security and audit ones, so existing sites do not need a visit to the Roles page.
*
* `pages.html` (raw HTML in pages, which can run scripts for every visitor) is deliberately
* NOT granted: only super_admin has it until somebody decides to give it to a role.
*/
class Migration_Grant_menus_pages_permissions extends CI_Migration
{
private $permissions = array('menus.view', 'menus.create', 'menus.edit', 'menus.delete', 'pages.view', 'pages.create', 'pages.edit', 'pages.delete');

public function up()
{
$admin = $this->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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ protected function form($module, array $values, array $errors)
$this->template
->set_title($module === null ? 'New module' : 'Edit '.$module['title'])
->set_breadcrumbs(['Home' => '', 'Generator' => 'admin/generator', ($module === null ? 'New module' : 'Edit') => null])
->add_foot('<script src="'.asset_url('js/generator.js').'"></script>')
->add_foot('<script src="'.asset_url('js/icon-picker.js').'"></script><script src="'.asset_url('js/generator.js').'"></script>')
->render('form', [
'module' => $module,
'values' => $values,
Expand Down
2 changes: 1 addition & 1 deletion application/modules/generator/views/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<label for="g-icon" class="form-label">Menu icon</label>
<div class="input-group">
<span class="input-group-text"><i class="bi <?php echo $e($values['icon'] ?: 'bi-table') ?>" id="g-icon-preview"></i></span>
<input type="text" class="form-control font-monospace<?php echo $invalid('icon') ?>" id="g-icon" maxlength="64" value="<?php echo $e($values['icon']) ?>" placeholder="bi-box-seam" autocomplete="off" spellcheck="false">
<input type="text" class="form-control font-monospace<?php echo $invalid('icon') ?>" id="g-icon" maxlength="64" value="<?php echo $e($values['icon']) ?>" placeholder="bi-box-seam" autocomplete="off" spellcheck="false" data-icon-picker="<?php echo $e(asset_url('vendor/bootstrap-icons/icons.json')) ?>">
<?php if (isset($errors['icon'])): ?><div class="invalid-feedback"><?php echo $e($errors['icon']) ?></div><?php endif ?>
</div>
<div class="form-text"><a href="https://icons.getbootstrap.com" target="_blank" rel="noopener">Bootstrap Icons</a> class name.</div>
Expand Down
23 changes: 23 additions & 0 deletions application/modules/menus/config/permissions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

defined('BASEPATH') or exit('No direct script access allowed');

/*
| -------------------------------------------------------------------------
| Menu builder permissions (application/modules/menus)
| -------------------------------------------------------------------------
| Admin -> 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',
],
];
Loading
Loading