From 17fd3d6b0e3f23f7f586e22573dc1fa997477c67 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 28 May 2026 12:24:33 -0500 Subject: [PATCH 1/2] feat: add Perfect Publisher web services API plugin New plg_webservices_perfectpublisher provides REST API for Perfect Publisher (com_autotweet): - GET /v1/perfectpublisher/channels List social channels - GET /v1/perfectpublisher/channels/:id Channel detail (OAuth redacted) - GET /v1/perfectpublisher/posts List posts (filter by status/channel) - GET /v1/perfectpublisher/posts/:id Post detail - GET /v1/perfectpublisher/requests Pending publish requests - POST /v1/perfectpublisher/requests Create publish request - GET /v1/perfectpublisher/rules Publishing rules - GET /v1/perfectpublisher/feeds RSS feeds - GET /v1/perfectpublisher/channeltypes Channel type definitions - GET /v1/perfectpublisher/stats Dashboard statistics Added to pkg_mokowaas.xml package manifest. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../perfectpublisher.xml | 17 + .../services/provider.php | 42 ++ .../src/Extension/PerfectPublisherApi.php | 539 ++++++++++++++++++ src/pkg_mokowaas.xml | 1 + 5 files changed, 600 insertions(+) create mode 100644 src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml create mode 100644 src/packages/plg_webservices_perfectpublisher/services/provider.php create mode 100644 src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7b58c3..2afffbd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `branch-cleanup.yml`: auto-delete merged feature branches after PR merge +- `plg_webservices_perfectpublisher`: REST API for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, feeds, and stats ### Planned - License/subscription check diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml new file mode 100644 index 00000000..fd1c3724 --- /dev/null +++ b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml @@ -0,0 +1,17 @@ + + + Web Services - Perfect Publisher + Moko Consulting + 2026-05-28 + Copyright (C) 2026 Moko Consulting. All rights reserved. + GPL-3.0-or-later + hello@mokoconsulting.tech + https://mokoconsulting.tech + 02.13.01 + Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds. + Moko\Plugin\WebServices\PerfectPublisher + + services + src + + diff --git a/src/packages/plg_webservices_perfectpublisher/services/provider.php b/src/packages/plg_webservices_perfectpublisher/services/provider.php new file mode 100644 index 00000000..dd88ea91 --- /dev/null +++ b/src/packages/plg_webservices_perfectpublisher/services/provider.php @@ -0,0 +1,42 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: Joomla.Plugin + * INGROUP: MokoWaaS + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS + * PATH: /src/packages/plg_webservices_perfectpublisher/services/provider.php + * VERSION: 02.13.01 + * BRIEF: DI service provider for Perfect Publisher Web Services plugin + */ + +defined('_JEXEC') or die; + +use Joomla\CMS\Extension\PluginInterface; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\DI\Container; +use Joomla\DI\ServiceProviderInterface; +use Joomla\Event\DispatcherInterface; +use Moko\Plugin\WebServices\PerfectPublisher\Extension\PerfectPublisherApi; + +return new class implements ServiceProviderInterface +{ + public function register(Container $container): void + { + $container->set( + PluginInterface::class, + function (Container $container) { + $dispatcher = $container->get(DispatcherInterface::class); + $plugin = new PerfectPublisherApi( + $dispatcher, + (array) PluginHelper::getPlugin('webservices', 'perfectpublisher') + ); + $plugin->setApplication(Factory::getApplication()); + return $plugin; + } + ); + } +}; diff --git a/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php new file mode 100644 index 00000000..a0543cff --- /dev/null +++ b/src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php @@ -0,0 +1,539 @@ + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: Joomla.Plugin + * INGROUP: MokoWaaS + * REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS + * PATH: /src/packages/plg_webservices_perfectpublisher/src/Extension/PerfectPublisherApi.php + * VERSION: 02.13.01 + * BRIEF: Web Services API plugin for Perfect Publisher (com_autotweet) + */ + +namespace Moko\Plugin\WebServices\PerfectPublisher\Extension; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\CMSPlugin; +use Joomla\CMS\Event\Application\BeforeApiRouteEvent; +use Joomla\CMS\Router\ApiRouter; +use Joomla\Event\SubscriberInterface; + +/** + * Perfect Publisher Web Services API Plugin + * + * Registers REST API routes for Perfect Publisher (com_autotweet) data. + * Provides read access to channels, posts, requests, rules, and feeds. + * Provides write access to create publish requests. + * + * Routes: + * GET /v1/perfectpublisher/channels List social channels + * GET /v1/perfectpublisher/channels/:id Get channel detail + * GET /v1/perfectpublisher/posts List published posts + * GET /v1/perfectpublisher/posts/:id Get post detail + * GET /v1/perfectpublisher/requests List pending requests + * POST /v1/perfectpublisher/requests Create a publish request + * GET /v1/perfectpublisher/rules List publishing rules + * GET /v1/perfectpublisher/feeds List RSS feeds + * GET /v1/perfectpublisher/channeltypes List channel type definitions + * GET /v1/perfectpublisher/stats Dashboard statistics + * + * @since 02.13.01 + */ +final class PerfectPublisherApi extends CMSPlugin implements SubscriberInterface +{ + /** + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + 'onBeforeApiRoute' => 'onBeforeApiRoute', + ]; + } + + /** + * Register API routes. + * + * @param BeforeApiRouteEvent $event The API route event + * + * @return void + */ + public function onBeforeApiRoute(BeforeApiRouteEvent $event): void + { + $router = $event->getRouter(); + + // All routes are handled by this plugin directly via custom callbacks + // because com_autotweet uses FOF, not standard Joomla MVC + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/channels', + [$this, 'getChannels'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/channels/:id', + [$this, 'getChannel'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/posts', + [$this, 'getPosts'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/posts/:id', + [$this, 'getPost'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/requests', + [$this, 'getRequests'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['POST'], + 'v1/perfectpublisher/requests', + [$this, 'createRequest'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/rules', + [$this, 'getRules'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/feeds', + [$this, 'getFeeds'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/channeltypes', + [$this, 'getChannelTypes'] + ) + ); + + $router->addRoute( + new \Joomla\Router\Route( + ['GET'], + 'v1/perfectpublisher/stats', + [$this, 'getStats'] + ) + ); + } + + /** + * GET /v1/perfectpublisher/channels + * + * @return void + */ + public function getChannels(): void + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + $limit = (int) $app->input->get('limit', 20); + $offset = (int) $app->input->get('offset', 0); + + $query = $db->getQuery(true) + ->select('c.*, ct.name AS channeltype_name, ct.max_chars') + ->from($db->quoteName('#__autotweet_channels', 'c')) + ->leftJoin( + $db->quoteName('#__autotweet_channeltypes', 'ct') + . ' ON ' . $db->quoteName('c.channeltype_id') + . ' = ' . $db->quoteName('ct.id') + ) + ->order($db->quoteName('c.ordering') . ' ASC'); + + $published = $app->input->get('published', null); + if ($published !== null) { + $query->where($db->quoteName('c.published') . ' = ' . (int) $published); + } + + $db->setQuery($query, $offset, $limit); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * GET /v1/perfectpublisher/channels/:id + * + * @return void + */ + public function getChannel(): void + { + $id = (int) Factory::getApplication()->input->get('id', 0); + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('c.*, ct.name AS channeltype_name, ct.max_chars, ct.description AS channeltype_desc') + ->from($db->quoteName('#__autotweet_channels', 'c')) + ->leftJoin( + $db->quoteName('#__autotweet_channeltypes', 'ct') + . ' ON ' . $db->quoteName('c.channeltype_id') + . ' = ' . $db->quoteName('ct.id') + ) + ->where($db->quoteName('c.id') . ' = ' . $id); + + $db->setQuery($query); + $result = $db->loadObject(); + + if (!$result) { + $this->sendJsonError('Channel not found', 404); + return; + } + + // Strip sensitive OAuth params + if (isset($result->params)) { + $params = json_decode($result->params, true); + if (is_array($params)) { + foreach (['access_token', 'access_secret', 'client_secret', 'api_secret', 'password'] as $key) { + if (isset($params[$key])) { + $params[$key] = '***'; + } + } + $result->params = json_encode($params); + } + } + + $this->sendJsonResponse($result); + } + + /** + * GET /v1/perfectpublisher/posts + * + * @return void + */ + public function getPosts(): void + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + $limit = (int) $app->input->get('limit', 20); + $offset = (int) $app->input->get('offset', 0); + + $query = $db->getQuery(true) + ->select('p.*, c.name AS channel_name') + ->from($db->quoteName('#__autotweet_posts', 'p')) + ->leftJoin( + $db->quoteName('#__autotweet_channels', 'c') + . ' ON ' . $db->quoteName('p.channel_id') + . ' = ' . $db->quoteName('c.id') + ) + ->order($db->quoteName('p.postdate') . ' DESC'); + + $pubstate = $app->input->get('pubstate', ''); + if ($pubstate !== '') { + $query->where($db->quoteName('p.pubstate') . ' = ' . $db->quote($pubstate)); + } + + $channel = (int) $app->input->get('channel_id', 0); + if ($channel > 0) { + $query->where($db->quoteName('p.channel_id') . ' = ' . $channel); + } + + $db->setQuery($query, $offset, $limit); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * GET /v1/perfectpublisher/posts/:id + * + * @return void + */ + public function getPost(): void + { + $id = (int) Factory::getApplication()->input->get('id', 0); + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('p.*, c.name AS channel_name, ct.name AS channeltype_name') + ->from($db->quoteName('#__autotweet_posts', 'p')) + ->leftJoin( + $db->quoteName('#__autotweet_channels', 'c') + . ' ON ' . $db->quoteName('p.channel_id') + . ' = ' . $db->quoteName('c.id') + ) + ->leftJoin( + $db->quoteName('#__autotweet_channeltypes', 'ct') + . ' ON ' . $db->quoteName('c.channeltype_id') + . ' = ' . $db->quoteName('ct.id') + ) + ->where($db->quoteName('p.id') . ' = ' . $id); + + $db->setQuery($query); + $result = $db->loadObject(); + + if (!$result) { + $this->sendJsonError('Post not found', 404); + return; + } + + $this->sendJsonResponse($result); + } + + /** + * GET /v1/perfectpublisher/requests + * + * @return void + */ + public function getRequests(): void + { + $db = Factory::getDbo(); + $app = Factory::getApplication(); + $limit = (int) $app->input->get('limit', 20); + $offset = (int) $app->input->get('offset', 0); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__autotweet_requests')) + ->order($db->quoteName('publish_up') . ' ASC'); + + $published = $app->input->get('published', null); + if ($published !== null) { + $query->where($db->quoteName('published') . ' = ' . (int) $published); + } + + $db->setQuery($query, $offset, $limit); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * POST /v1/perfectpublisher/requests + * + * Create a new publish request. Required fields: description. + * Optional: url, image_url, publish_up, plugin, priority. + * + * @return void + */ + public function createRequest(): void + { + $app = Factory::getApplication(); + $db = Factory::getDbo(); + $data = json_decode($app->input->json->getRaw(), true); + + if (empty($data['description'])) { + $this->sendJsonError('Field "description" is required', 400); + return; + } + + $now = Factory::getDate()->toSql(); + $user = Factory::getUser(); + + $row = (object) [ + 'ref_id' => $data['ref_id'] ?? null, + 'plugin' => $data['plugin'] ?? 'manual-api', + 'priority' => (int) ($data['priority'] ?? 5), + 'publish_up' => $data['publish_up'] ?? $now, + 'description' => $data['description'], + 'typeinfo' => (int) ($data['typeinfo'] ?? 0), + 'url' => $data['url'] ?? null, + 'image_url' => $data['image_url'] ?? null, + 'created' => $now, + 'created_by' => $user->id, + 'params' => json_encode($data['params'] ?? []), + 'published' => (int) ($data['published'] ?? 1), + ]; + + $db->insertObject('#__autotweet_requests', $row, 'id'); + + $this->sendJsonResponse( + ['id' => $row->id, 'status' => 'created'], + 201 + ); + } + + /** + * GET /v1/perfectpublisher/rules + * + * @return void + */ + public function getRules(): void + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('r.*, rt.name AS ruletype_name, rt.description AS ruletype_desc, c.name AS channel_name') + ->from($db->quoteName('#__autotweet_rules', 'r')) + ->leftJoin( + $db->quoteName('#__autotweet_ruletypes', 'rt') + . ' ON ' . $db->quoteName('r.ruletype_id') + . ' = ' . $db->quoteName('rt.id') + ) + ->leftJoin( + $db->quoteName('#__autotweet_channels', 'c') + . ' ON ' . $db->quoteName('r.channel_id') + . ' = ' . $db->quoteName('c.id') + ) + ->order($db->quoteName('r.ordering') . ' ASC'); + + $db->setQuery($query); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * GET /v1/perfectpublisher/feeds + * + * @return void + */ + public function getFeeds(): void + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__autotweet_feeds')) + ->order($db->quoteName('ordering') . ' ASC'); + + $db->setQuery($query); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * GET /v1/perfectpublisher/channeltypes + * + * @return void + */ + public function getChannelTypes(): void + { + $db = Factory::getDbo(); + + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__autotweet_channeltypes')) + ->order($db->quoteName('id') . ' ASC'); + + $db->setQuery($query); + + $this->sendJsonResponse($db->loadObjectList()); + } + + /** + * GET /v1/perfectpublisher/stats + * + * Dashboard statistics: post counts by status, channel counts, recent activity. + * + * @return void + */ + public function getStats(): void + { + $db = Factory::getDbo(); + + // Posts by status + $db->setQuery( + $db->getQuery(true) + ->select('pubstate, COUNT(*) AS total') + ->from($db->quoteName('#__autotweet_posts')) + ->group($db->quoteName('pubstate')) + ); + $postsByStatus = $db->loadObjectList('pubstate'); + + // Active channels + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*) AS total') + ->from($db->quoteName('#__autotweet_channels')) + ->where($db->quoteName('published') . ' = 1') + ); + $activeChannels = (int) $db->loadResult(); + + // Pending requests + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*) AS total') + ->from($db->quoteName('#__autotweet_requests')) + ->where($db->quoteName('published') . ' = 1') + ); + $pendingRequests = (int) $db->loadResult(); + + // Posts last 24h + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*) AS total') + ->from($db->quoteName('#__autotweet_posts')) + ->where($db->quoteName('postdate') . ' >= DATE_SUB(NOW(), INTERVAL 1 DAY)') + ); + $posts24h = (int) $db->loadResult(); + + // Posts last 7d + $db->setQuery( + $db->getQuery(true) + ->select('COUNT(*) AS total') + ->from($db->quoteName('#__autotweet_posts')) + ->where($db->quoteName('postdate') . ' >= DATE_SUB(NOW(), INTERVAL 7 DAY)') + ); + $posts7d = (int) $db->loadResult(); + + $this->sendJsonResponse([ + 'posts_by_status' => $postsByStatus, + 'active_channels' => $activeChannels, + 'pending_requests' => $pendingRequests, + 'posts_24h' => $posts24h, + 'posts_7d' => $posts7d, + ]); + } + + /** + * Send a JSON API response. + * + * @param mixed $data Response data + * @param int $status HTTP status code + * + * @return void + */ + private function sendJsonResponse($data, int $status = 200): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Status', (string) $status); + echo json_encode(['data' => $data], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $app->close(); + } + + /** + * Send a JSON error response. + * + * @param string $message Error message + * @param int $status HTTP status code + * + * @return void + */ + private function sendJsonError(string $message, int $status = 400): void + { + $app = Factory::getApplication(); + $app->setHeader('Content-Type', 'application/json; charset=utf-8'); + $app->setHeader('Status', (string) $status); + echo json_encode(['error' => $message], JSON_UNESCAPED_SLASHES); + $app->close(); + } +} diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml index e054e2f5..cd9ea8de 100644 --- a/src/pkg_mokowaas.xml +++ b/src/pkg_mokowaas.xml @@ -16,6 +16,7 @@ plg_system_mokowaas.zip com_mokowaas.zip plg_webservices_mokowaas.zip + plg_webservices_perfectpublisher.zip -- 2.52.0 From 94d45169ef3d126ca8959eff8efa4167fb88d98a Mon Sep 17 00:00:00 2001 From: "gitea-actions[bot]" Date: Thu, 28 May 2026 17:24:51 +0000 Subject: [PATCH 2/2] chore(version): patch bump to 02.13.02 [skip ci] --- README.md | 2 +- src/packages/com_mokowaas/mokowaas.xml | 2 +- src/packages/plg_system_mokowaas/mokowaas.xml | 2 +- src/packages/plg_webservices_mokowaas/mokowaas.xml | 2 +- .../plg_webservices_perfectpublisher/perfectpublisher.xml | 2 +- src/pkg_mokowaas.xml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cfd372c9..7b526ef6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ DEFGROUP: Joomla.Plugin INGROUP: MokoWaaS REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoWaaS - VERSION: 02.13.01 + VERSION: 02.13.02 PATH: /README.md BRIEF: MokoWaaS platform plugin for Joomla --> diff --git a/src/packages/com_mokowaas/mokowaas.xml b/src/packages/com_mokowaas/mokowaas.xml index 347d1079..d4945372 100644 --- a/src/packages/com_mokowaas/mokowaas.xml +++ b/src/packages/com_mokowaas/mokowaas.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.13.01 + 02.13.02 Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups. Moko\Component\MokoWaaS\Api diff --git a/src/packages/plg_system_mokowaas/mokowaas.xml b/src/packages/plg_system_mokowaas/mokowaas.xml index 332cae08..7139d84c 100644 --- a/src/packages/plg_system_mokowaas/mokowaas.xml +++ b/src/packages/plg_system_mokowaas/mokowaas.xml @@ -30,7 +30,7 @@ GNU General Public License version 3 or later; see LICENSE.md hello@mokoconsulting.tech https://mokoconsulting.tech - 02.13.01 + 02.13.02 This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform. Moko\Plugin\System\MokoWaaS script.php diff --git a/src/packages/plg_webservices_mokowaas/mokowaas.xml b/src/packages/plg_webservices_mokowaas/mokowaas.xml index e7a5e26c..2ad8d3b6 100644 --- a/src/packages/plg_webservices_mokowaas/mokowaas.xml +++ b/src/packages/plg_webservices_mokowaas/mokowaas.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.13.01 + 02.13.02 Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info. Moko\Plugin\WebServices\MokoWaaS diff --git a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml index fd1c3724..06638098 100644 --- a/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml +++ b/src/packages/plg_webservices_perfectpublisher/perfectpublisher.xml @@ -7,7 +7,7 @@ GPL-3.0-or-later hello@mokoconsulting.tech https://mokoconsulting.tech - 02.13.01 + 02.13.02 Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds. Moko\Plugin\WebServices\PerfectPublisher diff --git a/src/pkg_mokowaas.xml b/src/pkg_mokowaas.xml index cd9ea8de..2d2e6e92 100644 --- a/src/pkg_mokowaas.xml +++ b/src/pkg_mokowaas.xml @@ -2,7 +2,7 @@ MokoWaaS mokowaas - 02.13.01 + 02.13.02 2026-05-23 Moko Consulting hello@mokoconsulting.tech -- 2.52.0