diff --git a/administrator/components/com_content/config.xml b/administrator/components/com_content/config.xml index 8b21fceaf25d6..586c250a0feb3 100644 --- a/administrator/components/com_content/config.xml +++ b/administrator/components/com_content/config.xml @@ -330,17 +330,6 @@ - -
- * @license GNU General Public License version 2 or later; see LICENSE.txt - */ - -namespace Joomla\Component\Content\Administrator\Service; - -// phpcs:disable PSR1.Files.SideEffects -\defined('_JEXEC') or die; -// phpcs:enable PSR1.Files.SideEffects - -/** - * Service class for generating and validating article preview tokens. - * - * Tokens are URL-safe, HMAC-SHA256 signed, and time-limited. - * They allow unauthenticated public access to article previews without - * requiring a frontend login or published state. - * - * Usage: - * $helper = new PreviewTokenService($app->get('secret')); - * $token = $helper->createToken($articleId, 24); - * $valid = $helper->validateToken($token, $articleId); - * - * @since 6.2.0 - */ -class PreviewTokenService -{ - /** - * The secret key used to sign and verify preview tokens. - * - * @var string - */ - private string $secret; - - /** - * @param string $secret The secret key - * - * @since 6.2.0 - */ - public function __construct(string $secret) - { - $this->secret = $secret; - } - - /** - * Generate a signed preview token for a given article. - * - * @param int $id The article ID. - * @param int $expiresInMinutes Number of minutes before the token expires. - * - * @return string - * - * @since 6.2.0 - */ - public function createToken(int $id, int $expiresInMinutes): string - { - $payload = $this->encode(json_encode([ - 'id' => $id, - 'exp' => time() + ($expiresInMinutes * 60), - ])); - - $signature = $this->encode(hash_hmac('sha256', $payload, $this->secret, true)); - - return $payload . '.' . $signature; - } - - /** - * Validate a preview token against a given article ID. - * - * Checks that the token: - * - Has a valid structure - * - Has not been tampered with (HMAC signature check) - * - Has not expired - * - Belongs to the expected article - * - * @param string $token The preview token from the URL. - * @param int $id The expected article ID. - * - * @return bool - * - * @since 6.2.0 - */ - public function validateToken(string $token, int $id): bool - { - $parts = explode('.', $token); - - if (\count($parts) !== 2) { - return false; - } - - [$payload, $signature] = $parts; - - if (!hash_equals($this->encode(hash_hmac('sha256', $payload, $this->secret, true)), $signature)) { - return false; - } - - $decoded = $this->decode($payload); - - if ($decoded === false) { - return false; - } - - $data = json_decode($decoded, true); - - if (!\is_array($data) || !isset($data['id'], $data['exp'])) { - return false; - } - - if (!\is_int($data['id']) || !\is_int($data['exp'])) { - return false; - } - - if ($data['id'] !== $id) { - return false; - } - - return time() < $data['exp']; - } - - /** - * Encode binary data to URL-safe Base64 string. - * - * @param string $data - * - * @return string - * - * @since 6.2.0 - */ - private function encode(string $data): string - { - return str_replace('=', '', strtr(base64_encode($data), '+/', '-_')); - } - - /** - * Decode a URL-safe Base64 string to binary data. - * - * @param string $data - * - * @return string|false - * - * @since 6.2.0 - */ - private function decode(string $data): string|false - { - if ($remainder = \strlen($data) % 4) { - $data .= str_repeat('=', 4 - $remainder); - } - - return base64_decode(strtr($data, '-_', '+/')); - } -} diff --git a/administrator/components/com_content/src/View/Article/HtmlView.php b/administrator/components/com_content/src/View/Article/HtmlView.php index 6ded1f8cbe799..cdebf91aacaf9 100644 --- a/administrator/components/com_content/src/View/Article/HtmlView.php +++ b/administrator/components/com_content/src/View/Article/HtmlView.php @@ -10,13 +10,11 @@ namespace Joomla\Component\Content\Administrator\View\Article; -use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Helper\ContentHelper; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\View\FormView; use Joomla\CMS\Toolbar\ToolbarHelper; -use Joomla\Component\Content\Administrator\Service\PreviewTokenService; use Joomla\Component\Content\Site\Helper\RouteHelper; // phpcs:disable PSR1.Files.SideEffects @@ -89,18 +87,6 @@ protected function initializeView() $url = RouteHelper::getArticleRoute($this->item->id . ':' . $this->item->alias, $this->item->catid, $this->item->language); - // Generate preview token if editing an existing article - if ($this->item->id > 0) { - $params = ComponentHelper::getParams('com_content'); - $expiration = (int) $params->get('preview_token_expiration', 15); - - $tokenHelper = new PreviewTokenService(Factory::getApplication()->get('secret')); - $token = $tokenHelper->createToken((int) $this->item->id, $expiration); - - // Append token using proper URL query parameter handling - $url .= (!str_contains($url, '?') ? '?' : '&') . 'preview_token=' . $token; - } - $this->previewLink = $url; $this->jooa11yLink = $url . '&jooa11y=1'; diff --git a/administrator/language/en-GB/com_content.ini b/administrator/language/en-GB/com_content.ini index 972328dfdf72e..72b75be7c2f00 100644 --- a/administrator/language/en-GB/com_content.ini +++ b/administrator/language/en-GB/com_content.ini @@ -88,8 +88,6 @@ COM_CONTENT_FIELD_NOTE_LABEL="Note" COM_CONTENT_FIELD_OPTION_ABOVE="Above" COM_CONTENT_FIELD_OPTION_BELOW="Below" COM_CONTENT_FIELD_OPTION_SPLIT="Split" -COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_DESC="Number of minutes before a preview token expires. Preview tokens allow unauthenticated users to view unpublished articles. Default is 15 minutes." -COM_CONTENT_FIELD_PREVIEW_TOKEN_EXPIRATION_LABEL="Preview Token Expiration (minutes)" COM_CONTENT_FIELD_PUBLISH_DOWN_LABEL="Finish Publishing" COM_CONTENT_FIELD_PUBLISH_UP_LABEL="Start Publishing" COM_CONTENT_FIELD_SELECT_ARTICLE_LABEL="Select Article" diff --git a/components/com_content/src/Model/ArticleModel.php b/components/com_content/src/Model/ArticleModel.php index 1c91f19f43229..6782dc4cbefdb 100644 --- a/components/com_content/src/Model/ArticleModel.php +++ b/components/com_content/src/Model/ArticleModel.php @@ -17,7 +17,6 @@ use Joomla\CMS\MVC\Model\ItemModel; use Joomla\CMS\Table\Content; use Joomla\Component\Content\Administrator\Extension\ContentComponent; -use Joomla\Component\Content\Administrator\Service\PreviewTokenService; use Joomla\Database\ParameterType; use Joomla\Registry\Registry; use Joomla\Utilities\IpHelper; @@ -69,17 +68,6 @@ protected function populateState() // If $pk is set then authorise on complete asset, else on component only $asset = empty($pk) ? 'com_content' : 'com_content.article.' . $pk; - // Validate preview token if present, before any permission checks. - $token = $app->getInput()->getString('preview_token', ''); - - if ($token !== '' && $pk > 0) { - $previewTokenHelper = new PreviewTokenService($app->get('secret')); - - if ($previewTokenHelper->validateToken($token, $pk)) { - $this->setState('article.preview', true); - } - } - if ((!$user->authorise('core.edit.state', $asset)) && (!$user->authorise('core.edit', $asset))) { $this->setState('filter.published', ContentComponent::CONDITION_PUBLISHED); $this->setState('filter.archived', ContentComponent::CONDITION_ARCHIVED); @@ -189,11 +177,8 @@ public function getItem($pk = null) $query->whereIn($db->quoteName('a.language'), [Factory::getLanguage()->getTag(), '*'], ParameterType::STRING); } - $isPreview = $this->getState('article.preview', false); - if ( - !$isPreview - && !$user->authorise('core.edit.state', 'com_content.article.' . $pk) + !$user->authorise('core.edit.state', 'com_content.article.' . $pk) && !$user->authorise('core.edit', 'com_content.article.' . $pk) ) { // Filter by start and end dates. @@ -222,7 +207,7 @@ public function getItem($pk = null) $published = $this->getState('filter.published'); $archived = $this->getState('filter.archived'); - if (!$isPreview && is_numeric($published)) { + if (is_numeric($published)) { $query->whereIn($db->quoteName('a.state'), [(int) $published, (int) $archived]); } @@ -235,7 +220,7 @@ public function getItem($pk = null) } // Check for published state if filter set. - if (!$isPreview && (is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) { + if ((is_numeric($published) || is_numeric($archived)) && ($data->state != $published && $data->state != $archived)) { throw new \Exception(Text::_('COM_CONTENT_ERROR_ARTICLE_NOT_FOUND'), 404); } @@ -277,8 +262,8 @@ public function getItem($pk = null) } // Compute view access permissions. - if ($isPreview || $this->getState('filter.access')) { - // Valid preview token or access filter set — user can view. + if ($this->getState('filter.access')) { + // If the access filter has been set, we already know this user can view. $data->params->set('access-view', true); } else { // If no access filter is set, the layout takes some responsibility for display of limited information.