Compare commits

...
4 Commits
Author SHA1 Message Date
Adam Daley 1b3550a6e6 Include homepage and custom pages in XML sitemap, fix product lastmod (#4566)
* Include homepage and custom pages in XML sitemap, fix product lastmod

* Add MySQL migration for custom_pages.updated_at (patch131)
2026-10-22 18:06:42 +01:00
Adam Daleyandopencode 40de3ca924 Fix WHM activation failing when package already exists (#4565)
* Fix WHM activation failing when package already exists (#4560)

- Request listpkgs with want=all and parse both legacy package and modern data.pkg shapes
- On addpkg already-exists, sync via editpkg and continue; only block activation on other errors
- Include WHM reason in Server_Exception and fix placeholder keys

* Add WHM API terms to spellcheck dictionary

* Address review: use typed exception for WHM already-exists, keep reasons out of API errors

- Add Server_PackageExistsException; request() now throws generic messages and translates already-exists at the WHM boundary
- checkPackageExists() catches the typed exception instead of matching getMessage()

* Fix Rector findings: remove unused catch variable and setAccessible call

* Handle modern WHM metadata.result failures in Whm::request

---------

Co-authored-by: opencode <opencode@localhost>
2026-10-22 17:03:35 +01:00
renovate[bot] 26b1cb3e5e Update phpmyadmin:5 Docker digest to 62a50b1 (#4563)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 14:26:52 +01:00
renovate[bot] 300b021e30 Update composer:2 Docker digest to da624c2 (#4562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 13:06:44 +01:00
18 changed files with 573 additions and 57 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
services:
phpmyadmin:
container_name: ddev-${DDEV_SITENAME}-phpmyadmin
image: ${PHPMYADMIN_DOCKER_IMAGE:-phpmyadmin:5@sha256:527a161fa6c0192cc66180e17e5541aaea028a23a9590e86ce08ecf7daa4fbc8}
image: ${PHPMYADMIN_DOCKER_IMAGE:-phpmyadmin:5@sha256:62a50b1419fe55b9b4c3cd6f5aed03a18f8f5b4a236f24735d76400bea75a5ff}
working_dir: "/root"
restart: "no"
labels:
+2 -2
View File
@@ -47,7 +47,7 @@ FROM php-base AS composer-base
WORKDIR /app
COPY --from=composer:2@sha256:d0c32820c62cc860fddd22adceed4eeda7698b093a7a88d503414172ce400626 /usr/bin/composer /usr/bin/composer
COPY --from=composer:2@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee /usr/bin/composer /usr/bin/composer
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
@@ -104,7 +104,7 @@ ARG INSTALL_TRANSLATIONS=true
ARG TRANSLATIONS_URL=https://github.com/FOSSBilling/locale/releases/latest/download/translations.zip
ARG TRANSLATIONS_SHA256=
COPY --from=composer:2@sha256:d0c32820c62cc860fddd22adceed4eeda7698b093a7a88d503414172ce400626 /usr/bin/composer /usr/bin/composer
COPY --from=composer:2@sha256:da624c2c705d54e2f5a9bb77a5dc6c7d091711433ebefd7035672e84c2010fee /usr/bin/composer /usr/bin/composer
COPY composer.json ./composer.json
COPY src ./src
COPY README.md LICENSE ./src/
+2
View File
@@ -622,9 +622,11 @@
"unemailml",
"listpkgs",
"addpkg",
"editpkg",
"createacct",
"setupreseller",
"setacls",
"statusmsg",
"acllist",
"creationtime",
"domsecret",
+1 -1
View File
@@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE;
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
VALUES
(1,'last_patch','130',0,NULL,NULL,NOW(),NOW()),
(1,'last_patch','131',0,NULL,NULL,NOW(),NOW()),
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
+18
View File
@@ -1497,6 +1497,7 @@ class UpdatePatcher implements InjectionAwareInterface
128 => 'patch128',
129 => 'patch129',
130 => 'patch130',
131 => 'patch131',
];
ksort($patches, SORT_NATURAL);
@@ -4815,6 +4816,23 @@ class UpdatePatcher implements InjectionAwareInterface
$this->migrateDefaultBrandingAssetsToPublicDirectory();
}
private function patch131(): void
{
// Custom pages gained an updated_at column (TimestampTrait) without which
// sitemap <lastmod> falls back to omitting the tag on installs the portable
// sync never healed - same pattern as patch119/patch120 (see #4392, #4553).
// The table only exists when the extension was activated (install() creates
// it), so skip absent tables; guards make reruns no-ops. Non-MySQL drivers
// get the column from the portable schema sync.
if (!$this->tableExists('custom_pages')) {
return;
}
if (!$this->tableHasColumn('custom_pages', 'updated_at')) {
$this->executeSql('ALTER TABLE `custom_pages` ADD COLUMN `updated_at` datetime DEFAULT NULL');
}
}
/**
* Find a database-collision-free slug derived from $base for reconciliation.
*
+151 -40
View File
@@ -534,30 +534,37 @@ class Server_Manager_Whm extends Server_Manager
*/
public function getPackages(): array
{
// Send a request to the WHM server to list the packages
$pkgs = $this->request('listpkgs');
// Send a request to the WHM server to list the packages.
// `want=all` ensures reseller tokens also see packages they cannot
// create accounts with, which plain `listpkgs` may filter out.
$pkgs = $this->request('listpkgs', ['want' => 'all']);
$packages = $this->extractPackageList($pkgs);
$return = [];
// Iterate over the packages and add their details to the return array
foreach ($pkgs->package as $pkg) {
foreach ($packages as $pkg) {
$name = (string) $this->getPackageField($pkg, ['name'], '');
if ($name === '') {
continue;
}
$return[] = [
'title' => $pkg->name,
'name' => $pkg->name,
'feature_list' => $pkg->FEATURELIST,
'theme' => $pkg->CPMOD,
'quota' => $pkg->QUOTA,
'bandwidth' => $pkg->BWLIMIT,
'max_ftp' => $pkg->MAXFTP,
'max_sql' => $pkg->MAXSQL,
'max_emails' => $pkg->MAXLST,
'max_sub' => $pkg->MAXSUB,
'max_pop' => $pkg->MAXPOP,
'max_park' => $pkg->MAXPARK,
'max_addon' => $pkg->MAXADDON,
'has_shell' => $pkg->HASSHELL == 'n' ? 0 : 1,
'has_ip' => $pkg->IP == 'n' ? 0 : 1,
'has_cgi' => $pkg->CGI == 'n' ? 0 : 1,
'has_frontpage' => $pkg->FRONTPAGE == 'n' ? 0 : 1,
'title' => $name,
'name' => $name,
'feature_list' => $this->getPackageField($pkg, ['FEATURELIST', 'featurelist']),
'theme' => $this->getPackageField($pkg, ['CPMOD', 'cpmod']),
'quota' => $this->getPackageField($pkg, ['QUOTA', 'quota']),
'bandwidth' => $this->getPackageField($pkg, ['BWLIMIT', 'bwlimit']),
'max_ftp' => $this->getPackageField($pkg, ['MAXFTP', 'maxftp']),
'max_sql' => $this->getPackageField($pkg, ['MAXSQL', 'maxsql']),
'max_emails' => $this->getPackageField($pkg, ['MAXLST', 'maxlst']),
'max_sub' => $this->getPackageField($pkg, ['MAXSUB', 'maxsub']),
'max_pop' => $this->getPackageField($pkg, ['MAXPOP', 'maxpop']),
'max_park' => $this->getPackageField($pkg, ['MAXPARK', 'maxpark']),
'max_addon' => $this->getPackageField($pkg, ['MAXADDON', 'maxaddon']),
'has_shell' => $this->getPackageField($pkg, ['HASSHELL', 'hasshell'], 'n') == 'n' ? 0 : 1,
'has_ip' => $this->getPackageField($pkg, ['IP', 'ip'], 'n') == 'n' ? 0 : 1,
'has_cgi' => $this->getPackageField($pkg, ['CGI', 'cgi'], 'n') == 'n' ? 0 : 1,
'has_frontpage' => $this->getPackageField($pkg, ['FRONTPAGE', 'frontpage'], 'n') == 'n' ? 0 : 1,
'free_registration' => 0,
'free_transfer' => 0,
'free_renewal' => 0,
@@ -633,37 +640,62 @@ class Server_Manager_Whm extends Server_Manager
}
if (isset($json->cpanelresult->error)) {
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->cpanelresult->error]);
$placeholders = ['action' => $action, 'type' => 'cPanel'];
$reason = (string) $json->cpanelresult->error;
throw new Server_Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
$this->throwWhmError($action, $reason);
}
if (isset($json->data->result) && $json->data->result == '0') {
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->data->reason]);
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
$reason = (string) ($json->data->reason ?? 'Unknown error');
throw new Server_Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
$this->throwWhmError($action, $reason);
}
if (isset($json->result) && is_array($json->result) && $json->result[0]->status == 0) {
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->result[0]->statusmsg]);
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
$reason = (string) ($json->result[0]->statusmsg ?? 'Unknown error');
throw new Server_Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
$this->throwWhmError($action, $reason);
}
if (isset($json->status) && $json->status != '1') {
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->statusmsg]);
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
$reason = (string) ($json->statusmsg ?? 'Unknown error');
throw new Server_Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
$this->throwWhmError($action, $reason);
}
if (isset($json->metadata->result) && $json->metadata->result == '0') {
$reason = (string) ($json->metadata->reason ?? 'Unknown error');
$this->throwWhmError($action, $reason);
}
// Return the response
return $json;
}
/**
* Logs the WHM failure reason and throws a generic exception.
*
* The full WHM reason stays in the logs and is never exposed to API
* callers or stored in order history. An "already exists" WHM response
* is translated to a dedicated exception so callers can handle it
* without matching on message text.
*
* @throws Server_Exception
*/
private function throwWhmError(string $action, string $reason): never
{
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $reason]);
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
$message = 'Failed to :action: on the :type: server, check the error logs for further details';
if (str_contains(strtolower($reason), 'already exists')) {
throw new Server_PackageExistsException($message, $placeholders);
}
throw new Server_Exception($message, $placeholders);
}
/**
* Checks if a package exists on the WHM server.
*
@@ -677,14 +709,16 @@ class Server_Manager_Whm extends Server_Manager
// Get the name of the package
$name = $this->getPackageName($package);
// Send a request to the WHM server to list the packages
$json = $this->request('listpkgs');
$packages = $json->package;
// Send a request to the WHM server to list the packages.
// `want=all` lists every package on the system instead of only the
// ones the API user may use during account creation (see #4560).
$json = $this->request('listpkgs', ['want' => 'all']);
$packages = $this->extractPackageList($json);
// Check if the package exists
$exists = false;
foreach ($packages as $p) {
if ($p->name == $name) {
if ((string) $this->getPackageField($p, ['name'], '') === $name) {
$exists = true;
break;
@@ -693,7 +727,38 @@ class Server_Manager_Whm extends Server_Manager
// If the package does not exist and the $create parameter is true, create the package
if (!$exists && $create) {
$varHash = [
$varHash = $this->buildPackageParams($package, $name);
try {
$this->request('addpkg', $varHash);
} catch (Server_PackageExistsException) {
// listpkgs only returns packages visible to the API user, while
// addpkg fails globally when the name is taken. When the
// package already exists (e.g. created manually or by another
// user), sync its limits via editpkg instead of aborting the
// account creation. See #4560.
try {
$this->request('editpkg', $varHash);
$this->getLog()->info('Updated existing WHM package {package}', ['package' => $name]);
} catch (Server_Exception $editException) {
// The package exists but cannot be edited (e.g. owned by
// another user). Account creation can still use it, so do
// not block activation - just log and continue.
$this->getLog()->warning('WHM package {package} already exists and could not be updated: {error}', ['package' => $name, 'error' => $editException->getMessage()]);
}
}
}
}
/**
* Builds the WHM API parameters for creating or updating a package.
* addpkg and editpkg share the same shape, differing only in the verb.
*
* @return array<string, mixed>
*/
private function buildPackageParams(Server_Package $package, string $name): array
{
return [
'name' => $name,
'quota' => $package->getQuota(),
'bwlimit' => $package->getBandwidth(),
@@ -708,10 +773,56 @@ class Server_Manager_Whm extends Server_Manager
'maxlst' => $package->getCustomValue('maxlst'),
'hasshell' => $package->getCustomValue('hasshell'),
];
// Send a request to the WHM server to add the package
$this->request('addpkg', $varHash);
}
/**
* Extracts the raw package entries from a listpkgs response.
* Handles both the legacy `{"package": [...]}` shape and the modern
* WHM API `{"data": {"pkg": [...]}}` shape.
*
* @return array<int, object>
*/
private function extractPackageList(mixed $json): array
{
$raw = null;
if (is_object($json)) {
if (isset($json->package)) {
$raw = $json->package;
} elseif (isset($json->data->pkg)) {
$raw = $json->data->pkg;
}
}
if ($raw === null) {
return [];
}
if (is_object($raw)) {
return [$raw];
}
return is_array($raw) ? array_values($raw) : [];
}
/**
* Case-insensitive field lookup for listpkgs entries, whose keys are
* upper-case in the legacy API and lower-case in the modern WHM API.
*/
private function getPackageField(mixed $pkg, array $candidates, mixed $default = null): mixed
{
if (!is_object($pkg)) {
return $default;
}
$lowered = array_change_key_case(get_object_vars($pkg), CASE_LOWER);
foreach ($candidates as $candidate) {
$lookup = strtolower((string) $candidate);
if (array_key_exists($lookup, $lowered)) {
return $lowered[$lookup];
}
}
return $default;
}
/**
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Server_PackageExistsException extends Server_Exception
{
}
+1 -1
View File
@@ -18,7 +18,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
/**
* Get paginated list of custom pages.
*
* @optional string $sort - sort column: 'id', 'title', 'slug' or 'created_at'
* @optional string $sort - sort column: 'id', 'title', 'slug', 'created_at' or 'updated_at'
* @optional string $direction - sort direction: 'ASC' or 'DESC'
*
* @return array
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
namespace Box\Mod\Custompages\Api;
class Guest extends \FOSSBilling\Api\AbstractApi
{
/**
* Get paginated list of custom pages.
*
* All custom pages are publicly accessible, so no visibility filtering applies.
*
* @optional string $sort - sort column: 'id', 'title', 'slug', 'created_at' or 'updated_at'
* @optional string $direction - sort direction: 'ASC' or 'DESC'
*
* @return array
*/
public function get_list($data)
{
return $this->getService()->searchPages($data);
}
/**
* Get a single custom page by ID or slug.
*
* @optional int $id - custom page ID
* @optional string $slug - custom page slug
*
* @return array
*
* @throws \FOSSBilling\InformationException when the page is missing or not found
*/
public function get($data)
{
if (!isset($data['id']) && !isset($data['slug'])) {
throw new \FOSSBilling\InformationException('Custom page ID or slug is missing');
}
$page = isset($data['slug'])
? $this->getService()->getPage((string) $data['slug'], 'slug')
: $this->getService()->getPage((int) $data['id']);
if ($page === null) {
throw new \FOSSBilling\InformationException('Custom page not found');
}
return $page;
}
}
@@ -13,16 +13,17 @@ namespace Box\Mod\Custompages\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\CreatedAtTrait;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\Entity(repositoryClass: \Box\Mod\Custompages\Repository\CustomPageRepository::class)]
#[ORM\Table(name: 'custom_pages')]
#[ORM\UniqueConstraint(name: 'uniq_custom_pages_slug', columns: ['slug'])]
#[ORM\HasLifecycleCallbacks]
class CustomPage implements ApiArrayInterface
class CustomPage implements ApiArrayInterface, TimestampInterface
{
use CreatedAtTrait;
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -55,6 +56,7 @@ class CustomPage implements ApiArrayInterface
'content' => $this->getContent(),
'slug' => $this->getSlug(),
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
@@ -47,6 +47,7 @@ class CustomPageRepository extends EntityRepository
'title' => 'p.title',
'slug' => 'p.slug',
'created_at' => 'p.createdAt',
'updated_at' => 'p.updatedAt',
]);
if ($sort->isSorted()) {
$qb->orderBy($sort->expression, $sort->direction);
+3
View File
@@ -110,12 +110,15 @@ class Service
$slug = $this->generateUniqueSlug($title);
try {
$now = date('Y-m-d H:i:s');
$connection->insert('custom_pages', [
'title' => $title,
'description' => $description ?? '',
'keywords' => $keywords ?? '',
'content' => $content,
'slug' => $slug,
'created_at' => $now,
'updated_at' => $now,
]);
$id = (int) $connection->lastInsertId();
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Box\Mod\Custompages\Api\Guest;
use Box\Mod\Custompages\Service;
test('guest get list delegates to search pages', function (): void {
$api = apiEndpoint(new Guest());
$serviceMock = Mockery::mock(Service::class);
$serviceMock->shouldReceive('searchPages')->once()->with(['per_page' => 500])->andReturn(['list' => [], 'total' => 0]);
$api->setService($serviceMock);
expect($api->get_list(['per_page' => 500]))->toBe(['list' => [], 'total' => 0]);
});
test('guest get by slug returns page', function (): void {
$api = apiEndpoint(new Guest());
$page = ['id' => 3, 'slug' => 'about', 'title' => 'About'];
$serviceMock = Mockery::mock(Service::class);
$serviceMock->shouldReceive('getPage')->once()->with('about', 'slug')->andReturn($page);
$api->setService($serviceMock);
expect($api->get(['slug' => 'about']))->toBe($page);
});
test('guest get by id returns page', function (): void {
$api = apiEndpoint(new Guest());
$page = ['id' => 3, 'slug' => 'about', 'title' => 'About'];
$serviceMock = Mockery::mock(Service::class);
$serviceMock->shouldReceive('getPage')->once()->with(3)->andReturn($page);
$api->setService($serviceMock);
expect($api->get(['id' => 3]))->toBe($page);
});
test('guest get throws when id and slug are missing', function (): void {
$api = apiEndpoint(new Guest());
$api->setService(Mockery::mock(Service::class));
expect(fn () => $api->get([]))->toThrow(FOSSBilling\InformationException::class, 'Custom page ID or slug is missing');
});
test('guest get throws when page is not found', function (): void {
$api = apiEndpoint(new Guest());
$serviceMock = Mockery::mock(Service::class);
$serviceMock->shouldReceive('getPage')->once()->with('missing', 'slug')->andReturn(null);
$api->setService($serviceMock);
expect(fn () => $api->get(['slug' => 'missing']))->toThrow(FOSSBilling\InformationException::class, 'Custom page not found');
});
@@ -75,6 +75,7 @@ test('get page by id returns api array when found', function (): void {
'content' => 'body',
'slug' => 'about',
'created_at' => null,
'updated_at' => null,
]);
});
@@ -94,6 +95,7 @@ test('get page by slug uses slug finder', function (): void {
'content' => 'C',
'slug' => 'docs',
'created_at' => null,
'updated_at' => null,
]);
});
@@ -1,36 +1,133 @@
<?xml version="1.0" encoding="UTF-8" ?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{# products #}
{% for i, product in guest.product_get_list({"per_page": 50}).list %}
{# homepage - always publicly accessible, no reliable modification date so <lastmod> is omitted #}
<url>
<loc>{{ ''|url }}</loc>
<priority>1.0</priority>
<changefreq>daily</changefreq>
</url>
{# products - paginated so large catalogs are not truncated at the first page #}
{% set products = guest.product_get_list({"per_page": 500}) %}
{% for i, product in products.list %}
<url>
<loc>{{ "order/#{product.slug}"|url }}</loc>
<lastmod>{{product.updated_at|default('now')|date('Y-m-d')}}</lastmod>
{% set lastmod = product.updated_at ?? product.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% if products.pages > 1 %}
{% for page_number in 2..products.pages %}
{% set products_page = guest.product_get_list({"per_page": 500, "page": page_number}) %}
{% for i, product in products_page.list %}
<url>
<loc>{{ "order/#{product.slug}"|url }}</loc>
{% set lastmod = product.updated_at ?? product.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% endfor %}
{% endif %}
{# news items #}
{% if guest.extension_is_on({"mod":'news'}) %}
{% for i, article in guest.news_get_list({"per_page": 50}).list %}
{% set news = guest.news_get_list({"per_page": 500}) %}
{% if news.total > 0 %}
<url>
<loc>{{ 'news'|url }}</loc>
<priority>0.8</priority>
<changefreq>weekly</changefreq>
</url>
{% endif %}
{% for i, article in news.list %}
<url>
<loc>{{ "news/#{article.slug}"|url }}</loc>
<lastmod>{{article.updated_at|date('Y-m-d')}}</lastmod>
{% set lastmod = article.updated_at ?? article.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% if news.pages > 1 %}
{% for page_number in 2..news.pages %}
{% set news_page = guest.news_get_list({"per_page": 500, "page": page_number}) %}
{% for i, article in news_page.list %}
<url>
<loc>{{ "news/#{article.slug}"|url }}</loc>
{% set lastmod = article.updated_at ?? article.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% endfor %}
{% endif %}
{% endif %}
{# knowledge base items #}
{% if guest.support_kb_enabled() %}
{% for i, article in guest.support_kb_article_get_list({"per_page":50}).list %}
{% set kb = guest.support_kb_article_get_list({"per_page": 500}) %}
{% if kb.total > 0 %}
<url>
<loc>{{ 'support/kb'|url }}</loc>
<priority>0.8</priority>
<changefreq>weekly</changefreq>
</url>
{% endif %}
{% for i, article in kb.list %}
<url>
<loc>{{ "support/kb/#{article.category.slug}/#{article.slug}"|url }}</loc>
<lastmod>{{article.updated_at|date('Y-m-d')}}</lastmod>
{% set lastmod = article.updated_at ?? article.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% if kb.pages > 1 %}
{% for page_number in 2..kb.pages %}
{% set kb_page = guest.support_kb_article_get_list({"per_page": 500, "page": page_number}) %}
{% for i, article in kb_page.list %}
<url>
<loc>{{ "support/kb/#{article.category.slug}/#{article.slug}"|url }}</loc>
{% set lastmod = article.updated_at ?? article.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.9</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% endfor %}
{% endif %}
{% endif %}
{# custom pages - public alias is /page/:slug (rewritten to /custompages/:slug) #}
{% if guest.extension_is_on({"mod":'custompages'}) %}
{% set pages = guest.custompages_get_list({"per_page": 500}) %}
{% for i, page in pages.list %}
<url>
<loc>{{ "page/#{page.slug}"|url }}</loc>
{% set lastmod = page.updated_at ?? page.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.8</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% if pages.pages > 1 %}
{% for page_number in 2..pages.pages %}
{% set pages_page = guest.custompages_get_list({"per_page": 500, "page": page_number}) %}
{% for i, page in pages_page.list %}
<url>
<loc>{{ "page/#{page.slug}"|url }}</loc>
{% set lastmod = page.updated_at ?? page.created_at ?? null %}
{% if lastmod %}<lastmod>{{ lastmod|date('Y-m-d') }}</lastmod>{% endif %}
<priority>0.8</priority>
<changefreq>weekly</changefreq>
</url>
{% endfor %}
{% endfor %}
{% endif %}
{% endif %}
</urlset>
+5 -2
View File
@@ -205,11 +205,14 @@ class Service implements InjectionAwareInterface
// Exposed publicly so the order form can be fetched during guest checkout.
'form_id' => $model->getFormId(),
// Exposed publicly so the XML sitemap can render accurate <lastmod>
// values without falling back to the current date.
'created_at' => $this->formatDateTimeValue($model->getCreatedAt()),
'updated_at' => $this->formatDateTimeValue($model->getUpdatedAt()),
];
if ($isAdmin) {
$result['created_at'] = $this->formatDateTimeValue($model->getCreatedAt());
$result['updated_at'] = $this->formatDateTimeValue($model->getUpdatedAt());
$result['suspension_grace_days'] = $model->getSuspensionGraceDays();
$result['addons'] = $addons;
$result['quantity_in_stock'] = $model->getQuantityInStock();
@@ -207,6 +207,7 @@
"custom_pages.keywords",
"custom_pages.slug",
"custom_pages.title",
"custom_pages.updated_at",
"email_queue.admin_id",
"email_queue.attachment_content",
"email_queue.attachment_mime",
+147
View File
@@ -114,3 +114,150 @@ test('createAccount never calls setupreseller or setacls for a non-reseller acco
$actions = array_map(actionOf(...), $requests);
expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct']);
});
test('createAccount skips addpkg when the package is already listed (legacy format)', function (): void {
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
if (str_contains($url, 'listpkgs')) {
return new MockResponse(json_encode(['package' => [['name' => 'admin_FOSSBilling']]]));
}
if (str_contains($url, 'createacct')) {
return new MockResponse(json_encode(['result' => [['status' => 1]]]));
}
return new MockResponse(json_encode(['status' => 1]));
});
$manager = createWhmManager($client);
$account = createWhmAccount(new Server_Package(), false);
expect($manager->createAccount($account))->toBeTrue();
$actions = array_map(actionOf(...), $requests);
expect($actions)->toBe(['listpkgs', 'createacct']);
});
test('createAccount skips addpkg when listpkgs uses the modern data.pkg format', function (): void {
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
if (str_contains($url, 'listpkgs')) {
return new MockResponse(json_encode(['data' => ['pkg' => [['name' => 'admin_FOSSBilling']]]]));
}
if (str_contains($url, 'createacct')) {
return new MockResponse(json_encode(['result' => [['status' => 1]]]));
}
return new MockResponse(json_encode(['status' => 1]));
});
$manager = createWhmManager($client);
$account = createWhmAccount(new Server_Package(), false);
expect($manager->createAccount($account))->toBeTrue();
$actions = array_map(actionOf(...), $requests);
expect($actions)->toBe(['listpkgs', 'createacct']);
});
test('createAccount updates the existing package via editpkg when addpkg reports already exists (see #4560)', function (): void {
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
if (str_contains($url, 'listpkgs')) {
return new MockResponse(json_encode(['package' => []]));
}
if (str_contains($url, 'addpkg')) {
return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists. If you wish to make changes, please edit the package.']]]));
}
if (str_contains($url, 'editpkg')) {
return new MockResponse(json_encode(['status' => 1]));
}
if (str_contains($url, 'createacct')) {
return new MockResponse(json_encode(['result' => [['status' => 1]]]));
}
return new MockResponse(json_encode(['status' => 1]));
});
$manager = createWhmManager($client);
$account = createWhmAccount(new Server_Package(), false);
expect($manager->createAccount($account))->toBeTrue();
$actions = array_map(actionOf(...), $requests);
expect($actions)->toBe(['listpkgs', 'addpkg', 'editpkg', 'createacct']);
});
test('createAccount still creates the account when editpkg also fails after already-exists (see #4560)', function (): void {
$requests = [];
$client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
if (str_contains($url, 'listpkgs')) {
return new MockResponse(json_encode(['package' => []]));
}
if (str_contains($url, 'addpkg')) {
return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists.']]]));
}
if (str_contains($url, 'editpkg')) {
return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => 'Permission denied']]]));
}
if (str_contains($url, 'createacct')) {
return new MockResponse(json_encode(['result' => [['status' => 1]]]));
}
return new MockResponse(json_encode(['status' => 1]));
});
$manager = createWhmManager($client);
$account = createWhmAccount(new Server_Package(), false);
expect($manager->createAccount($account))->toBeTrue();
$actions = array_map(actionOf(...), $requests);
expect($actions)->toBe(['listpkgs', 'addpkg', 'editpkg', 'createacct']);
});
test('createAccount rethrows addpkg failures unrelated to already-exists', function (): void {
$client = new MockHttpClient(function (string $method, string $url): MockResponse {
if (str_contains($url, 'listpkgs')) {
return new MockResponse(json_encode(['package' => []]));
}
if (str_contains($url, 'addpkg')) {
return new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => 'Invalid quota value']]]));
}
return new MockResponse(json_encode(['status' => 1]));
});
$manager = createWhmManager($client);
$account = createWhmAccount(new Server_Package(), false);
// The WHM reason stays in the logs; API callers only see the generic message.
expect(fn () => $manager->createAccount($account))->toThrow(Server_Exception::class, 'Failed to addpkg on the cPanel server');
});
test('addpkg already-exists does not leak the WHM reason to callers', function (): void {
$client = new MockHttpClient(fn (string $method, string $url): MockResponse => new MockResponse(json_encode(['result' => [['status' => 0, 'statusmsg' => '(XID n25fcu) The package "admin_FOSSBilling" already exists.']]])));
$manager = createWhmManager($client);
// Directly verify the typed exception carries the generic message, not the WHM reason.
$ref = new ReflectionMethod($manager, 'request');
try {
$ref->invoke($manager, 'addpkg', ['name' => 'admin_FOSSBilling']);
expect(false)->toBeTrue('Expected Server_PackageExistsException was not thrown');
} catch (Server_PackageExistsException $e) {
expect($e->getMessage())->not->toContain('already exists')
->and($e->getMessage())->toContain('Failed to addpkg');
}
});