mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Allow custom payment gateway images (#4519)
* Add custom payment gateway images Allow image uploads and URL imports with AVIF and PNG browser fallback. Preserve sanitized SVG artwork and keep custom images outside packaged assets. * Fix gateway image CI checks and upgrade coverage * Limit gateway raster images to four million pixels
This commit is contained in:
1 parent
dfb57671d1
commit
5489acb148
19 files changed
+973
-13
No files matched your search
@@ -12,6 +12,7 @@
|
||||
!/src/data/log/index.html
|
||||
!/src/data/uploads/index.html
|
||||
/src/public/assets
|
||||
/src/public/gateways/custom/
|
||||
/src/vendor/*
|
||||
build
|
||||
|
||||
|
||||
+4
-1
@@ -105,7 +105,10 @@
|
||||
"Bakker",
|
||||
"Novakova",
|
||||
"nosniff",
|
||||
"pjpeg"
|
||||
"pjpeg",
|
||||
"imageavif",
|
||||
"webp",
|
||||
"webmoney"
|
||||
],
|
||||
|
||||
"ignoreWords": [
|
||||
|
||||
@@ -416,7 +416,7 @@ const API = {
|
||||
|
||||
const formMethod = (formElement.getAttribute('method') || 'post').toLowerCase();
|
||||
const data = formMethod !== 'get'
|
||||
? Tools.serializeFormDataToJSON(formData)
|
||||
? (formElement.enctype === 'multipart/form-data' ? formData : Tools.serializeFormDataToJSON(formData))
|
||||
: Tools.serializeFormData(formData);
|
||||
|
||||
const buttons = formElement.querySelectorAll('button:not([disabled])');
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const testGlobal = globalThis as any;
|
||||
const forms: any[] = [];
|
||||
const NativeFormData = FormData;
|
||||
|
||||
testGlobal.window = { FOSSBilling: { ready() {} }, location: { origin: 'https://billing.example' } };
|
||||
testGlobal.document = { cookie: '', querySelectorAll: () => forms };
|
||||
testGlobal.FormData = class extends NativeFormData {
|
||||
constructor(form: any) {
|
||||
super();
|
||||
for (const [name, value] of form.fields) this.append(name, value);
|
||||
}
|
||||
};
|
||||
await import('../api.ts');
|
||||
|
||||
test('API form binding preserves uploaded files and nested fields in multipart requests', async () => {
|
||||
let submit: (event: any) => void;
|
||||
const image = new File(['image bytes'], 'cards.png', { type: 'image/png' });
|
||||
forms.push({
|
||||
dataset: {},
|
||||
enctype: 'multipart/form-data',
|
||||
fields: [['title', 'Cards'], ['config[key]', 'value'], ['logo_file', image]],
|
||||
addEventListener(_event: string, handler: any) { submit = handler; },
|
||||
getAttribute(name: string) { return name === 'method' ? 'post' : '/api/admin/invoice/gateway_update'; },
|
||||
querySelectorAll() { return []; },
|
||||
});
|
||||
let body: FormData;
|
||||
const api = testGlobal.window.FOSSBilling.api;
|
||||
api.makeRequest = (method: string, url: string, params: FormData) => {
|
||||
assert.equal(method, 'post');
|
||||
assert.equal(url, 'https://billing.example/api/admin/invoice/gateway_update');
|
||||
body = params;
|
||||
};
|
||||
api._apiForm();
|
||||
submit!({ preventDefault() {} });
|
||||
|
||||
assert.ok(body! instanceof NativeFormData);
|
||||
assert.equal(body!.get('title'), 'Cards');
|
||||
assert.equal(body!.get('config[key]'), 'value');
|
||||
assert.equal(await (body!.get('logo_file') as File).text(), 'image bytes');
|
||||
});
|
||||
|
||||
test('ordinary API forms still submit JSON', () => {
|
||||
let submit: (event: any) => void;
|
||||
forms.length = 0;
|
||||
forms.push({
|
||||
dataset: {},
|
||||
enctype: 'application/x-www-form-urlencoded',
|
||||
fields: [['title', 'Cards'], ['config[key]', 'value']],
|
||||
addEventListener(_event: string, handler: any) { submit = handler; },
|
||||
getAttribute(name: string) { return name === 'method' ? 'post' : '/api/admin/invoice/gateway_update'; },
|
||||
querySelectorAll() { return []; },
|
||||
});
|
||||
const api = testGlobal.window.FOSSBilling.api;
|
||||
api.makeRequest = (_method: string, _url: string, params: string) => {
|
||||
assert.deepEqual(JSON.parse(params), { title: 'Cards', config: { key: 'value' } });
|
||||
};
|
||||
api._apiForm();
|
||||
submit!({ preventDefault() {} });
|
||||
});
|
||||
@@ -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','128',0,NULL,NULL,NOW(),NOW()),
|
||||
(1,'last_patch','129',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()),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*/
|
||||
|
||||
namespace FOSSBilling\Sanitizer;
|
||||
|
||||
/** Rebuild static SVG artwork with a restricted set of elements and attributes. */
|
||||
final class GatewaySvgSanitizer
|
||||
{
|
||||
private const string SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
||||
private const array ELEMENTS = [
|
||||
'svg', 'g', 'defs', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
|
||||
'text', 'tspan', 'title', 'desc', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern',
|
||||
];
|
||||
private const array ATTRIBUTES = [
|
||||
'id', 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'width', 'height',
|
||||
'viewBox', 'preserveAspectRatio', 'd', 'points', 'transform', 'opacity',
|
||||
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
|
||||
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
|
||||
'clip-path', 'clip-rule', 'mask', 'maskUnits', 'maskContentUnits',
|
||||
'gradientUnits', 'gradientTransform', 'spreadMethod', 'offset', 'stop-color', 'stop-opacity',
|
||||
'patternUnits', 'patternContentUnits', 'patternTransform',
|
||||
'font-family', 'font-size', 'font-weight', 'font-style', 'text-anchor', 'dominant-baseline', 'dx', 'dy', 'color',
|
||||
];
|
||||
private const array STYLE_PROPERTIES = [
|
||||
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-opacity',
|
||||
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset',
|
||||
'opacity', 'stop-color', 'stop-opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'text-anchor', 'color',
|
||||
];
|
||||
|
||||
public static function sanitize(string $bytes): string
|
||||
{
|
||||
$source = new \DOMDocument();
|
||||
if (!@$source->loadXML($bytes, LIBXML_NONET) || $source->doctype !== null
|
||||
|| $source->documentElement?->localName !== 'svg'
|
||||
|| !in_array($source->documentElement->namespaceURI, [null, self::SVG_NAMESPACE], true)) {
|
||||
throw new \FOSSBilling\Exception('Use a valid PNG, JPEG, WebP, AVIF, or SVG image.');
|
||||
}
|
||||
|
||||
$clean = new \DOMDocument('1.0', 'UTF-8');
|
||||
$clean->appendChild(self::copyElement($source->documentElement, $clean, 0));
|
||||
|
||||
return $clean->saveXML($clean->documentElement);
|
||||
}
|
||||
|
||||
private static function copyElement(\DOMElement $source, \DOMDocument $document, int $depth): \DOMElement
|
||||
{
|
||||
if ($depth > 64) {
|
||||
throw new \FOSSBilling\Exception('SVG image is too deeply nested.');
|
||||
}
|
||||
$element = $document->createElementNS(self::SVG_NAMESPACE, $source->localName);
|
||||
foreach ($source->attributes as $attribute) {
|
||||
if ($attribute->namespaceURI !== null) {
|
||||
continue;
|
||||
}
|
||||
if (in_array($attribute->name, self::ATTRIBUTES, true) && self::isSafeValue($attribute->value)) {
|
||||
$element->setAttribute($attribute->name, $attribute->value);
|
||||
} elseif ($attribute->name === 'style') {
|
||||
$declarations = [];
|
||||
foreach (explode(';', $attribute->value) as $declaration) {
|
||||
$parts = explode(':', $declaration, 2);
|
||||
if (count($parts) === 2 && in_array(trim($parts[0]), self::STYLE_PROPERTIES, true) && self::isSafeValue(trim($parts[1]))) {
|
||||
$declarations[] = trim($parts[0]) . ':' . trim($parts[1]);
|
||||
}
|
||||
}
|
||||
$element->setAttribute('style', implode(';', $declarations));
|
||||
}
|
||||
}
|
||||
foreach ($source->childNodes as $child) {
|
||||
if ($child instanceof \DOMElement && in_array($child->localName, self::ELEMENTS, true)
|
||||
&& in_array($child->namespaceURI, [null, self::SVG_NAMESPACE], true)) {
|
||||
$element->appendChild(self::copyElement($child, $document, $depth + 1));
|
||||
} elseif ($child instanceof \DOMText && in_array($source->localName, ['text', 'tspan', 'title', 'desc'], true)) {
|
||||
$element->appendChild($document->createTextNode($child->textContent));
|
||||
}
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
|
||||
private static function isSafeValue(string $value): bool
|
||||
{
|
||||
// Only local paint-server references are permitted. CSS escapes,
|
||||
// at-rules, external URLs, and arbitrary functions cannot survive.
|
||||
$value = preg_replace('/url\(\s*#[a-zA-Z_][a-zA-Z0-9_.-]*\s*\)/i', '', $value);
|
||||
$value = preg_replace('/(?:matrix|translate|scale|rotate|skewX|skewY|rgb|rgba|hsl|hsla)\([0-9\s.,%+\/-]*\)/i', '', $value);
|
||||
|
||||
return preg_match('/\A[a-zA-Z0-9\s#.,%+\-]*\z/', $value) === 1;
|
||||
}
|
||||
}
|
||||
@@ -1492,6 +1492,7 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
126 => 'patch126',
|
||||
127 => 'patch127',
|
||||
128 => 'patch128',
|
||||
129 => 'patch129',
|
||||
];
|
||||
ksort($patches, SORT_NATURAL);
|
||||
|
||||
@@ -4787,6 +4788,13 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function patch129(): void
|
||||
{
|
||||
if (!$this->tableHasColumn('pay_gateway', 'custom_logo')) {
|
||||
$this->executeSql('ALTER TABLE `pay_gateway` ADD COLUMN `custom_logo` VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a database-collision-free slug derived from $base for reconciliation.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,7 @@ use Box\Mod\Order\Entity\Order;
|
||||
use FOSSBilling\InformationException;
|
||||
use FOSSBilling\PaginationOptions;
|
||||
use FOSSBilling\Validation\Api\RequiredParams;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
@@ -934,6 +935,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
* Change gateway settings.
|
||||
*
|
||||
* @optional string $title - gateway title
|
||||
* @optional string $logo_url - public HTTP(S) image URL to download and store locally; blank keeps the current image
|
||||
* @optional file $logo_file - custom image uploaded in a multipart request; takes precedence over logo_url
|
||||
* @optional bool $remove_custom_logo - restore the adapter's default image
|
||||
* @optional array $config - gateway config array
|
||||
* @optional array $accepted_currencies - list of currencies this gateway supports
|
||||
* @optional bool $enabled - flag to enable or disable gateway
|
||||
@@ -956,7 +960,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
}
|
||||
$gatewayService = $this->getDi()['mod_service']('Invoice', 'PayGateway');
|
||||
|
||||
return $gatewayService->update($model, $data);
|
||||
$file = $this->getDi()['request']->files->get('logo_file');
|
||||
if ($file !== null && !$file instanceof UploadedFile) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway image upload failed.');
|
||||
}
|
||||
|
||||
return $gatewayService->update($model, $data, $file);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,6 +48,9 @@ class PayGateway
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $config = null;
|
||||
|
||||
#[ORM\Column(name: 'custom_logo', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $customLogo = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -148,4 +151,16 @@ class PayGateway
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCustomLogo(): ?string
|
||||
{
|
||||
return $this->customLogo;
|
||||
}
|
||||
|
||||
public function setCustomLogo(?string $customLogo): self
|
||||
{
|
||||
$this->customLogo = $customLogo;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,22 @@ use Box\Mod\Invoice\Entity\Subscription;
|
||||
use Box\Mod\Invoice\Entity\Transaction;
|
||||
use Box\Mod\Invoice\Repository\PayGatewayRepository;
|
||||
use FOSSBilling\InjectionAwareInterface;
|
||||
use FOSSBilling\Sanitizer\GatewaySvgSanitizer;
|
||||
use FOSSBilling\Tools;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Filesystem\Path;
|
||||
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
|
||||
class ServicePayGateway implements InjectionAwareInterface
|
||||
{
|
||||
private const int MAX_LOGO_BYTES = 2 * 1024 * 1024;
|
||||
private const int MAX_LOGO_PIXELS = 4_000_000;
|
||||
|
||||
/**
|
||||
* Sent by the admin UI in place of a secret config field's value to mean
|
||||
* "keep the currently stored value". Blank input means the same thing;
|
||||
@@ -175,6 +184,8 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
$result['enabled'] = $model->isEnabled();
|
||||
$result['test_mode'] = $model->isTestMode();
|
||||
$result['callback'] = $this->getCallbackUrl($model);
|
||||
$result['custom_logo_url'] = $this->getCustomLogoUrl($model);
|
||||
$result['custom_logo_avif_url'] = $this->getCustomLogoUrl($model, true);
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -223,6 +234,7 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
$new->setAcceptedCurrencies($model->getAcceptedCurrencies());
|
||||
$new->setTestMode($model->isTestMode());
|
||||
$new->setConfig($model->getConfig());
|
||||
$new->setCustomLogo($model->getCustomLogo());
|
||||
$this->di['em']->persist($new);
|
||||
$this->di['em']->flush();
|
||||
$newId = (int) $new->getId();
|
||||
@@ -231,10 +243,8 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
return $newId;
|
||||
}
|
||||
|
||||
public function update(PayGateway $model, array $data): bool
|
||||
public function update(PayGateway $model, array $data, ?UploadedFile $file = null): bool
|
||||
{
|
||||
$model->setName($data['title'] ?? $model->getName());
|
||||
|
||||
$newEnabled = isset($data['enabled']) ? (bool) $data['enabled'] : $model->isEnabled();
|
||||
$newTestMode = isset($data['test_mode']) ? (bool) $data['test_mode'] : $model->isTestMode();
|
||||
$existingConfig = json_decode($model->getConfig() ?? '', true) ?? [];
|
||||
@@ -252,6 +262,18 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
$this->validateGatewayConfig($model, $mergedConfig, $newTestMode);
|
||||
}
|
||||
|
||||
$customLogo = $model->getCustomLogo();
|
||||
if (Tools::normalizeBoolean($data['remove_custom_logo'] ?? false)) {
|
||||
$customLogo = null;
|
||||
} elseif ($file !== null) {
|
||||
$customLogo = $this->storeUploadedLogo($file);
|
||||
} elseif (array_key_exists('logo_url', $data) && $data['logo_url'] !== '') {
|
||||
$customLogo = $this->importGatewayLogo($data['logo_url']);
|
||||
}
|
||||
|
||||
$model->setName($data['title'] ?? $model->getName());
|
||||
$model->setCustomLogo($customLogo);
|
||||
|
||||
if (isset($data['config']) && is_array($data['config'])) {
|
||||
$model->setConfig(json_encode($mergedConfig));
|
||||
}
|
||||
@@ -373,6 +395,10 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
];
|
||||
}
|
||||
|
||||
if (($customLogoUrl = $this->getCustomLogoUrl($gtw)) !== null) {
|
||||
$gateway['logo'] = ['logo' => $customLogoUrl, 'avif' => $this->getCustomLogoUrl($gtw, true), 'height' => '50px', 'width' => '150px'];
|
||||
}
|
||||
|
||||
$result[] = $gateway;
|
||||
}
|
||||
}
|
||||
@@ -380,6 +406,160 @@ class ServicePayGateway implements InjectionAwareInterface
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getCustomLogoUrl(PayGateway $model, bool $avif = false): ?string
|
||||
{
|
||||
$filename = $model->getCustomLogo();
|
||||
if ($avif) {
|
||||
if ($filename === null || !str_ends_with($filename, '.png')) {
|
||||
return null;
|
||||
}
|
||||
$filename = substr($filename, 0, -4) . '.avif';
|
||||
}
|
||||
if ($filename === null || !preg_match('/\A[a-f0-9]{64}\.(?:png|jpg|webp|avif|svg)\z/', $filename)
|
||||
|| !$this->filesystem->exists(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->di['tools']->url('/public/gateways/custom/' . $filename);
|
||||
}
|
||||
|
||||
/** Import once; client pages always use the stored local image. */
|
||||
private function importGatewayLogo(mixed $url): string
|
||||
{
|
||||
if (!is_string($url) || strlen($url) > 2048 || !filter_var($url, FILTER_VALIDATE_URL)
|
||||
|| !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
|
||||
|| parse_url($url, PHP_URL_USER) !== null || parse_url($url, PHP_URL_PASS) !== null) {
|
||||
throw new \FOSSBilling\Exception('Enter a valid HTTP or HTTPS image URL without credentials.');
|
||||
}
|
||||
|
||||
$client = new NoPrivateNetworkHttpClient($this->di['http_client']);
|
||||
$response = null;
|
||||
|
||||
try {
|
||||
$response = $client->request('GET', $url, [
|
||||
'max_redirects' => 3,
|
||||
'timeout' => 5,
|
||||
'max_duration' => 10,
|
||||
'on_progress' => static function (int $downloaded, int $size): void {
|
||||
if ($downloaded > self::MAX_LOGO_BYTES || $size > self::MAX_LOGO_BYTES) {
|
||||
throw new TransportException('Payment gateway image is too large.');
|
||||
}
|
||||
},
|
||||
]);
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \FOSSBilling\Exception('Could not download the payment gateway image.');
|
||||
}
|
||||
$bytes = '';
|
||||
foreach ($client->stream($response) as $chunk) {
|
||||
$bytes .= $chunk->getContent();
|
||||
if (strlen($bytes) > self::MAX_LOGO_BYTES) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
|
||||
}
|
||||
}
|
||||
} catch (ExceptionInterface) {
|
||||
throw new \FOSSBilling\Exception('Could not download the payment gateway image. Use a publicly accessible image URL, no larger than 2 MB.');
|
||||
} finally {
|
||||
$response?->cancel();
|
||||
}
|
||||
|
||||
return $this->storeGatewayLogo($bytes);
|
||||
}
|
||||
|
||||
private function storeUploadedLogo(UploadedFile $file): string
|
||||
{
|
||||
if (!$file->isValid()) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway image upload failed.');
|
||||
}
|
||||
if ($file->getSize() > self::MAX_LOGO_BYTES) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
|
||||
}
|
||||
|
||||
return $this->storeGatewayLogo($this->filesystem->readFile($file->getPathname()));
|
||||
}
|
||||
|
||||
private function storeGatewayLogo(string $bytes): string
|
||||
{
|
||||
if (strlen($bytes) > self::MAX_LOGO_BYTES) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway images must be no larger than 2 MB.');
|
||||
}
|
||||
|
||||
$avif = null;
|
||||
$image = @getimagesizefromstring($bytes);
|
||||
$extension = $image === false ? null : match ($image['mime']) {
|
||||
'image/png' => 'png',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/webp' => 'webp',
|
||||
'image/avif' => 'avif',
|
||||
default => null,
|
||||
};
|
||||
if ($image === false) {
|
||||
$bytes = GatewaySvgSanitizer::sanitize($bytes);
|
||||
$extension = 'svg';
|
||||
} elseif ($extension === null || $image[0] <= 0 || $image[1] <= 0 || $image[0] * $image[1] > self::MAX_LOGO_PIXELS) {
|
||||
throw new \FOSSBilling\Exception('Use a PNG, JPEG, WebP, AVIF, or SVG image with no more than 4 million pixels.');
|
||||
} else {
|
||||
[$bytes, $extension, $avif] = $this->normalizeRasterLogo($bytes, $extension);
|
||||
}
|
||||
if (strlen($bytes) > self::MAX_LOGO_BYTES || ($avif !== null && strlen($avif) > self::MAX_LOGO_BYTES)) {
|
||||
throw new \FOSSBilling\Exception('The processed payment gateway image exceeds 2 MB. Use a smaller image.');
|
||||
}
|
||||
|
||||
// Gateway copies share stored images.
|
||||
$basename = hash('sha256', $bytes);
|
||||
$filename = $basename . '.' . $extension;
|
||||
$this->filesystem->dumpFile(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename), $bytes);
|
||||
if ($avif !== null) {
|
||||
$this->filesystem->dumpFile(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $basename . '.avif'), $avif);
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/** @return array{string, string, ?string} PNG fallback (or unsupported original), extension, optional AVIF. */
|
||||
private function normalizeRasterLogo(string $bytes, string $extension): array
|
||||
{
|
||||
if (!function_exists('imagecreatefromstring') || !function_exists('imagepng')) {
|
||||
return [$bytes, $extension, null];
|
||||
}
|
||||
$type = match ($extension) {
|
||||
'png' => IMG_PNG,
|
||||
'jpg' => IMG_JPG,
|
||||
'webp' => IMG_WEBP,
|
||||
'avif' => IMG_AVIF,
|
||||
default => throw new \LogicException('Unsupported raster image format.'),
|
||||
};
|
||||
if (!(imagetypes() & $type) || !(imagetypes() & IMG_PNG)) {
|
||||
return [$bytes, $extension, null];
|
||||
}
|
||||
$image = @imagecreatefromstring($bytes);
|
||||
if ($image === false) {
|
||||
throw new \FOSSBilling\Exception('Could not decode the payment gateway image.');
|
||||
}
|
||||
imagepalettetotruecolor($image);
|
||||
imagesavealpha($image, true);
|
||||
$png = $this->encodeRasterLogo($image, 'imagepng', 6);
|
||||
$avif = function_exists('imageavif') && (imagetypes() & IMG_AVIF)
|
||||
? $this->encodeRasterLogo($image, 'imageavif', 80)
|
||||
: null;
|
||||
|
||||
return [$png, 'png', $avif];
|
||||
}
|
||||
|
||||
private function encodeRasterLogo(\GdImage $image, string $encoder, int $quality): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
if (!@$encoder($image, null, $quality) || ob_get_length() === 0) {
|
||||
throw new \FOSSBilling\Exception('Could not process the payment gateway image.');
|
||||
}
|
||||
|
||||
return (string) ob_get_contents();
|
||||
} finally {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
|
||||
public function resolveGatewayLogo(array $logoConfig): string
|
||||
{
|
||||
$filename = $logoConfig['logo'] ?? 'default.png';
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ 'invoice/gateway_update'|api_url }}" {{ fb_api_form({message: 'Gateway Updated'|trans}) }}>
|
||||
<form method="post" action="{{ 'invoice/gateway_update'|api_url }}" enctype="multipart/form-data" {{ fb_api_form({message: 'Gateway Updated'|trans, reload: true}) }}>
|
||||
{% embed 'partial_admin_form_card.html.twig' with {
|
||||
title: 'Gateway Basics'|trans,
|
||||
subtitle: 'Define the display title and choose which currencies can use this gateway.'|trans,
|
||||
@@ -49,6 +49,26 @@
|
||||
<label class="form-label" for="gateway_title">{{ 'Payment Gateway Title'|trans }}</label>
|
||||
<input class="form-control" id="gateway_title" type="text" name="title" value="{{ gateway.title }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="gateway_logo_file">{{ 'Custom Image'|trans }}</label>
|
||||
<input class="form-control" id="gateway_logo_file" type="file" name="logo_file" accept=".png,.jpg,.jpeg,.webp,.avif,.svg">
|
||||
<div class="form-text">{{ 'PNG, JPEG, WebP, AVIF, or SVG, up to 2 MB. Leave empty to keep the current image. SVG sanitization may remove unsupported elements or styles.'|trans }}</div>
|
||||
<label class="form-label mt-2" for="gateway_logo_url">{{ 'Or Import from URL'|trans }}</label>
|
||||
<input class="form-control" id="gateway_logo_url" type="url" name="logo_url" maxlength="2048" placeholder="https://example.com/payment-methods.png">
|
||||
<div class="form-text">{{ 'Use a publicly accessible image URL. Images are saved locally and preserved during updates.'|trans }}</div>
|
||||
{% if gateway.custom_logo_url %}
|
||||
<picture>
|
||||
{% if gateway.custom_logo_avif_url %}
|
||||
<source srcset="{{ gateway.custom_logo_avif_url }}" type="image/avif">
|
||||
{% endif %}
|
||||
<img class="mt-2" src="{{ gateway.custom_logo_url }}" alt="{{ 'Custom payment gateway image'|trans }}" style="max-width: 150px; max-height: 50px;">
|
||||
</picture>
|
||||
{% endif %}
|
||||
<label class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="remove_custom_logo" value="1">
|
||||
<span class="form-check-label">{{ 'Restore Default Image'|trans }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="gateway_accepted_currencies">{{ 'Accepted Currencies'|trans }}</label>
|
||||
<div class="form-text mb-2">{{ 'Select which currencies can accept this payment gateway. Leave all unselected to allow all currencies.'|trans }}</div>
|
||||
@@ -200,6 +220,18 @@
|
||||
{% block js %}
|
||||
<script>
|
||||
(function () {
|
||||
const removeLogo = document.querySelector('input[name="remove_custom_logo"]');
|
||||
const logoUrl = document.getElementById('gateway_logo_url');
|
||||
const logoFile = document.getElementById('gateway_logo_file');
|
||||
if (removeLogo && logoUrl && logoFile) {
|
||||
const updateImageInputs = () => {
|
||||
logoUrl.disabled = removeLogo.checked || logoFile.files.length > 0;
|
||||
logoFile.disabled = removeLogo.checked;
|
||||
};
|
||||
removeLogo.addEventListener('change', updateImageInputs);
|
||||
logoFile.addEventListener('change', updateImageInputs);
|
||||
updateImageInputs();
|
||||
}
|
||||
const enabledCheckbox = document.querySelector('input[type="checkbox"][name="enabled"]');
|
||||
const testModeCheckbox = document.querySelector('input[type="checkbox"][name="test_mode"]');
|
||||
|
||||
|
||||
@@ -49,9 +49,16 @@
|
||||
<div class="invoice-gateway rounded-2 p-2">
|
||||
<input type="radio" class="btn-check border-0" name="gateway_id" gateway_id="{{ gtw.id }}" id="{{ gtw.id }}" autocomplete="off">
|
||||
<label class="btn" for="{{ gtw.id }}"
|
||||
style="background: transparent url('{{gtw.logo.logo}}') no-repeat center center; background-size: contain; height:{{gtw.logo.height}}; width:{{gtw.logo.width}};"
|
||||
style="height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }}; padding: 0;"
|
||||
onclick="paymentPrompt('{{ gtw.id }}', {{ gtw.allow_recurrent ? 'true' : 'false' }}, {{ gtw.allow_single ? 'true' : 'false' }}, `{{ gtw.title }}`)"
|
||||
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}"></label>
|
||||
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}">
|
||||
<picture class="d-block h-100">
|
||||
{% if gtw.logo.avif|default(null) %}
|
||||
<source srcset="{{ gtw.logo.avif }}" type="image/avif">
|
||||
{% endif %}
|
||||
<img src="{{ gtw.logo.logo }}" alt="{{ gtw.title }}" style="width: 100%; height: 100%; object-fit: contain;">
|
||||
</picture>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -1285,6 +1285,37 @@ test('updates a gateway', function (): void {
|
||||
expect($result)->toBeBool()->toBeTrue();
|
||||
});
|
||||
|
||||
test('uploads a gateway image from the multipart request', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$model = createEntity(PayGateway::class, ['id' => 1]);
|
||||
$file = Mockery::mock(Symfony\Component\HttpFoundation\File\UploadedFile::class);
|
||||
$service = Mockery::mock(ServicePayGateway::class);
|
||||
$service->shouldReceive('update')->once()->with($model, ['id' => 1, 'title' => 'Cards'], $file)->andReturn(true);
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$repo = Mockery::mock(PayGatewayRepository::class);
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
|
||||
$repo->shouldReceive('find')->with(1)->andReturn($model);
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['request']->files->set('logo_file', $file);
|
||||
$di['mod_service'] = $di->protect(moduleService(['invoice:paygateway' => $service]));
|
||||
$api->setDi($di);
|
||||
|
||||
expect($api->gateway_update(['id' => 1, 'title' => 'Cards']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('gateway image upload requires gateway management permission before processing the file', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$staff = Mockery::mock(Box\Mod\Staff\Service::class);
|
||||
$staff->shouldReceive('checkPermissionsAndThrowException')->once()->with('invoice', 'manage_gateways', null, null)
|
||||
->andThrow(new FOSSBilling\Exception('Denied'));
|
||||
$di = container();
|
||||
$di['mod_service'] = $di->protect(moduleService(['staff' => $staff]));
|
||||
$api->setDi($di);
|
||||
|
||||
expect(fn () => $api->gateway_update(['id' => 1]))->toThrow(FOSSBilling\Exception::class, 'Denied');
|
||||
});
|
||||
|
||||
test('deletes a gateway', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$data = [
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Invoice\Entity\PayGateway;
|
||||
use Box\Mod\Invoice\Repository\PayGatewayRepository;
|
||||
use Box\Mod\Invoice\ServicePayGateway;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Filesystem\Path;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
use function Tests\Helpers\container;
|
||||
use function Tests\Helpers\createEntity;
|
||||
|
||||
function gatewayLogoPng(): string
|
||||
{
|
||||
return base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAYAAA4AAbGa6gYAAAAASUVORK5CYII=');
|
||||
}
|
||||
|
||||
function gatewayLogoAvif(): string
|
||||
{
|
||||
return base64_decode('AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADrbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAOcGl0bQAAAAAAAQAAAB5pbG9jAAAAAEQAAAEAAQAAAAEAAAETAAAAIgAAAChpaW5mAAAAAAABAAAAGmluZmUCAAAAAAEAAGF2MDFDb2xvcgAAAABqaXBycAAAAEtpcGNvAAAAFGlzcGUAAAAAAAAAAgAAAAIAAAAQcGl4aQAAAAADCAgIAAAADGF2MUOBAAwAAAAAE2NvbHJuY2x4AAEADQABgAAAABdpcG1hAAAAAAAAAAEAAQQBAoMEAAAAKm1kYXQSAAoIGAA2iAhoDCAyFBeHh4YhhJJJJlAAAABIVDZhrGGr');
|
||||
}
|
||||
|
||||
function gatewayLogoCanNormalize(string $extension): bool
|
||||
{
|
||||
if (!function_exists('imagecreatefromstring') || !function_exists('imagepng')) {
|
||||
return false;
|
||||
}
|
||||
$support = gd_info();
|
||||
$format = match ($extension) {
|
||||
'png' => 'PNG Support',
|
||||
'jpg' => 'JPEG Support',
|
||||
'webp' => 'WebP Support',
|
||||
'avif' => 'AVIF Support',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return ($support['PNG Support'] ?? false) && ($support[$format] ?? false);
|
||||
}
|
||||
|
||||
function gatewayLogoService(MockHttpClient $client, Filesystem $filesystem): ServicePayGateway
|
||||
{
|
||||
$di = container();
|
||||
$repo = Mockery::mock(PayGatewayRepository::class);
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
|
||||
$em->shouldReceive('flush')->byDefault();
|
||||
$di['em'] = $em;
|
||||
$di['http_client'] = $client;
|
||||
$di['filesystem'] = $filesystem;
|
||||
$di['url'] = Mockery::mock(FOSSBilling\Url::class);
|
||||
$di['url']->shouldReceive('link')->andReturn('https://billing.example/');
|
||||
$service = new ServicePayGateway();
|
||||
$service->setDi($di);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
function captureGatewayLogo(Filesystem $filesystem): object
|
||||
{
|
||||
$stored = new stdClass();
|
||||
$stored->files = [];
|
||||
$filesystem->shouldReceive('dumpFile')->atLeast()->once()->andReturnUsing(static function (string $path, string $bytes) use ($stored): void {
|
||||
$stored->files[basename($path)] = $bytes;
|
||||
if (!isset($stored->bytes)) {
|
||||
$stored->bytes = $bytes;
|
||||
$stored->filename = basename($path);
|
||||
}
|
||||
expect($path)->toBe(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', basename($path)));
|
||||
});
|
||||
|
||||
return $stored;
|
||||
}
|
||||
|
||||
test('gateway image import stores detected image bytes locally and does not retain the source URL', function (): void {
|
||||
$bytes = gatewayLogoPng();
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$client = new MockHttpClient(new MockResponse($bytes));
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
expect($service->update($gateway, ['logo_url' => 'https://8.8.8.8/image.php']))->toBeTrue();
|
||||
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.png')
|
||||
->and($gateway->getConfig())->toBeNull();
|
||||
});
|
||||
|
||||
test('gateway image is preserved on ordinary updates and can be reset without fetching', function (): void {
|
||||
$client = new MockHttpClient(static fn () => throw new LogicException('Must not fetch'));
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$filename = str_repeat('a', 64) . '.png';
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => $filename]);
|
||||
|
||||
$service->update($gateway, ['title' => 'Cards']);
|
||||
$service->update($gateway, ['logo_url' => '']);
|
||||
expect($gateway->getCustomLogo())->toBe($filename);
|
||||
$service->update($gateway, ['remove_custom_logo' => '0']);
|
||||
expect($gateway->getCustomLogo())->toBe($filename);
|
||||
$service->update($gateway, ['remove_custom_logo' => '1', 'logo_url' => 'https://8.8.8.8/logo.png']);
|
||||
expect($gateway->getCustomLogo())->toBeNull();
|
||||
});
|
||||
|
||||
test('gateway image rejects invalid or private URLs without making a request or changing settings', function (mixed $url): void {
|
||||
$client = new MockHttpClient(static fn () => throw new LogicException('Must not fetch'));
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$service->getDi()['em']->shouldReceive('flush')->never();
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'name' => 'Original', 'customLogo' => 'original.png']);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['title' => 'Changed', 'logo_url' => $url]))->toThrow(FOSSBilling\Exception::class);
|
||||
expect($gateway->getName())->toBe('Original')->and($gateway->getCustomLogo())->toBe('original.png');
|
||||
})->with([
|
||||
'file:///etc/passwd', 'ftp://8.8.8.8/logo.png', 'https://',
|
||||
'https://user:pass@8.8.8.8/logo.png', 'http://127.0.0.1/logo.png',
|
||||
'http://169.254.169.254/logo.png', 'http://192.168.1.1/logo.png',
|
||||
'http://[::1]/logo.png', 'http://localhost/logo.png',
|
||||
[null], [['https://8.8.8.8/logo.png']],
|
||||
]);
|
||||
|
||||
test('gateway image rejects unsuccessful or unsafe downloads', function (string $bytes, int $status): void {
|
||||
$client = new MockHttpClient(new MockResponse($bytes, ['http_code' => $status]));
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$service->getDi()['em']->shouldReceive('flush')->never();
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'original.png']);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
|
||||
expect($gateway->getCustomLogo())->toBe('original.png');
|
||||
})->with([
|
||||
'HTML' => ['<html>not an image</html>', 200],
|
||||
'invalid SVG' => ['<svg><path></svg>', 200],
|
||||
// cspell:disable-next-line
|
||||
'GIF' => [base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'), 200],
|
||||
'oversized' => [str_repeat('x', 2 * 1024 * 1024 + 1), 200],
|
||||
'not found' => ['', 404],
|
||||
]);
|
||||
|
||||
test('gateway image rejects redirects to private networks', function (): void {
|
||||
$client = new MockHttpClient(new MockResponse('', [
|
||||
'http_code' => 302,
|
||||
'response_headers' => ['location: http://127.0.0.1/logo.png'],
|
||||
'redirect_url' => 'http://127.0.0.1/logo.png',
|
||||
]));
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
|
||||
});
|
||||
|
||||
test('active gateways use the imported local image and fall back if it is missing', function (bool $exists): void {
|
||||
$filename = str_repeat('a', 64) . '.png';
|
||||
$gateway = createEntity(PayGateway::class, ['gateway' => 'Custom', 'customLogo' => $filename, 'acceptedCurrencies' => '["USD"]']);
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', $filename))->andReturn($exists);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom', str_repeat('a', 64) . '.avif'))->andReturn(false);
|
||||
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
|
||||
$service->getPayGatewayRepository()->shouldReceive('findEnabledOrderedByIdDesc')->andReturn([$gateway]);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_LIBRARY, 'Payment', 'Adapter', 'default.png'))->andReturn(false);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'default.png'))->andReturn(true);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_LIBRARY, 'Payment', 'Adapter', 'custom.png'))->andReturn(false);
|
||||
$filesystem->shouldReceive('exists')->with(Path::join(PATH_ROOT, 'public', 'gateways', 'custom.png'))->andReturn(false);
|
||||
|
||||
$logo = $service->getActive([])[0]['logo'];
|
||||
expect($logo['logo'])->toBe(SYSTEM_URL . ($exists ? 'public/gateways/custom/' . $filename : 'public/gateways/default.png'));
|
||||
})->with([true, false]);
|
||||
|
||||
test('gateway image imports through a public redirect', function (): void {
|
||||
$bytes = gatewayLogoPng();
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$client = new MockHttpClient([
|
||||
new MockResponse('', ['http_code' => 302, 'response_headers' => ['location: https://1.1.1.1/logo.png'], 'redirect_url' => 'https://1.1.1.1/logo.png']),
|
||||
new MockResponse($bytes),
|
||||
]);
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
|
||||
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.png');
|
||||
});
|
||||
|
||||
test('gateway image rejects a changed connection address and excessive advertised size', function (string $ip, int $size): void {
|
||||
$client = new MockHttpClient(static function (string $method, string $url, array $options) use ($ip, $size): MockResponse {
|
||||
$options['on_progress'](0, $size, ['primary_ip' => $ip, 'url' => $url]);
|
||||
|
||||
return new MockResponse(gatewayLogoPng());
|
||||
});
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService($client, $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))->toThrow(FOSSBilling\Exception::class);
|
||||
})->with([['127.0.0.1', 0], ['8.8.8.8', 2 * 1024 * 1024 + 1]]);
|
||||
|
||||
test('gateway settings render image import and default restoration controls', function (?string $imageUrl): void {
|
||||
$renderer = new Tests\Support\StrictTemplateRenderer();
|
||||
$html = $renderer->renderTemplate(PATH_MODS . '/Invoice/templates/admin/mod_invoice_gateway.html.twig', [
|
||||
'gateway' => [
|
||||
'id' => 1, 'title' => 'Cards', 'description' => null, 'form' => [], 'custom_logo_url' => $imageUrl, 'custom_logo_avif_url' => $imageUrl === null ? null : str_replace('.png', '.avif', $imageUrl),
|
||||
'accepted_currencies' => ['USD'], 'supports_one_time_payments' => true, 'supports_subscriptions' => false,
|
||||
'enabled' => true, 'test_mode' => false, 'allow_single' => true, 'callback' => 'https://billing.example/ipn.php',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($html)->toContain('name="logo_url"')->toContain('name="remove_custom_logo"')
|
||||
->toContain('name="logo_file"')->toContain('enctype="multipart/form-data"');
|
||||
if ($imageUrl !== null) {
|
||||
expect($html)->toContain('src="' . $imageUrl . '"')->toContain('type="image/avif"')
|
||||
->toContain('srcset="' . str_replace('.png', '.avif', $imageUrl) . '"');
|
||||
}
|
||||
})->with([null, 'https://billing.example/public/gateways/custom/' . str_repeat('a', 64) . '.png']);
|
||||
|
||||
test('gateway image upload detects the format from bytes rather than the filename', function (string $bytes, string $extension): void {
|
||||
$path = tempnam(sys_get_temp_dir(), 'gateway-logo-');
|
||||
$realFilesystem = new Filesystem();
|
||||
$realFilesystem->dumpFile($path, $bytes);
|
||||
|
||||
try {
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('readFile')->with($path)->andReturn($bytes);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$service = gatewayLogoService(new MockHttpClient(static fn () => throw new LogicException('Must not fetch')), $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'old.png']);
|
||||
$file = new UploadedFile($path, 'misleading.gif', 'image/gif', UPLOAD_ERR_OK, true);
|
||||
|
||||
expect($service->update($gateway, ['title' => 'Cards', 'logo_url' => 'ignored because a file is selected'], $file))->toBeTrue();
|
||||
expect($gateway->getName())->toBe('Cards');
|
||||
$outputExtension = gatewayLogoCanNormalize($extension) ? 'png' : $extension;
|
||||
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.' . $outputExtension);
|
||||
if ($extension === 'svg') {
|
||||
expect($stored->bytes)->not->toContain('<script');
|
||||
expect($stored->files)->toHaveCount(1);
|
||||
} else {
|
||||
$original = getimagesizefromstring($bytes);
|
||||
$processed = getimagesizefromstring($stored->bytes);
|
||||
expect($processed[0])->toBe($original[0])->and($processed[1])->toBe($original[1])->and($processed['mime'])->toBe(gatewayLogoCanNormalize($extension) ? 'image/png' : $original['mime']);
|
||||
}
|
||||
} finally {
|
||||
$realFilesystem->remove($path);
|
||||
}
|
||||
})->with([
|
||||
'PNG' => [gatewayLogoPng(), 'png'],
|
||||
'AVIF' => [gatewayLogoAvif(), 'avif'],
|
||||
'legacy file with misleading JPEG extension' => [(new Filesystem())->readFile(PATH_ROOT . '/public/gateways/webmoney.jpg'), 'png'],
|
||||
'WebP' => [base64_decode('UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoCAAIAAUAmJaQAA3AA/v02aAA='), 'webp'],
|
||||
'SVG' => ['<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><path d="M0 0L1 1"/></svg>', 'svg'],
|
||||
]);
|
||||
|
||||
test('gateway image upload rejects errors, GIFs, and oversized files without changing the image', function (string $bytes, int $error): void {
|
||||
$path = tempnam(sys_get_temp_dir(), 'gateway-logo-');
|
||||
$realFilesystem = new Filesystem();
|
||||
$realFilesystem->dumpFile($path, $bytes);
|
||||
|
||||
try {
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('readFile')->with($path)->andReturn($bytes);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
|
||||
$service->getDi()['em']->shouldReceive('flush')->never();
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'customLogo' => 'original.png', 'name' => 'Original']);
|
||||
$file = new UploadedFile($path, 'logo.png', 'image/png', $error, true);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['title' => 'Changed'], $file))->toThrow(FOSSBilling\Exception::class);
|
||||
expect($gateway->getCustomLogo())->toBe('original.png')->and($gateway->getName())->toBe('Original');
|
||||
} finally {
|
||||
$realFilesystem->remove($path);
|
||||
}
|
||||
})->with([
|
||||
// cspell:disable-next-line
|
||||
'GIF' => [base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'), UPLOAD_ERR_OK],
|
||||
'too large' => [str_repeat('x', 2 * 1024 * 1024 + 1), UPLOAD_ERR_OK],
|
||||
'failed upload' => [gatewayLogoPng(), UPLOAD_ERR_PARTIAL],
|
||||
]);
|
||||
|
||||
test('gateway URL import supports AVIF and sanitizes SVG before storing it', function (string $bytes, string $extension): void {
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo']);
|
||||
$outputExtension = gatewayLogoCanNormalize($extension) ? 'png' : $extension;
|
||||
expect($gateway->getCustomLogo())->toBe(hash('sha256', $stored->bytes) . '.' . $outputExtension);
|
||||
if ($extension === 'svg') {
|
||||
expect($stored->bytes)->not->toContain('<script')->toContain('<path');
|
||||
} else {
|
||||
expect(getimagesizefromstring($stored->bytes)['mime'])->toBe(gatewayLogoCanNormalize('avif') ? 'image/png' : 'image/avif');
|
||||
}
|
||||
})->with([
|
||||
'AVIF' => [gatewayLogoAvif(), 'avif'],
|
||||
'SVG' => ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script>alert(1)</script><path d="M0 0L10 10"/></svg>', 'svg'],
|
||||
]);
|
||||
|
||||
test('gateway SVG sanitizer preserves artwork while removing active and external content', function (): void {
|
||||
$svg = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)" viewBox="0 0 10 10">'
|
||||
. '<defs><linearGradient id="g"><stop offset="0" stop-color="red"/></linearGradient></defs>'
|
||||
. '<style>@import "https://evil.example/style.css";</style><script>alert(1)</script>'
|
||||
. '<foreignObject><iframe src="https://evil.example"/></foreignObject>'
|
||||
. '<image href="https://evil.example/logo.png"/><use href="#recursive"/>'
|
||||
. '<path transform="translate(1, 2)" d="M0 0L10 10" fill="url(#g)" style="stroke:red;fill:url(https://evil.example/a);opacity:0.5"/>'
|
||||
. '<rect fill="url(https://evil.example/a)"/>'
|
||||
. '</svg>';
|
||||
$clean = FOSSBilling\Sanitizer\GatewaySvgSanitizer::sanitize($svg);
|
||||
|
||||
expect($clean)->toContain('linearGradient')->toContain('fill="url(#g)"')->toContain('transform="translate(1, 2)"')
|
||||
->toContain('style="stroke:red;opacity:0.5"')->not->toContain('evil.example')
|
||||
->not->toContain('<script')->not->toContain('onload')->not->toContain('foreignObject')->not->toContain('<use');
|
||||
});
|
||||
|
||||
test('gateway SVG rejects entities and non SVG documents', function (string $bytes): void {
|
||||
expect(fn () => FOSSBilling\Sanitizer\GatewaySvgSanitizer::sanitize($bytes))->toThrow(FOSSBilling\Exception::class);
|
||||
})->with([
|
||||
'<!DOCTYPE svg [<!ENTITY x SYSTEM "file:///etc/passwd">]><svg xmlns="http://www.w3.org/2000/svg">&x;</svg>',
|
||||
'<html><body>not svg</body></html>',
|
||||
'<svg xmlns="http://www.w3.org/1999/xhtml"/>',
|
||||
]);
|
||||
|
||||
test('raster normalization strips trailing data and preserves dimensions and transparency', function (): void {
|
||||
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAC0lEQVQImWNgQAcAABIAAW/6Y7cAAAAASUVORK5CYII=');
|
||||
$bytes = $png . 'private-trailing-data';
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
|
||||
|
||||
if (!function_exists('imagecreatefromstring')) {
|
||||
expect($stored->bytes)->toBe($bytes);
|
||||
|
||||
return;
|
||||
}
|
||||
expect($stored->bytes)->not->toContain('private-trailing-data');
|
||||
$image = imagecreatefromstring($stored->bytes);
|
||||
expect(imagesx($image))->toBe(2)->and(imagesy($image))->toBe(2)
|
||||
->and(imagecolorsforindex($image, imagecolorat($image, 0, 0))['alpha'])->toBe(127);
|
||||
});
|
||||
|
||||
test('raster normalization rejects a PNG with valid headers but corrupt image data', function (): void {
|
||||
if (!function_exists('imagecreatefromstring')) {
|
||||
$this->markTestSkipped('GD decoding is unavailable.');
|
||||
}
|
||||
$bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII=');
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']))
|
||||
->toThrow(FOSSBilling\Exception::class, 'Could not decode the payment gateway image.');
|
||||
});
|
||||
|
||||
test('raster images store PNG and at most one AVIF generated from the same decoded image', function (): void {
|
||||
$bytes = gatewayLogoPng();
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$stored = captureGatewayLogo($filesystem);
|
||||
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false]);
|
||||
$service->update($gateway, ['logo_url' => 'https://8.8.8.8/logo.png']);
|
||||
|
||||
$hasAvif = function_exists('imagecreatefromstring') && function_exists('imageavif') && (imagetypes() & IMG_AVIF);
|
||||
expect($stored->files)->toHaveCount($hasAvif ? 2 : 1);
|
||||
expect(getimagesizefromstring($stored->bytes)['mime'])->toBe('image/png');
|
||||
if ($hasAvif) {
|
||||
$avif = $stored->files[substr($gateway->getCustomLogo(), 0, -4) . '.avif'];
|
||||
$size = getimagesizefromstring($avif);
|
||||
expect($size['mime'])->toBe('image/avif')->and($size[0])->toBe(2)->and($size[1])->toBe(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('active gateways offer AVIF only when its stored companion exists', function (bool $hasAvif): void {
|
||||
$filename = str_repeat('a', 64) . '.png';
|
||||
$gateway = createEntity(PayGateway::class, ['gateway' => 'Custom', 'customLogo' => $filename, 'acceptedCurrencies' => '["USD"]']);
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('exists')->andReturnUsing(static fn (string $path): bool => str_ends_with($path, '/custom/' . $filename) || ($hasAvif && str_ends_with($path, '/custom/' . str_repeat('a', 64) . '.avif')));
|
||||
$service = gatewayLogoService(new MockHttpClient(), $filesystem);
|
||||
$service->getPayGatewayRepository()->shouldReceive('findEnabledOrderedByIdDesc')->andReturn([$gateway]);
|
||||
|
||||
$logo = $service->getActive([])[0]['logo'];
|
||||
expect($logo['logo'])->toBe(SYSTEM_URL . 'public/gateways/custom/' . $filename);
|
||||
expect($logo['avif'])->toBe($hasAvif ? SYSTEM_URL . 'public/gateways/custom/' . str_repeat('a', 64) . '.avif' : null);
|
||||
})->with([true, false]);
|
||||
|
||||
test('payment selectors render AVIF with one PNG fallback and support images without variants', function (string $template, bool $hasAvif): void {
|
||||
$png = 'https://billing.example/logo.png';
|
||||
$avif = 'https://billing.example/logo.avif';
|
||||
$gateway = [
|
||||
'id' => 1, 'title' => 'Cards', 'accepted_currencies' => ['USD'],
|
||||
'allow_single' => true, 'allow_recurrent' => false,
|
||||
'logo' => ['logo' => $png, 'height' => '50px', 'width' => '150px'],
|
||||
];
|
||||
if ($hasAvif) {
|
||||
$gateway['logo']['avif'] = $avif;
|
||||
}
|
||||
$guest = new Tests\Support\PermissiveStub([
|
||||
'invoice_gateways' => [$gateway],
|
||||
'cart_get' => new Tests\Support\PermissiveStub([
|
||||
'items' => [new Tests\Support\PermissiveStub(['id' => 1, 'title' => 'Service', 'quantity' => 1, 'total' => 10, 'discount_price' => 0, 'setup_price' => 0, 'discount_setup' => 0, 'form_id' => false, 'period' => false])],
|
||||
'currency' => ['code' => 'USD', 'conversion_rate' => 1], 'total' => 10, 'discount' => 0, 'subtotal' => 10,
|
||||
]),
|
||||
]);
|
||||
$html = (new Tests\Support\StrictTemplateRenderer())->renderTemplate(PATH_MODS . '/' . $template, [
|
||||
'app_area' => 'client', 'client' => null, 'guest' => $guest,
|
||||
'invoice' => new Tests\Support\PermissiveStub([
|
||||
'status' => 'unpaid', 'currency' => 'USD', 'items' => [],
|
||||
'credit_note_for_invoice_id' => false, 'debit_note_for_invoice_id' => false,
|
||||
'refunded_by_invoice_ids' => [], 'debited_by_invoice_ids' => [],
|
||||
'paid_at' => '', 'created_at' => '2026-10-12', 'tax' => 0, 'subtotal' => 10, 'total' => 10,
|
||||
'buyer' => new Tests\Support\PermissiveStub(['country' => 'US']),
|
||||
]),
|
||||
'request' => new Tests\Support\PermissiveStub(['checkout' => true, 'show_custom_form_values' => false]),
|
||||
]);
|
||||
expect($html)->toContain('<picture')->toContain('src="' . $png . '"');
|
||||
if ($hasAvif) {
|
||||
expect($html)->toContain('srcset="' . $avif . '" type="image/avif"');
|
||||
} else {
|
||||
expect($html)->not->toContain('type="image/avif"');
|
||||
}
|
||||
})->with([
|
||||
['Invoice/templates/client/mod_invoice_invoice.html.twig', true],
|
||||
['Invoice/templates/client/mod_invoice_invoice.html.twig', false],
|
||||
['Orderbutton/templates/client/mod_orderbutton_checkout.html.twig', true],
|
||||
['Orderbutton/templates/client/mod_orderbutton_checkout.html.twig', false],
|
||||
]);
|
||||
|
||||
test('gateway images above four million pixels are rejected before decoding or changing settings', function (): void {
|
||||
$bytes = gatewayLogoPng();
|
||||
// Change the PNG header dimensions without allocating a large decoded image.
|
||||
$header = substr($bytes, 12, 4) . pack('NN', 2000, 2001) . substr($bytes, 24, 5);
|
||||
$bytes = substr($bytes, 0, 12) . $header . hash('crc32b', $header, true) . substr($bytes, 33);
|
||||
expect(getimagesizefromstring($bytes)[0])->toBe(2000);
|
||||
expect(getimagesizefromstring($bytes)[1])->toBe(2001);
|
||||
|
||||
$filesystem = Mockery::mock(Filesystem::class);
|
||||
$filesystem->shouldReceive('dumpFile')->never();
|
||||
$service = gatewayLogoService(new MockHttpClient(new MockResponse($bytes)), $filesystem);
|
||||
$service->getDi()['em']->shouldReceive('flush')->never();
|
||||
$gateway = createEntity(PayGateway::class, ['enabled' => false, 'name' => 'Original', 'customLogo' => 'original.png']);
|
||||
|
||||
expect(fn () => $service->update($gateway, ['title' => 'Changed', 'logo_url' => 'https://8.8.8.8/logo.png']))
|
||||
->toThrow(FOSSBilling\Exception::class, 'no more than 4 million pixels');
|
||||
expect($gateway->getName())->toBe('Original')->and($gateway->getCustomLogo())->toBe('original.png');
|
||||
});
|
||||
@@ -14,6 +14,8 @@ use Box\Mod\Invoice\Entity\PayGateway;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\ORMSetup;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use FOSSBilling\Doctrine\SchemaSynchronizer;
|
||||
use Symfony\Component\Filesystem\Path;
|
||||
|
||||
function payGatewayEntityManager(): EntityManager
|
||||
@@ -25,6 +27,32 @@ function payGatewayEntityManager(): EntityManager
|
||||
return new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]), $config);
|
||||
}
|
||||
|
||||
test('custom gateway image persists independently of adapter credentials', function (): void {
|
||||
$em = payGatewayEntityManager();
|
||||
(new SchemaTool($em))->createSchema([$em->getClassMetadata(PayGateway::class)]);
|
||||
$gateway = (new PayGateway())->setGateway('Custom')->setConfig('{"api_key":"secret"}')->setCustomLogo(str_repeat('a', 64) . '.png');
|
||||
$em->persist($gateway);
|
||||
$em->flush();
|
||||
$id = $gateway->getId();
|
||||
$em->clear();
|
||||
|
||||
$stored = $em->find(PayGateway::class, $id);
|
||||
expect($stored->getCustomLogo())->toBe(str_repeat('a', 64) . '.png')
|
||||
->and($stored->getConfig())->toBe('{"api_key":"secret"}');
|
||||
});
|
||||
|
||||
test('schema synchronization adds custom images to existing gateways', function (): void {
|
||||
$em = payGatewayEntityManager();
|
||||
(new SchemaTool($em))->createSchema([$em->getClassMetadata(PayGateway::class)]);
|
||||
$em->getConnection()->executeStatement('ALTER TABLE pay_gateway DROP COLUMN custom_logo');
|
||||
$em->getConnection()->insert('pay_gateway', ['name' => 'Existing', 'enabled' => 1, 'allow_single' => 1, 'allow_recurrent' => 0, 'test_mode' => 0]);
|
||||
|
||||
SchemaSynchronizer::syncEntities($em, [PayGateway::class]);
|
||||
|
||||
$row = $em->getConnection()->fetchAssociative('SELECT name, custom_logo FROM pay_gateway');
|
||||
expect($row)->toBe(['name' => 'Existing', 'custom_logo' => null]);
|
||||
});
|
||||
|
||||
test('getSearchQueryBuilder orders by gateway ascending by default', function (): void {
|
||||
$repository = payGatewayEntityManager()->getRepository(PayGateway::class);
|
||||
|
||||
|
||||
@@ -168,11 +168,12 @@ test('copies a gateway', function (): void {
|
||||
'acceptedCurrencies' => null,
|
||||
'testMode' => false,
|
||||
'config' => null,
|
||||
'customLogo' => str_repeat('a', 64) . '.png',
|
||||
]);
|
||||
|
||||
$serviceMock = Mockery::mock(ServicePayGateway::class)->makePartial();
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('persist')->once();
|
||||
$em->shouldReceive('persist')->once()->with(Mockery::on(fn (PayGateway $copy): bool => $copy->getCustomLogo() === $payGateway->getCustomLogo()));
|
||||
$em->shouldReceive('flush')->once();
|
||||
$repo = Mockery::mock(PayGatewayRepository::class);
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn($repo);
|
||||
|
||||
@@ -184,9 +184,16 @@
|
||||
<input type="radio" class="btn-check border-0" name="gateway_id" id="{{ gateway_input_id }}" value="{{ gtw.id }}" autocomplete="off" {{ not gateway_selected ? 'checked' : '' }}/>
|
||||
{% if gtw.logo.logo %}
|
||||
<label class="btn" for="{{ gateway_input_id }}"
|
||||
style="background: transparent url('{{ gtw.logo.logo }}') no-repeat center center; background-size: contain; height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }};"
|
||||
style="height:{{ gtw.logo.height }}; width:{{ gtw.logo.width }}; padding: 0;"
|
||||
data-bs-toggle="tooltip" data-bs-title="{{ gtw.title }}"
|
||||
aria-label="{{ gtw.title }}"></label>
|
||||
aria-label="{{ gtw.title }}">
|
||||
<picture class="d-block h-100">
|
||||
{% if gtw.logo.avif|default(null) %}
|
||||
<source srcset="{{ gtw.logo.avif }}" type="image/avif">
|
||||
{% endif %}
|
||||
<img src="{{ gtw.logo.logo }}" alt="{{ gtw.title }}" style="width: 100%; height: 100%; object-fit: contain;">
|
||||
</picture>
|
||||
</label>
|
||||
{% else %}
|
||||
<label class="btn text-nowrap" for="{{ gateway_input_id }}">{{ gtw.title }}</label>
|
||||
{% endif %}
|
||||
|
||||
@@ -366,6 +366,7 @@
|
||||
"pay_gateway.allow_recurrent",
|
||||
"pay_gateway.allow_single",
|
||||
"pay_gateway.config",
|
||||
"pay_gateway.custom_logo",
|
||||
"pay_gateway.enabled",
|
||||
"pay_gateway.gateway",
|
||||
"pay_gateway.id",
|
||||
|
||||
@@ -3486,3 +3486,25 @@ test('custom page charset repair follows the promotion patch', function (): void
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 127);
|
||||
expect($patches)->toHaveKey(128)->and($patches[128][1])->toBe('patch128');
|
||||
});
|
||||
|
||||
test('custom gateway image migration adds the column and skips already migrated gateways', function (bool $migrated): void {
|
||||
$columns = Mockery::mock(PDOStatement::class);
|
||||
$columns->expects('execute')->with([])->andReturnTrue();
|
||||
$columns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn($migrated ? [['Field' => 'custom_logo']] : [['Field' => 'id']]);
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `pay_gateway`')->andReturn($columns);
|
||||
$sql = 'ALTER TABLE `pay_gateway` ADD COLUMN `custom_logo` VARCHAR(255) DEFAULT NULL';
|
||||
if ($migrated) {
|
||||
$pdo->shouldNotReceive('prepare')->with($sql);
|
||||
} else {
|
||||
$statement = Mockery::mock(PDOStatement::class);
|
||||
$statement->expects('execute')->with([])->andReturnTrue();
|
||||
$pdo->expects('prepare')->with($sql)->andReturn($statement);
|
||||
}
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
|
||||
(new ReflectionMethod($patcher, 'patch129'))->invoke($patcher);
|
||||
})->with([false, true]);
|
||||
Reference in new issue
Block a user