Merge pull request 'feat: add Perfect Publisher web services API plugin' (#68) from dev into main
Universal: Cascade Main → Dev / Cascade main → branches (push) Successful in 4s
Joomla: Repo Health / Release configuration (push) Blocked by required conditions
Joomla: Repo Health / Scripts governance (push) Blocked by required conditions
Joomla: Repo Health / Repository health (push) Blocked by required conditions
Joomla: Repo Health / Access control (push) Successful in 1s

This commit was merged in pull request #68.
This commit is contained in:
2026-05-28 17:30:01 +00:00
9 changed files with 605 additions and 5 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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
-->
+1 -1
View File
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.13.01</version>
<version>02.13.02</version>
<description>Minimal API-only component for MokoWaaS. Provides REST endpoints for site health, cache, updates, and backups.</description>
<namespace path="api/src">Moko\Component\MokoWaaS\Api</namespace>
<administration>
@@ -30,7 +30,7 @@
<license>GNU General Public License version 3 or later; see LICENSE.md</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.13.01</version>
<version>02.13.02</version>
<description>This plugin rebrands the Joomla system interface with MokoWaaS identity. It applies language overrides and ensures consistent branding across the platform.</description>
<namespace path=".">Moko\Plugin\System\MokoWaaS</namespace>
<scriptfile>script.php</scriptfile>
@@ -7,7 +7,7 @@
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.13.01</version>
<version>02.13.02</version>
<description>Joomla Web Services API routes for MokoWaaS site management — health checks, cache, updates, backups, and site info.</description>
<namespace path="src">Moko\Plugin\WebServices\MokoWaaS</namespace>
<files>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - Perfect Publisher</name>
<author>Moko Consulting</author>
<creationDate>2026-05-28</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>02.13.02</version>
<description>Joomla Web Services API routes for Perfect Publisher (com_autotweet) — channels, posts, requests, rules, and feeds.</description>
<namespace path="src">Moko\Plugin\WebServices\PerfectPublisher</namespace>
<files>
<folder plugin="perfectpublisher">services</folder>
<folder>src</folder>
</files>
</extension>
@@ -0,0 +1,42 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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;
}
);
}
};
@@ -0,0 +1,539 @@
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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();
}
}
+2 -1
View File
@@ -2,7 +2,7 @@
<extension type="package" method="upgrade">
<name>MokoWaaS</name>
<packagename>mokowaas</packagename>
<version>02.13.01</version>
<version>02.13.02</version>
<creationDate>2026-05-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -16,6 +16,7 @@
<file type="plugin" id="plg_system_mokowaas" group="system">plg_system_mokowaas.zip</file>
<file type="component" id="com_mokowaas">com_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_mokowaas" group="webservices">plg_webservices_mokowaas.zip</file>
<file type="plugin" id="plg_webservices_perfectpublisher" group="webservices">plg_webservices_perfectpublisher.zip</file>
</files>
<updateservers>