From 6426fee4287641a4f75ddee644c478b9f0b9540b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 14:37:15 -0500 Subject: [PATCH] feat: v1.2 multi-category, REST API, ACL, security hardening (#1, #2, #29, #30, #31, #34, #48) Multi-category support with parent/child hierarchy, junction table, admin CRUD, and per-category custom marker icons on the Leaflet map. REST API via Web Services plugin with JSON:API endpoints. ACL permissions via access.xml. SQL update schema for safe upgrades. Shop integration bridge (LocationBridgeHelper, LocationSavedEvent). Security: CSV formula injection prevention, MIME validation, file size limit, ORDER BY allowlist, map height CSS regex validation. Authored-by: Moko Consulting --- CHANGELOG.md | 31 +++- README.md | 20 ++- .../com_mokosuitestorelocator/access.xml | 12 ++ .../admin/forms/category.xml | 69 ++++++++ .../admin/forms/filter_categories.xml | 47 +++++ .../admin/forms/location.xml | 10 ++ .../en-GB/com_mokosuitestorelocator.ini | 14 ++ .../admin/sql/install.mysql.sql | 35 ++++ .../admin/sql/uninstall.mysql.sql | 2 + .../admin/sql/updates/mysql/01.00.00.sql | 1 + .../admin/sql/updates/mysql/01.00.01.sql | 28 +++ .../src/Controller/CategoriesController.php | 37 ++++ .../src/Controller/CategoryController.php | 22 +++ .../admin/src/Controller/ImportController.php | 28 +++ .../admin/src/Event/LocationSavedEvent.php | 46 +++++ .../admin/src/Helper/LocationBridgeHelper.php | 162 ++++++++++++++++++ .../admin/src/Model/CategoriesModel.php | 126 ++++++++++++++ .../admin/src/Model/CategoryModel.php | 85 +++++++++ .../admin/src/Model/ImportModel.php | 25 ++- .../admin/src/Model/LocationModel.php | 109 ++++++++++-- .../admin/src/Model/LocationsModel.php | 15 +- .../admin/src/Table/CategoryTable.php | 81 +++++++++ .../admin/src/View/Categories/HtmlView.php | 51 ++++++ .../admin/src/View/Category/HtmlView.php | 54 ++++++ .../admin/tmpl/categories/default.php | 102 +++++++++++ .../admin/tmpl/category/edit.php | 52 ++++++ .../admin/tmpl/location/edit.php | 8 + .../src/Controller/LocationsController.php | 37 ++++ .../api/src/View/Locations/JsonapiView.php | 68 ++++++++ .../mokosuitestorelocator.xml | 13 ++ .../site/src/Model/LocationsModel.php | 29 +++- .../src/Dispatcher/Dispatcher.php | 49 ++++-- .../tmpl/default.php | 16 +- .../plg_webservices_mokosuitestorelocator.ini | 2 + .../plg_webservices_mokosuitestorelocator.xml | 24 +++ .../services/provider.php | 37 ++++ .../src/Extension/MokoSuiteStoreLocator.php | 57 ++++++ source/pkg_mokosuitestorelocator.xml | 1 + 38 files changed, 1559 insertions(+), 46 deletions(-) create mode 100644 source/packages/com_mokosuitestorelocator/access.xml create mode 100644 source/packages/com_mokosuitestorelocator/admin/forms/category.xml create mode 100644 source/packages/com_mokosuitestorelocator/admin/forms/filter_categories.xml create mode 100644 source/packages/com_mokosuitestorelocator/admin/sql/updates/mysql/01.00.00.sql create mode 100644 source/packages/com_mokosuitestorelocator/admin/sql/updates/mysql/01.00.01.sql create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Controller/CategoriesController.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Controller/CategoryController.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Event/LocationSavedEvent.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Helper/LocationBridgeHelper.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Model/CategoriesModel.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Model/CategoryModel.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/Table/CategoryTable.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/View/Categories/HtmlView.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/src/View/Category/HtmlView.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/tmpl/categories/default.php create mode 100644 source/packages/com_mokosuitestorelocator/admin/tmpl/category/edit.php create mode 100644 source/packages/com_mokosuitestorelocator/api/src/Controller/LocationsController.php create mode 100644 source/packages/com_mokosuitestorelocator/api/src/View/Locations/JsonapiView.php create mode 100644 source/packages/plg_webservices_mokosuitestorelocator/language/en-GB/plg_webservices_mokosuitestorelocator.ini create mode 100644 source/packages/plg_webservices_mokosuitestorelocator/plg_webservices_mokosuitestorelocator.xml create mode 100644 source/packages/plg_webservices_mokosuitestorelocator/services/provider.php create mode 100644 source/packages/plg_webservices_mokosuitestorelocator/src/Extension/MokoSuiteStoreLocator.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b06b74..e92ed7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.1.0] - Unreleased +## [1.2.0] - Unreleased + +### Added +- Multi-category support with parent/child hierarchy (#1) +- Categories admin CRUD — list, edit, color picker, custom marker icon +- Location-category junction table (many-to-many) +- Categories tab on location edit form (multi-select) +- Category filtering on site frontend (`catid` parameter) +- Custom map markers per category — SVG/PNG icon support (#2) +- Map module JOINs category data for marker icons and colors +- `access.xml` with full Joomla ACL permissions (#30) +- SQL update schema with `sql/updates/mysql/` versioned files (#31) +- REST API via Web Services plugin (`plg_webservices_mokosuitestorelocator`) (#29) +- API controller + JSON:API view for locations CRUD at `/api/v1/mokosuitestorelocator/locations` +- `LocationBridgeHelper` — static helper for cross-extension integration (#48) +- `LocationSavedEvent` — fires `onStoreLocatorLocationSaved` for cache invalidation +- Plugin added to package manifest + +### Changed +- Map module dispatcher uses aliased table queries with category JOIN +- ORDER BY clauses in admin and site models now validated against filter_fields allowlist + +### Security +- CSV import: MIME type validation, 2 MB file size limit, delimiter allowlist (#34) +- CSV import: formula injection prevention (strips leading `=+\-@\t\r` characters) +- ORDER BY injection prevention — replaced `$db->escape()` with allowlist validation +- Map module: `$mapHeight` CSS value validated with regex pattern + +## [1.1.0] - 2026-06-23 ### Added - Haversine proximity search — filter locations by distance from user's coordinates @@ -18,6 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CSV import: auto-detect column headers (title/name/store, address/street, city, etc.) - CSV import: per-row validation via LocationTable::bind()->check()->store() - CSV import view accessible from admin toolbar and submenu +- FocalPoint (Shack Locations) migration import - Language strings for directions, geocoding feedback, and import UI ## [01.00.00] - 2026-06-23 diff --git a/README.md b/README.md index 4d997c6..d64748d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Joomla 4/5 package providing a store locator listing component with coordinati | Store Locator Component | component | `com_mokosuitestorelocator` | | Store Locator Map | module (site) | `mod_mokosuitestorelocator_map` | | Store Locator Search | module (site) | `mod_mokosuitestorelocator_search` | +| Web Services API | plugin (webservices) | `plg_webservices_mokosuitestorelocator` | ## Requirements @@ -27,7 +28,9 @@ A Joomla 4/5 package providing a store locator listing component with coordinati ### Implemented - **Admin CRUD** — full location management with tabbed edit form (details, address, coordinates, contact, image) - **Admin list** — searchable, filterable, sortable locations list with bulk publish/unpublish/delete -- **Site frontend** — locations list and detail views with pagination +- **Multi-category** — categories with parent/child hierarchy, color, custom marker icons, many-to-many assignments +- **Custom map markers** — per-category SVG/PNG marker icons on the Leaflet map +- **Site frontend** — locations list and detail views with pagination and category filtering - **Schema.org** — LocalBusiness structured data markup on all frontend templates - **SEF URLs** — router with menu, standard, and nomenu rules - **Menu items** — "All Locations" list and single "Location Detail" picker @@ -37,13 +40,18 @@ A Joomla 4/5 package providing a store locator listing component with coordinati - **Get Directions** — Google Maps directions link on detail page and map popups - **Auto-geocoding** — coordinates auto-populated from address on save (Nominatim/OSM) - **CSV import** — bulk-create locations from spreadsheet with auto-detected column mapping +- **FocalPoint migration** — one-click import from Shack Locations / FocalPoint +- **REST API** — JSON:API endpoints via Joomla Web Services plugin +- **ACL permissions** — `access.xml` with standard Joomla permission actions +- **SQL update schema** — versioned migration files for safe upgrades +- **Shop integration** — `LocationBridgeHelper` for cross-extension data access, `LocationSavedEvent` for cache invalidation +- **Security hardening** — CSV injection prevention, MIME validation, ORDER BY allowlists, input sanitization ### Planned -- Marker clustering for dense location areas -- Multi-category support with custom map markers -- ACL permissions and SQL upgrade schema -- REST API via Joomla Web Services plugin -- MokoSuiteShop integration for multi-store ecommerce +- Marker clustering for dense location areas (Leaflet.markercluster) +- Google Maps provider as alternative to Leaflet +- CSV export +- Photo gallery per location ## Development diff --git a/source/packages/com_mokosuitestorelocator/access.xml b/source/packages/com_mokosuitestorelocator/access.xml new file mode 100644 index 0000000..74c1a43 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/access.xml @@ -0,0 +1,12 @@ + + +
+ + + + + + + +
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/forms/category.xml b/source/packages/com_mokosuitestorelocator/admin/forms/category.xml new file mode 100644 index 0000000..2453fab --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/forms/category.xml @@ -0,0 +1,69 @@ + + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + +
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/forms/filter_categories.xml b/source/packages/com_mokosuitestorelocator/admin/forms/filter_categories.xml new file mode 100644 index 0000000..04b3ff3 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/forms/filter_categories.xml @@ -0,0 +1,47 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + +
diff --git a/source/packages/com_mokosuitestorelocator/admin/forms/location.xml b/source/packages/com_mokosuitestorelocator/admin/forms/location.xml index cb7cb2f..2816f1e 100644 --- a/source/packages/com_mokosuitestorelocator/admin/forms/location.xml +++ b/source/packages/com_mokosuitestorelocator/admin/forms/location.xml @@ -43,6 +43,16 @@ +
+ +
+
true]) + { + return parent::getModel($name, $prefix, $config); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Controller/CategoryController.php b/source/packages/com_mokosuitestorelocator/admin/src/Controller/CategoryController.php new file mode 100644 index 0000000..4b90780 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Controller/CategoryController.php @@ -0,0 +1,22 @@ +input->files->get('jform', [], 'array'); $delimiter = $this->input->post->getString('delimiter', ','); + // Validate delimiter against allowlist + if (!\in_array($delimiter, [',', ';', '|', "\t"], true)) + { + $delimiter = ','; + } + $csvFile = $file['csv_file'] ?? null; if (!$csvFile || $csvFile['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($csvFile['tmp_name'])) @@ -59,6 +65,15 @@ class ImportController extends BaseController return; } + // Enforce 2 MB file size limit + if ($csvFile['size'] > 2 * 1024 * 1024) + { + $this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_FILE_TOO_LARGE'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false)); + + return; + } + // Validate file extension $ext = strtolower(pathinfo($csvFile['name'], PATHINFO_EXTENSION)); @@ -70,6 +85,19 @@ class ImportController extends BaseController return; } + // Validate MIME type + $finfo = new \finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($csvFile['tmp_name']); + $allowedMimes = ['text/csv', 'text/plain', 'application/csv', 'application/vnd.ms-excel', 'application/octet-stream']; + + if (!$mime || !\in_array($mime, $allowedMimes, true)) + { + $this->setMessage(Text::_('COM_MOKOJOOMSTORELOCATOR_IMPORT_INVALID_FILE'), 'error'); + $this->setRedirect(Route::_('index.php?option=com_mokosuitestorelocator&view=import', false)); + + return; + } + $result = $model->processImport($csvFile['tmp_name'], $delimiter); if ($result['imported'] > 0) diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Event/LocationSavedEvent.php b/source/packages/com_mokosuitestorelocator/admin/src/Event/LocationSavedEvent.php new file mode 100644 index 0000000..da81be1 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Event/LocationSavedEvent.php @@ -0,0 +1,46 @@ + array]. + * + * @since 1.2.0 + */ + public function __construct(string $name, array $arguments = []) + { + parent::__construct($name, $arguments); + } + + /** + * Get the saved location data. + * + * @return array + * + * @since 1.2.0 + */ + public function getLocationData(): array + { + return $this->getArgument('locationData', []); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Helper/LocationBridgeHelper.php b/source/packages/com_mokosuitestorelocator/admin/src/Helper/LocationBridgeHelper.php new file mode 100644 index 0000000..d56071f --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Helper/LocationBridgeHelper.php @@ -0,0 +1,162 @@ +get('DatabaseDriver'); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitestorelocator_locations')); + + if ($publishedOnly) + { + $query->where($db->quoteName('published') . ' = 1'); + } + + $query->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get a single location by ID. + * + * @param int $locationId The location ID. + * + * @return object|null + * + * @since 1.2.0 + */ + public static function getById(int $locationId): ?object + { + $db = Factory::getContainer()->get('DatabaseDriver'); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitestorelocator_locations')) + ->where($db->quoteName('id') . ' = :id') + ->bind(':id', $locationId, ParameterType::INTEGER); + + $db->setQuery($query); + + return $db->loadObject() ?: null; + } + + /** + * Get locations within a radius using Haversine formula. + * + * @param float $lat Latitude of the search origin. + * @param float $lng Longitude of the search origin. + * @param float $radiusMiles Search radius in miles. + * @param int $limit Maximum results. + * + * @return array Objects with an additional `distance` property (miles). + * + * @since 1.2.0 + */ + public static function getNearby(float $lat, float $lng, float $radiusMiles = 25, int $limit = 10): array + { + $db = Factory::getContainer()->get('DatabaseDriver'); + $query = $db->getQuery(true); + + $haversine = '(3959 * ACOS(LEAST(1, GREATEST(-1, ' + . 'SIN(RADIANS(' . $db->quoteName('latitude') . ')) * SIN(RADIANS(' . (float) $lat . ')) ' + . '+ COS(RADIANS(' . $db->quoteName('latitude') . ')) * COS(RADIANS(' . (float) $lat . ')) ' + . '* COS(RADIANS(' . $db->quoteName('longitude') . ' - ' . (float) $lng . '))' + . '))))'; + + $query->select('*') + ->select($haversine . ' AS distance') + ->from($db->quoteName('#__mokosuitestorelocator_locations')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('latitude') . ' IS NOT NULL') + ->where($db->quoteName('longitude') . ' IS NOT NULL') + ->where($haversine . ' <= ' . (float) $radiusMiles) + ->order('distance ASC'); + + $db->setQuery($query, 0, $limit); + + return $db->loadObjectList() ?: []; + } + + /** + * Get locations by city. + * + * @param string $city City name. + * + * @return array + * + * @since 1.2.0 + */ + public static function getByCity(string $city): array + { + $db = Factory::getContainer()->get('DatabaseDriver'); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitestorelocator_locations')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('city') . ' = :city') + ->bind(':city', $city) + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } + + /** + * Get locations by state/province. + * + * @param string $state State/province name. + * + * @return array + * + * @since 1.2.0 + */ + public static function getByState(string $state): array + { + $db = Factory::getContainer()->get('DatabaseDriver'); + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitestorelocator_locations')) + ->where($db->quoteName('published') . ' = 1') + ->where($db->quoteName('state') . ' = :state') + ->bind(':state', $state) + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + + return $db->loadObjectList() ?: []; + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoriesModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoriesModel.php new file mode 100644 index 0000000..4efd63f --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoriesModel.php @@ -0,0 +1,126 @@ +getUserStateFromRequest($this->context . '.filter.search', 'filter_search', '', 'string'); + $this->setState('filter.search', $search); + + $published = $this->getUserStateFromRequest($this->context . '.filter.published', 'filter_published', '', 'string'); + $this->setState('filter.published', $published); + + parent::populateState($ordering, $direction); + } + + /** + * Build an SQL query to load the list data. + * + * @return QueryInterface + * + * @since 1.2.0 + */ + protected function getListQuery(): QueryInterface + { + $db = $this->getDatabase(); + $query = $db->getQuery(true); + + $query->select('a.*') + ->from($db->quoteName('#__mokosuitestorelocator_categories', 'a')); + + // Count locations per category + $subQuery = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitestorelocator_location_categories', 'lc')) + ->where($db->quoteName('lc.category_id') . ' = ' . $db->quoteName('a.id')); + + $query->select('(' . $subQuery . ') AS ' . $db->quoteName('location_count')); + + // Filter by published state + $published = $this->getState('filter.published'); + + if (is_numeric($published)) + { + $query->where($db->quoteName('a.published') . ' = :published') + ->bind(':published', $published, \Joomla\Database\ParameterType::INTEGER); + } + elseif ($published === '') + { + $query->where($db->quoteName('a.published') . ' IN (0, 1)'); + } + + // Search filter + $search = $this->getState('filter.search'); + + if (!empty($search)) + { + $search = '%' . trim($search) . '%'; + $query->where($db->quoteName('a.title') . ' LIKE :search') + ->bind(':search', $search); + } + + // Ordering — validate against filter_fields allowlist + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + + if (!\in_array($orderCol, $this->filter_fields, true)) + { + $orderCol = 'a.ordering'; + } + + $orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC'; + $query->order($db->quoteName($orderCol) . ' ' . $orderDir); + + return $query; + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoryModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoryModel.php new file mode 100644 index 0000000..b0a1c9a --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/CategoryModel.php @@ -0,0 +1,85 @@ +loadForm( + 'com_mokosuitestorelocator.category', + 'category', + ['control' => 'jform', 'load_data' => $loadData] + ); + + if (empty($form)) + { + return false; + } + + return $form; + } + + /** + * Load the data for the form. + * + * @return mixed + * + * @since 1.2.0 + */ + protected function loadFormData() + { + return $this->getItem(); + } + + /** + * Get the table for this model. + * + * @param string $name The table name. + * @param string $prefix The table prefix. + * @param array $options Configuration array. + * + * @return Table + * + * @since 1.2.0 + */ + public function getTable($name = 'Category', $prefix = 'Administrator', $options = []) + { + return parent::getTable($name, $prefix, $options); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php index cb2177e..9168eb9 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/ImportModel.php @@ -206,12 +206,35 @@ class ImportModel extends BaseDatabaseModel foreach ($mapping as $dbField => $csvIndex) { - $data[$dbField] = trim($row[$csvIndex] ?? ''); + $value = trim($row[$csvIndex] ?? ''); + $data[$dbField] = $this->sanitizeCsvValue($value); } return $data; } + /** + * Sanitize a CSV value to prevent formula injection. + * + * Strips leading characters that spreadsheet applications interpret as formulas. + * + * @param string $value Raw CSV cell value. + * + * @return string Sanitized value. + * + * @since 1.1.0 + */ + private function sanitizeCsvValue(string $value): string + { + if ($value === '') + { + return $value; + } + + // Strip leading formula trigger characters + return ltrim($value, "=+\-@\t\r"); + } + /** * Import locations from an installed FocalPoint (Shack Locations) component. * diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php index aed8fff..1c314eb 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationModel.php @@ -13,6 +13,7 @@ defined('_JEXEC') or die; use Joomla\CMS\Factory; use Joomla\CMS\Form\Form; use Joomla\CMS\Http\HttpFactory; +use Moko\Component\MokoSuiteStoreLocator\Administrator\Event\LocationSavedEvent; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Model\AdminModel; use Joomla\CMS\Table\Table; @@ -58,20 +59,6 @@ class LocationModel extends AdminModel return $form; } - /** - * Load the data for the form. - * - * @return mixed The data for the form. - * - * @since 1.0.0 - */ - protected function loadFormData() - { - $data = $this->getItem(); - - return $data; - } - /** * Get the table for this model. * @@ -118,7 +105,99 @@ class LocationModel extends AdminModel } } - return parent::save($data); + // Extract categories before parent::save (it won't know about junction table) + $categories = $data['categories'] ?? []; + unset($data['categories']); + + if (!parent::save($data)) + { + return false; + } + + // Save category associations + $locationId = (int) $this->getState($this->getName() . '.id'); + $this->saveCategories($locationId, $categories); + + // Fire event for cross-extension integration (e.g. MokoSuiteShop) + $data['id'] = $locationId; + Factory::getApplication()->getDispatcher()->dispatch( + 'onStoreLocatorLocationSaved', + new LocationSavedEvent('onStoreLocatorLocationSaved', ['locationData' => $data]) + ); + + return true; + } + + /** + * Save location-category associations in the junction table. + * + * @param int $locationId The location ID. + * @param array $categories Array of category IDs. + * + * @return void + * + * @since 1.2.0 + */ + private function saveCategories(int $locationId, array $categories): void + { + $db = $this->getDatabase(); + + // Remove existing associations + $query = $db->getQuery(true) + ->delete($db->quoteName('#__mokosuitestorelocator_location_categories')) + ->where($db->quoteName('location_id') . ' = :locationId') + ->bind(':locationId', $locationId, \Joomla\Database\ParameterType::INTEGER); + + $db->setQuery($query); + $db->execute(); + + // Insert new associations + if (!empty($categories)) + { + $query = $db->getQuery(true) + ->insert($db->quoteName('#__mokosuitestorelocator_location_categories')) + ->columns([$db->quoteName('location_id'), $db->quoteName('category_id')]); + + foreach ($categories as $catId) + { + $catId = (int) $catId; + + if ($catId > 0) + { + $query->values($locationId . ', ' . $catId); + } + } + + $db->setQuery($query); + $db->execute(); + } + } + + /** + * Load the data for the form, including category associations. + * + * @return mixed + * + * @since 1.0.0 + */ + protected function loadFormData() + { + $data = $this->getItem(); + + if ($data && (int) $data->id > 0) + { + $db = $this->getDatabase(); + $query = $db->getQuery(true) + ->select($db->quoteName('category_id')) + ->from($db->quoteName('#__mokosuitestorelocator_location_categories')) + ->where($db->quoteName('location_id') . ' = :id') + ->bind(':id', $data->id, \Joomla\Database\ParameterType::INTEGER); + + $db->setQuery($query); + $data->categories = $db->loadColumn(); + } + + return $data; } /** diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php index d756a14..e78621a 100644 --- a/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php +++ b/source/packages/com_mokosuitestorelocator/admin/src/Model/LocationsModel.php @@ -111,10 +111,17 @@ class LocationsModel extends ListModel ->bind(':search4', $search); } - // Ordering - $orderCol = $this->state->get('list.ordering', 'a.title'); - $orderDir = $this->state->get('list.direction', 'ASC'); - $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + // Ordering — validate against filter_fields allowlist + $orderCol = $this->state->get('list.ordering', 'a.title'); + $orderDir = $this->state->get('list.direction', 'ASC'); + + if (!\in_array($orderCol, $this->filter_fields, true)) + { + $orderCol = 'a.title'; + } + + $orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC'; + $query->order($db->quoteName($orderCol) . ' ' . $orderDir); return $query; } diff --git a/source/packages/com_mokosuitestorelocator/admin/src/Table/CategoryTable.php b/source/packages/com_mokosuitestorelocator/admin/src/Table/CategoryTable.php new file mode 100644 index 0000000..c2448f7 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/Table/CategoryTable.php @@ -0,0 +1,81 @@ +title) === '') + { + $this->setError(Text::_('COM_MOKOJOOMSTORELOCATOR_ERROR_CATEGORY_TITLE_REQUIRED')); + + return false; + } + + if (trim($this->alias) === '') + { + $this->alias = $this->title; + } + + $this->alias = OutputFilter::stringURLSafe($this->alias); + + // Validate color format + if ($this->color !== '' && !preg_match('/^#[0-9a-fA-F]{6}$/', $this->color)) + { + $this->color = ''; + } + + $now = Factory::getDate()->toSql(); + + if (!(int) $this->id) + { + if (!$this->created || $this->created === '0000-00-00 00:00:00') + { + $this->created = $now; + } + } + + $this->modified = $now; + + return parent::check(); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/View/Categories/HtmlView.php b/source/packages/com_mokosuitestorelocator/admin/src/View/Categories/HtmlView.php new file mode 100644 index 0000000..4b3b069 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/View/Categories/HtmlView.php @@ -0,0 +1,51 @@ +items = $this->get('Items'); + $this->pagination = $this->get('Pagination'); + $this->state = $this->get('State'); + $this->filterForm = $this->get('FilterForm'); + $this->activeFilters = $this->get('ActiveFilters'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + ToolbarHelper::title(Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORIES'), 'folder'); + ToolbarHelper::addNew('category.add'); + ToolbarHelper::publish('categories.publish', 'JTOOLBAR_PUBLISH', true); + ToolbarHelper::unpublish('categories.unpublish', 'JTOOLBAR_UNPUBLISH', true); + ToolbarHelper::deleteList('', 'categories.delete', 'JTOOLBAR_DELETE'); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/src/View/Category/HtmlView.php b/source/packages/com_mokosuitestorelocator/admin/src/View/Category/HtmlView.php new file mode 100644 index 0000000..2067665 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/src/View/Category/HtmlView.php @@ -0,0 +1,54 @@ +form = $this->get('Form'); + $this->item = $this->get('Item'); + + $this->addToolbar(); + + parent::display($tpl); + } + + protected function addToolbar(): void + { + Factory::getApplication()->input->set('hidemainmenu', true); + + $isNew = ($this->item->id == 0); + + ToolbarHelper::title( + Text::_('COM_MOKOJOOMSTORELOCATOR_CATEGORY_' . ($isNew ? 'NEW' : 'EDIT')), + 'folder' + ); + + ToolbarHelper::apply('category.apply'); + ToolbarHelper::save('category.save'); + ToolbarHelper::save2new('category.save2new'); + ToolbarHelper::cancel('category.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE'); + } +} diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/categories/default.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/categories/default.php new file mode 100644 index 0000000..45523f7 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/categories/default.php @@ -0,0 +1,102 @@ + +
+ +
+
+
+ $this]); ?> + + items)) : ?> +
+ + +
+ + + + + + + + + + + + + + + items as $i => $item) : ?> + + + + + + + + + + +
+ +
+ + + + + + + + + + + +
+ id, false, 'cid', 'cb', $item->title); ?> + + level > 1) : ?> + —', (int) $item->level - 1); ?> + + + escape($item->title); ?> + + + color) : ?> + + + — + + + location_count; ?> + + published, $i, 'categories.', true, 'cb'); ?> + + id; ?> +
+ + pagination->getListFooter(); ?> + + + + + +
+
+
+
diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/category/edit.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/category/edit.php new file mode 100644 index 0000000..4eb2923 --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/category/edit.php @@ -0,0 +1,52 @@ + +
+ + 'details', 'recall' => true, 'breakpoint' => 768]); ?> + + +
+
+ form->renderField('title'); ?> + form->renderField('alias'); ?> + form->renderField('parent_id'); ?> + form->renderField('description'); ?> +
+
+ form->renderField('published'); ?> +
+
+ + + +
+
+ form->renderField('color'); ?> + form->renderField('marker_icon'); ?> +
+
+ + + + + + +
diff --git a/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php b/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php index d8aec50..2eb6251 100644 --- a/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php +++ b/source/packages/com_mokosuitestorelocator/admin/tmpl/location/edit.php @@ -37,6 +37,14 @@ HTMLHelper::_('behavior.keepalive'); + +
+
+ form->renderField('categories'); ?> +
+
+ +
diff --git a/source/packages/com_mokosuitestorelocator/api/src/Controller/LocationsController.php b/source/packages/com_mokosuitestorelocator/api/src/Controller/LocationsController.php new file mode 100644 index 0000000..9ab379e --- /dev/null +++ b/source/packages/com_mokosuitestorelocator/api/src/Controller/LocationsController.php @@ -0,0 +1,37 @@ + + + + sql/updates/mysql + + + language src tmpl + + + src + + + forms @@ -57,6 +69,7 @@ COM_MOKOJOOMSTORELOCATOR COM_MOKOJOOMSTORELOCATOR_LOCATIONS + COM_MOKOJOOMSTORELOCATOR_CATEGORIES COM_MOKOJOOMSTORELOCATOR_IMPORT diff --git a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php index 6d44b90..7e78ab5 100644 --- a/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php +++ b/source/packages/com_mokosuitestorelocator/site/src/Model/LocationsModel.php @@ -99,6 +99,9 @@ class LocationsModel extends ListModel $this->setState('filter.radius_unit', in_array($radiusUnit, ['miles', 'km']) ? $radiusUnit : 'miles'); + $catid = $app->input->getInt('catid', 0); + $this->setState('filter.catid', $catid); + parent::populateState($ordering, $direction); } @@ -154,6 +157,17 @@ class LocationsModel extends ListModel ->bind(':state', $state); } + // Category filter + $catid = (int) $this->getState('filter.catid'); + + if ($catid > 0) + { + $query->join('INNER', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc') + . ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id')) + ->where($db->quoteName('lc.category_id') . ' = :catid') + ->bind(':catid', $catid, ParameterType::INTEGER); + } + // Proximity / Haversine distance filter $lat = $this->getState('filter.lat'); $lng = $this->getState('filter.lng'); @@ -179,10 +193,17 @@ class LocationsModel extends ListModel } else { - // Default ordering - $orderCol = $this->state->get('list.ordering', 'a.ordering'); - $orderDir = $this->state->get('list.direction', 'ASC'); - $query->order($db->escape($orderCol) . ' ' . $db->escape($orderDir)); + // Default ordering — validate against filter_fields allowlist + $orderCol = $this->state->get('list.ordering', 'a.ordering'); + $orderDir = $this->state->get('list.direction', 'ASC'); + + if (!\in_array($orderCol, $this->filter_fields, true)) + { + $orderCol = 'a.ordering'; + } + + $orderDir = strtoupper($orderDir) === 'DESC' ? 'DESC' : 'ASC'; + $query->order($db->quoteName($orderCol) . ' ' . $orderDir); } return $query; diff --git a/source/packages/mod_mokosuitestorelocator_map/src/Dispatcher/Dispatcher.php b/source/packages/mod_mokosuitestorelocator_map/src/Dispatcher/Dispatcher.php index 4b3c131..3d7eb3c 100644 --- a/source/packages/mod_mokosuitestorelocator_map/src/Dispatcher/Dispatcher.php +++ b/source/packages/mod_mokosuitestorelocator_map/src/Dispatcher/Dispatcher.php @@ -41,20 +41,29 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI $query = $db->getQuery(true); $query->select([ - $db->quoteName('id'), - $db->quoteName('title'), - $db->quoteName('address'), - $db->quoteName('city'), - $db->quoteName('state'), - $db->quoteName('postcode'), - $db->quoteName('phone'), - $db->quoteName('latitude'), - $db->quoteName('longitude'), + $db->quoteName('a.id'), + $db->quoteName('a.title'), + $db->quoteName('a.address'), + $db->quoteName('a.city'), + $db->quoteName('a.state'), + $db->quoteName('a.postcode'), + $db->quoteName('a.phone'), + $db->quoteName('a.latitude'), + $db->quoteName('a.longitude'), ]) - ->from($db->quoteName('#__mokosuitestorelocator_locations')) - ->where($db->quoteName('published') . ' = 1') - ->where($db->quoteName('latitude') . ' IS NOT NULL') - ->where($db->quoteName('longitude') . ' IS NOT NULL'); + ->from($db->quoteName('#__mokosuitestorelocator_locations', 'a')) + ->where($db->quoteName('a.published') . ' = 1') + ->where($db->quoteName('a.latitude') . ' IS NOT NULL') + ->where($db->quoteName('a.longitude') . ' IS NOT NULL'); + + // Join to get primary category marker icon + $query->select([$db->quoteName('c.marker_icon'), $db->quoteName('c.color', 'cat_color')]) + ->join('LEFT', $db->quoteName('#__mokosuitestorelocator_location_categories', 'lc') + . ' ON ' . $db->quoteName('lc.location_id') . ' = ' . $db->quoteName('a.id')) + ->join('LEFT', $db->quoteName('#__mokosuitestorelocator_categories', 'c') + . ' ON ' . $db->quoteName('c.id') . ' = ' . $db->quoteName('lc.category_id') + . ' AND ' . $db->quoteName('c.published') . ' = 1') + ->group($db->quoteName('a.id')); $db->setQuery($query); $locations = $db->loadObjectList() ?: []; @@ -63,7 +72,7 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI foreach ($locations as $loc) { - $markers[] = [ + $marker = [ 'id' => (int) $loc->id, 'title' => $loc->title, 'address' => trim($loc->address . ', ' . $loc->city . ', ' . $loc->state . ' ' . $loc->postcode, ', '), @@ -71,6 +80,18 @@ class Dispatcher extends AbstractModuleDispatcher implements HelperFactoryAwareI 'lat' => (float) $loc->latitude, 'lng' => (float) $loc->longitude, ]; + + if (!empty($loc->marker_icon)) + { + $marker['marker_icon'] = $loc->marker_icon; + } + + if (!empty($loc->cat_color)) + { + $marker['cat_color'] = $loc->cat_color; + } + + $markers[] = $marker; } $data['locations'] = $markers; diff --git a/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php b/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php index 2fed686..16964dd 100644 --- a/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php +++ b/source/packages/mod_mokosuitestorelocator_map/tmpl/default.php @@ -15,6 +15,11 @@ $params = $displayData['params']; $locations = $displayData['locations'] ?? []; $moduleId = $displayData['module']->id; $mapHeight = $params->get('map_height', '400px'); + +if (!preg_match('/^\d+(px|em|rem|vh|%)$/', $mapHeight)) +{ + $mapHeight = '400px'; +} $mapZoom = (int) $params->get('map_zoom', 10); $provider = $params->get('map_provider', 'leaflet'); $apiKey = $params->get('api_key', ''); @@ -69,7 +74,16 @@ document.addEventListener('DOMContentLoaded', function() { } locations.forEach(function(loc) { - var marker = L.marker([loc.lat, loc.lng]).addTo(map); + var markerOptions = {}; + if (loc.marker_icon) { + markerOptions.icon = L.icon({ + iconUrl: loc.marker_icon, + iconSize: [32, 32], + iconAnchor: [16, 32], + popupAnchor: [0, -32] + }); + } + var marker = L.marker([loc.lat, loc.lng], markerOptions).addTo(map); var popup = '' + esc(loc.title) + ''; if (loc.address) popup += '
' + esc(loc.address); if (loc.phone) popup += '
' + esc(loc.phone) + ''; diff --git a/source/packages/plg_webservices_mokosuitestorelocator/language/en-GB/plg_webservices_mokosuitestorelocator.ini b/source/packages/plg_webservices_mokosuitestorelocator/language/en-GB/plg_webservices_mokosuitestorelocator.ini new file mode 100644 index 0000000..a79710d --- /dev/null +++ b/source/packages/plg_webservices_mokosuitestorelocator/language/en-GB/plg_webservices_mokosuitestorelocator.ini @@ -0,0 +1,2 @@ +PLG_WEBSERVICES_MOKOSUITESTORELOCATOR="MokoSuiteStoreLocator - Web Services" +PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC="Provides REST API endpoints for the MokoSuiteStoreLocator component." diff --git a/source/packages/plg_webservices_mokosuitestorelocator/plg_webservices_mokosuitestorelocator.xml b/source/packages/plg_webservices_mokosuitestorelocator/plg_webservices_mokosuitestorelocator.xml new file mode 100644 index 0000000..4316def --- /dev/null +++ b/source/packages/plg_webservices_mokosuitestorelocator/plg_webservices_mokosuitestorelocator.xml @@ -0,0 +1,24 @@ + + + + plg_webservices_mokosuitestorelocator + 01.00.01 + 2026-06-24 + Moko Consulting + hello@mokoconsulting.tech + https://mokoconsulting.tech + Copyright (C) 2026 Moko Consulting. All rights reserved. + GNU General Public License version 3 or later; see LICENSE + PLG_WEBSERVICES_MOKOSUITESTORELOCATOR_DESC + + Moko\Plugin\WebServices\MokoSuiteStoreLocator + + + services + src + language + + diff --git a/source/packages/plg_webservices_mokosuitestorelocator/services/provider.php b/source/packages/plg_webservices_mokosuitestorelocator/services/provider.php new file mode 100644 index 0000000..85cca5e --- /dev/null +++ b/source/packages/plg_webservices_mokosuitestorelocator/services/provider.php @@ -0,0 +1,37 @@ +set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new MokoSuiteStoreLocator( + $dispatcher, + (array) PluginHelper::getPlugin('webservices', 'mokosuitestorelocator') + ); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + } + ); + } +}; diff --git a/source/packages/plg_webservices_mokosuitestorelocator/src/Extension/MokoSuiteStoreLocator.php b/source/packages/plg_webservices_mokosuitestorelocator/src/Extension/MokoSuiteStoreLocator.php new file mode 100644 index 0000000..710c9f0 --- /dev/null +++ b/source/packages/plg_webservices_mokosuitestorelocator/src/Extension/MokoSuiteStoreLocator.php @@ -0,0 +1,57 @@ + 'onBeforeApiRoute', + ]; + } + + /** + * Register API routes. + * + * @param \Joomla\CMS\Event\ApiRouterEvent $event The event. + * + * @return void + * + * @since 1.2.0 + */ + public function onBeforeApiRoute($event): void + { + $router = $event->getArgument('router') ?? $event->getRouter(); + + $router->createCRUDRoutes( + 'v1/mokosuitestorelocator/locations', + 'locations', + ['component' => 'com_mokosuitestorelocator'] + ); + } +} diff --git a/source/pkg_mokosuitestorelocator.xml b/source/pkg_mokosuitestorelocator.xml index 9eac2e4..27929cf 100644 --- a/source/pkg_mokosuitestorelocator.xml +++ b/source/pkg_mokosuitestorelocator.xml @@ -32,6 +32,7 @@ com_mokosuitestorelocator.zip mod_mokosuitestorelocator_map.zip mod_mokosuitestorelocator_search.zip + plg_webservices_mokosuitestorelocator.zip