mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Compare commits
4
Commits
af68a4fa1c
...
1b3550a6e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3550a6e6 | ||
|
|
40de3ca924 | ||
|
|
26b1cb3e5e | ||
|
|
300b021e30 |
No files matched your search
@@ -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
@@ -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/
|
||||
|
||||
@@ -622,9 +622,11 @@
|
||||
"unemailml",
|
||||
"listpkgs",
|
||||
"addpkg",
|
||||
"editpkg",
|
||||
"createacct",
|
||||
"setupreseller",
|
||||
"setacls",
|
||||
"statusmsg",
|
||||
"acllist",
|
||||
"creationtime",
|
||||
"domsecret",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user